605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||
import { MetaFunction } from "@remix-run/node";
|
||
import { safeGoBack } from "~/utils/helpers";
|
||
import {
|
||
useSellerData,
|
||
useGetSellerProducts,
|
||
} from "../requestHandler/use-seller-hooks";
|
||
import {
|
||
useGetInstagramPosts,
|
||
useInstagramSync,
|
||
} from "../requestHandler/use-instagram-hooks";
|
||
import {
|
||
ArrowRight,
|
||
Star,
|
||
ChevronDown,
|
||
X,
|
||
Menu,
|
||
GalleryThumbnailsIcon,
|
||
Plus,
|
||
Images,
|
||
Instagram,
|
||
Headset,
|
||
} from "lucide-react";
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
DrawerContent,
|
||
Drawer,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
} from "../components/ui/drawer";
|
||
import { Button } from "../components/ui/button";
|
||
import { Seller as SellerType } from "../../src/api/types";
|
||
import UiProvider from "../components/UiProvider";
|
||
import MondrianProductList from "../components/MondrianProductList";
|
||
import { useStoreOwnership } from "../hooks/useStoreOwnership";
|
||
import { LoadingModal } from "./store.add.manual.$storeId";
|
||
import SellerLogo from "../components/SellerLogo";
|
||
import CallDialog from "../components/CallDialog";
|
||
|
||
export default function Store() {
|
||
const { storeId } = useParams();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const { data } = useSellerData(storeId);
|
||
|
||
// Check store ownership - this will redirect if user doesn't own the store
|
||
const { isLoading: isOwnershipLoading } = useStoreOwnership(storeId);
|
||
|
||
// Get active tab from URL parameters, default to "products"
|
||
const activeTab =
|
||
(searchParams.get("tab") as "products" | "instagram") || "products";
|
||
|
||
// Function to update tab and URL
|
||
const setActiveTab = (tab: "products" | "instagram") => {
|
||
const newSearchParams = new URLSearchParams(searchParams);
|
||
newSearchParams.set("tab", tab);
|
||
setSearchParams(newSearchParams, { replace: true });
|
||
};
|
||
|
||
// Fetch Store products with infinite scroll
|
||
const {
|
||
data: productsData,
|
||
isLoading: isProductsLoading,
|
||
fetchNextPage: fetchNextProductsPage,
|
||
hasNextPage: hasNextProductsPage,
|
||
isFetchingNextPage: isFetchingNextProductsPage,
|
||
isError: isProductsError,
|
||
refetch: refetchProducts,
|
||
} = useGetSellerProducts(storeId || "");
|
||
|
||
// Fetch Instagram posts with infinite scroll
|
||
const {
|
||
data: instagramData,
|
||
isLoading: isInstagramLoading,
|
||
fetchNextPage: fetchNextInstagramPage,
|
||
hasNextPage: hasNextInstagramPage,
|
||
isFetchingNextPage: isFetchingNextInstagramPage,
|
||
isError: isInstagramError,
|
||
refetch: refetchInstagram,
|
||
} = useGetInstagramPosts();
|
||
|
||
const products = productsData?.pages.flatMap((page) => page.results) || [];
|
||
const instagramPosts =
|
||
instagramData?.pages.flatMap((page) => page.results) || [];
|
||
const [addProductDrawerOpen, setAddProductDrawerOpen] = useState(false);
|
||
const [instagramSyncDrawerOpen, setInstagramSyncDrawerOpen] = useState(false);
|
||
const [isCallModalOpen, setIsCallModalOpen] = useState(false);
|
||
const supportPhone = useMemo(
|
||
() => import.meta.env.VITE_SUPPORT_PHONE || "",
|
||
[]
|
||
);
|
||
|
||
const handleCallConfirm = () => {
|
||
window.location.href = `tel:${supportPhone}`;
|
||
setIsCallModalOpen(false);
|
||
};
|
||
|
||
// Show loading while checking ownership
|
||
if (isOwnershipLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[500px]">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col pb-20 static md:relative ">
|
||
<SellerDetailHeader sellerData={data} storeId={storeId} />
|
||
<div className="w-full">
|
||
<div className="px-4">
|
||
<div className="flex px-4 border rounded-[10px] w-full py-2">
|
||
<div className="w-full border-l flex-2 flex flex-col items-center justify-center">
|
||
<p className="text-R12">{data?.followerCount}</p>
|
||
<p className="text-R12">دنبال کننده</p>
|
||
</div>
|
||
<div className="w-full flex-3 flex flex-col items-center justify-center">
|
||
<p className="text-R12">{data?.reviewAverage}</p>
|
||
<div className="flex flex-1 items-center">
|
||
{Array.from({ length: 5 }).map((_, index) => (
|
||
<Star key={index} className="fill-BLACK" size={12} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="w-full border-r flex-2 flex flex-col items-center justify-center">
|
||
<p className="text-R12">{data?.productCount}</p>
|
||
<p className="text-R12">تعداد محصولات</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="my-6 px-4">
|
||
<ReadMoreText
|
||
sellerData={data}
|
||
text={data?.storeDescription || ""}
|
||
maxLength={150}
|
||
/>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className="flex px-4 mb-4">
|
||
<div className="flex w-full items-center justify-center">
|
||
<div
|
||
onClick={() => setActiveTab("products")}
|
||
className={`p-2 text-center transition-colors border-b-2 ${
|
||
activeTab === "products"
|
||
? "bg-WHITE text-BLACK shadow-sm"
|
||
: "text-BLACK2 border-WHITE"
|
||
}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Products"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
setActiveTab("products");
|
||
}
|
||
}}
|
||
>
|
||
محصولات
|
||
</div>
|
||
<div
|
||
onClick={() => setActiveTab("instagram")}
|
||
className={`p-2 text-center transition-colors border-b-2 ${
|
||
activeTab === "instagram"
|
||
? "bg-WHITE text-BLACK shadow-sm"
|
||
: "text-BLACK2 border-WHITE"
|
||
}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Instagram"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
setActiveTab("instagram");
|
||
}
|
||
}}
|
||
>
|
||
همه پستها
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Content */}
|
||
{activeTab === "products" ? (
|
||
<UiProvider
|
||
isLoading={isProductsLoading}
|
||
isError={isProductsError}
|
||
isEmpty={false}
|
||
onRetry={refetchProducts}
|
||
type="seller"
|
||
>
|
||
{/* seller products */}
|
||
<MondrianProductList
|
||
storeId={storeId}
|
||
isCommonList={true}
|
||
products={products.map((product) => ({
|
||
id: product?.id || "",
|
||
title: product?.title || "",
|
||
images: product?.imageUrls || [],
|
||
isActive: product?.isActive,
|
||
}))}
|
||
storePage={true}
|
||
fetchNextPage={fetchNextProductsPage}
|
||
hasNextPage={!!hasNextProductsPage}
|
||
isFetchingNextPage={isFetchingNextProductsPage}
|
||
isLoading={isProductsLoading}
|
||
isError={isProductsError}
|
||
refetch={refetchProducts}
|
||
emptyState={{
|
||
image: <GalleryThumbnailsIcon size={80} />,
|
||
title: "فروشگاه شما محصولی ندارد!",
|
||
description:
|
||
"پست جدید بسازید یا پستهای اینستاگرام خود را وارد فروشگاه کنید.",
|
||
}}
|
||
/>
|
||
</UiProvider>
|
||
) : (
|
||
<UiProvider
|
||
isLoading={isInstagramLoading}
|
||
isError={isInstagramError}
|
||
isEmpty={false}
|
||
onRetry={refetchInstagram}
|
||
type="seller"
|
||
>
|
||
{/* Instagram posts */}
|
||
<MondrianProductList
|
||
storeId={storeId}
|
||
isCommonList={true}
|
||
isDraft={true}
|
||
products={instagramPosts.map((post) => ({
|
||
id: post?.id?.toString() || "",
|
||
title: post?.caption || "",
|
||
images: post?.localMedia || [],
|
||
isDraft: !post?.convertedToProduct,
|
||
}))}
|
||
fetchNextPage={fetchNextInstagramPage}
|
||
hasNextPage={!!hasNextInstagramPage}
|
||
isFetchingNextPage={isFetchingNextInstagramPage}
|
||
isLoading={isInstagramLoading}
|
||
isError={isInstagramError}
|
||
refetch={refetchInstagram}
|
||
emptyState={{
|
||
image: <Instagram size={80} />,
|
||
title: "هیچ پست اینستاگرامی یافت نشد!",
|
||
description: "ابتدا پستهای اینستاگرام خود را همگامسازی کنید.",
|
||
}}
|
||
/>
|
||
</UiProvider>
|
||
)}
|
||
</div>
|
||
<div className="px-4 z-30 fixed bottom-[60px] right-2 md:bottom-10 md:right-0 w-full">
|
||
<Button
|
||
size="sm"
|
||
className="w-12 h-12 rounded-lg shadow-xl"
|
||
variant="dark"
|
||
onClick={() => {
|
||
setAddProductDrawerOpen(true);
|
||
}}
|
||
>
|
||
<Plus size={20} />
|
||
</Button>
|
||
<AddProductDrawer
|
||
instaIsAuthenticated={data?.instaStatus === "authenticated"}
|
||
isOpen={addProductDrawerOpen}
|
||
onOpenChange={setAddProductDrawerOpen}
|
||
sellerUsername={data?.username || ""}
|
||
onOpenInstagramSync={() => {
|
||
setAddProductDrawerOpen(false);
|
||
setInstagramSyncDrawerOpen(true);
|
||
}}
|
||
/>
|
||
<InstagramSyncDrawer
|
||
isOpen={instagramSyncDrawerOpen}
|
||
onOpenChange={setInstagramSyncDrawerOpen}
|
||
storeId={storeId || ""}
|
||
setIsCallModalOpen={setIsCallModalOpen}
|
||
/>
|
||
<CallDialog
|
||
isCallModalOpen={isCallModalOpen}
|
||
setIsCallModalOpen={setIsCallModalOpen}
|
||
handleCallConfirm={handleCallConfirm}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const AddProductDrawer = ({
|
||
isOpen,
|
||
instaIsAuthenticated,
|
||
onOpenChange,
|
||
sellerUsername,
|
||
onOpenInstagramSync,
|
||
}: {
|
||
isOpen: boolean;
|
||
instaIsAuthenticated: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
sellerUsername: string;
|
||
onOpenInstagramSync: () => void;
|
||
}) => {
|
||
const navigate = useNavigate();
|
||
return (
|
||
<Drawer open={isOpen} onOpenChange={onOpenChange}>
|
||
<DrawerContent>
|
||
<DrawerHeader className="flex items-center relative justify-center">
|
||
<X
|
||
size={24}
|
||
className="text-BLACK2 absolute left-4"
|
||
onClick={() => onOpenChange(false)}
|
||
/>
|
||
<DrawerTitle className="text-center text-B12 fonr-bold">
|
||
افزودن محصول جدید
|
||
</DrawerTitle>
|
||
</DrawerHeader>
|
||
|
||
<div className="flex gap-10 p-4 pb-6 items-center justify-center">
|
||
<div
|
||
className="flex flex-col items-center justify-center gap-1"
|
||
onClick={() => {
|
||
if (instaIsAuthenticated) onOpenInstagramSync();
|
||
else navigate(`/store/authenticate/instagram/${sellerUsername}`);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
if (instaIsAuthenticated) onOpenInstagramSync();
|
||
else
|
||
navigate(`/store/authenticate/instagram/${sellerUsername}`);
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Instagram sync"
|
||
>
|
||
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
|
||
<Instagram size={24} className="text-WHITE" />
|
||
</div>
|
||
<p className="text-R12">پستهای اینستاگرام</p>
|
||
</div>
|
||
<div
|
||
className="flex flex-col items-center justify-center gap-1"
|
||
onClick={() => navigate(`/store/add/manual/${sellerUsername}`)}
|
||
onKeyDown={(e) =>
|
||
(e.key === "Enter" || e.key === " ") &&
|
||
navigate(`/store/add/manual/${sellerUsername}`)
|
||
}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="Camera"
|
||
>
|
||
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
|
||
<Images size={24} className="text-WHITE" />
|
||
</div>
|
||
<p className="text-R12">ایجاد محصول</p>
|
||
</div>
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
);
|
||
};
|
||
|
||
export const InstagramSyncDrawer = ({
|
||
isOpen,
|
||
onOpenChange,
|
||
storeId,
|
||
setIsCallModalOpen,
|
||
}: {
|
||
isOpen: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
storeId: string;
|
||
setIsCallModalOpen: (open: boolean) => void;
|
||
}) => {
|
||
const [count, setCount] = useState(1);
|
||
const instagramSync = useInstagramSync();
|
||
const navigate = useNavigate();
|
||
const handleSync = async () => {
|
||
try {
|
||
const result = await instagramSync.mutateAsync({ count });
|
||
console.log("Instagram sync result:", result);
|
||
onOpenChange(false);
|
||
navigate(`/store/${storeId}?tab=instagram`);
|
||
} catch (error) {
|
||
console.error("Instagram sync error:", error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Drawer open={isOpen} onOpenChange={onOpenChange}>
|
||
<DrawerContent>
|
||
<DrawerHeader className="flex items-center relative justify-center border-b border-inner-border">
|
||
<X
|
||
size={24}
|
||
className="text-BLACK2 absolute left-4"
|
||
onClick={() => onOpenChange(false)}
|
||
/>
|
||
<DrawerTitle className="text-center text-B16 font-bold">
|
||
همگامسازی اینستاگرام
|
||
</DrawerTitle>
|
||
<div
|
||
className="absolute right-4 h-8 w-8 rounded-full bg-WHITE shadow-xl flex items-center justify-center cursor-pointer"
|
||
onClick={() => {
|
||
setIsCallModalOpen(true);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
setIsCallModalOpen(true);
|
||
}
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
>
|
||
<Headset size={20} />
|
||
</div>
|
||
</DrawerHeader>
|
||
|
||
{/* main content */}
|
||
<div className="flex flex-col gap-6 p-4 max-h-[90vh] overflow-y-auto">
|
||
{/* Count Input Section */}
|
||
<div className="flex flex-col gap-2">
|
||
<p className="text-right text-md font-medium">
|
||
تعداد پستهایی که میخواهید همگامسازی کنید:
|
||
</p>
|
||
<p className="text-right text-sm font-medium">
|
||
برای مثال اگر عدد 3 را وارد کنید، 3 پست آخر پیج اینستاگرام شما
|
||
دریافت و به فروشگاه اضافه خواهند شد.
|
||
</p>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
{/* Number Selection Squares */}
|
||
<div className="flex justify-center gap-2 flex-wrap">
|
||
{Array.from({ length: 10 }, (_, index) => {
|
||
const number = index + 1;
|
||
const isSelected = number <= count;
|
||
return (
|
||
<button
|
||
key={number}
|
||
onClick={() => setCount(number)}
|
||
className={`w-10 h-10 flex items-center justify-center text-sm font-medium border-2 rounded-lg transition-all ${
|
||
isSelected
|
||
? "bg-BLACK text-WHITE border-BLACK"
|
||
: "bg-WHITE text-BLACK2 border-GRAY3 hover:border-GRAY2"
|
||
}`}
|
||
>
|
||
{number}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex justify-center text-sm text-BLACK2">
|
||
<span>تعداد انتخاب شده: {count}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Info Section */}
|
||
<div className="bg-blue-50 p-4 rounded-lg">
|
||
<p className="text-sm text-blue-800 text-right">
|
||
پیج اینستاگرام شما باید public باشد تا crawler بتواند به آن
|
||
دسترسی داشته باشد.
|
||
</p>
|
||
</div>
|
||
<div className="bg-blue-50 p-4 rounded-lg">
|
||
<p className="text-sm text-blue-800 text-right">
|
||
با انتخاب تعداد پستها، آخرین پستهای اینستاگرام شما که تابحال
|
||
همگام سازی نشده اند، دریافت و به فروشگاه اضافه خواهند شد.
|
||
</p>
|
||
</div>
|
||
<div className="bg-blue-50 p-4 rounded-lg">
|
||
<p className="text-sm text-blue-800 text-right">
|
||
شما میتوانید روزانه حداکثر 10 پست از اینستاگرام خود را همگام
|
||
سازی کنید.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="pt-4">
|
||
<Button
|
||
className="w-full"
|
||
size="xl"
|
||
variant="dark"
|
||
onClick={handleSync}
|
||
disabled={instagramSync.isPending || count < 1 || count > 10}
|
||
>
|
||
{instagramSync.isPending ? "در حال دریافت..." : "دریافت پست ها"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
<LoadingModal
|
||
open={instagramSync.isPending}
|
||
setOpen={() => {}}
|
||
title="در حال دریافت پست ها..."
|
||
description="حداکثر یک دقیقه طول میکشد."
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
function ReadMoreText({
|
||
text,
|
||
maxLength,
|
||
sellerData,
|
||
}: {
|
||
text: string;
|
||
maxLength: number;
|
||
sellerData: SellerType | undefined;
|
||
}) {
|
||
const [aboutDrawerOpen, setAboutDrawerOpen] = useState(false);
|
||
if (text.length <= maxLength) {
|
||
return <p className="text-R12">{text}</p>;
|
||
}
|
||
return (
|
||
<>
|
||
<div className="relative">
|
||
<p className="text-R12">
|
||
{text.substring(0, maxLength)}
|
||
<span>...</span>
|
||
<button
|
||
onClick={() => setAboutDrawerOpen(true)}
|
||
className="inline-flex items-center text-blue-500 mr-1 focus:outline-none"
|
||
>
|
||
<span>بیشتر</span>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
</p>
|
||
</div>
|
||
<Drawer open={aboutDrawerOpen} onOpenChange={setAboutDrawerOpen}>
|
||
<DrawerContent className="flex flex-col gap-4">
|
||
<div className="flex relative items-center justify-center w-full">
|
||
<X
|
||
className="absolute right-6 cursor-pointer"
|
||
size={24}
|
||
onClick={() => setAboutDrawerOpen(false)}
|
||
/>
|
||
<div className="flex flex-col gap-2 py-4 items-center">
|
||
<SellerLogo src={sellerData?.storeLogo} alt="Seller Logo" />
|
||
<p className="text-B14 font-bold">{sellerData?.storeName}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-R12 px-4 pb-10">{text}</p>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function SellerDetailHeader({
|
||
sellerData,
|
||
storeId,
|
||
}: {
|
||
sellerData: SellerType | undefined;
|
||
storeId: string | undefined;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
|
||
const handleSettingClick = () => {
|
||
navigate(`/store/${storeId}/setting`);
|
||
};
|
||
|
||
return (
|
||
<div className="flex px-4 w-full justify-between items-center sticky top-0 z-30 bg-WHITE">
|
||
<ArrowRight size={24} onClick={() => safeGoBack(navigate)} />
|
||
<div className="flex flex-col gap-2 py-4 items-center">
|
||
<SellerLogo src={sellerData?.storeLogo} alt="Seller Logo" />
|
||
<p className="text-B14 font-bold">{sellerData?.storeName}</p>
|
||
</div>
|
||
<Button
|
||
className="h-10 w-10 flex items-center justify-center rounded-full bg-WHITE shadow-[0_0_8px_0_hsl(var(--TRANSPARENT_BLACK_30))]"
|
||
onClick={handleSettingClick}
|
||
>
|
||
<Menu size={24} />
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const meta: MetaFunction = ({ params }) => {
|
||
const storeId = params.storeId;
|
||
const title = `مدیریت فروشگاه ${storeId} | ویترون`;
|
||
const description = `مدیریت محصولات و تنظیمات فروشگاه ${storeId} در ویترون`;
|
||
const imageUrl =
|
||
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
|
||
|
||
return [
|
||
{ title },
|
||
{ name: "description", content: description },
|
||
{
|
||
name: "keywords",
|
||
content: `مدیریت فروشگاه، ${storeId}، محصولات، اینستاگرام، ویترون`,
|
||
},
|
||
{ name: "robots", content: "noindex, follow" },
|
||
|
||
// Open Graph
|
||
{ property: "og:type", content: "website" },
|
||
{ property: "og:title", content: title },
|
||
{ property: "og:description", content: description },
|
||
{ property: "og:image", content: imageUrl },
|
||
{ property: "og:site_name", content: "ویترون" },
|
||
{ property: "og:locale", content: "fa_IR" },
|
||
|
||
// Twitter Card
|
||
{ name: "twitter:card", content: "summary" },
|
||
{ name: "twitter:title", content: title },
|
||
{ name: "twitter:description", content: description },
|
||
];
|
||
};
|