Vitron-Front/app/routes/store.$storeId._index.tsx
2026-04-29 01:44:16 +03:30

605 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
];
};