678 lines
21 KiB
TypeScript
678 lines
21 KiB
TypeScript
import { MetaFunction, LoaderFunctionArgs, json } from "@remix-run/node";
|
||
import { useLoaderData, useNavigate, Link } from "@remix-run/react";
|
||
import {
|
||
Layers,
|
||
Trash2,
|
||
Loader2,
|
||
Edit2,
|
||
X,
|
||
ShoppingBag,
|
||
Sparkles,
|
||
MoreVertical,
|
||
} from "lucide-react";
|
||
import { useState, useEffect } from "react";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Skeleton } from "~/components/ui/skeleton";
|
||
import { Dialog, DialogContent, DialogOverlay } from "~/components/ui/dialog";
|
||
import { Input } from "~/components/ui/input";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from "~/components/ui/dropdown-menu";
|
||
import {
|
||
useUserCollection,
|
||
useDeleteCollection,
|
||
useRemoveProductFromCollection,
|
||
useUpdateCollection,
|
||
} from "../requestHandler/use-user-collection-hooks";
|
||
import { UserCollection, ProductDetail } from "src/api/types";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import ProfilePagesHeader from "~/components/ProfilePagesHeader";
|
||
import AIPromoteModal from "~/components/AIPromoteModal";
|
||
|
||
export async function loader({ params }: LoaderFunctionArgs) {
|
||
const { id } = params;
|
||
return json({ collectionId: id });
|
||
}
|
||
|
||
export default function SetDetail() {
|
||
const { collectionId } = useLoaderData<typeof loader>();
|
||
const navigate = useNavigate();
|
||
|
||
const {
|
||
data: collection,
|
||
isLoading,
|
||
refetch,
|
||
} = useUserCollection(collectionId || "");
|
||
|
||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [removeProductId, setRemoveProductId] = useState<string | null>(null);
|
||
const [aiModalOpen, setAiModalOpen] = useState(false);
|
||
|
||
if (!collectionId) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<p className="text-R14 text-GRAY">ست یافت نشد</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col min-h-screen bg-WHITE2">
|
||
{/* Header */}
|
||
<ProfilePagesHeader
|
||
title={collection?.title || "جزئیات ست"}
|
||
rightContent={
|
||
!isLoading && collection ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="p-2 rounded-full hover:bg-BLACK/5 transition-colors">
|
||
<MoreVertical size={20} className="text-BLACK" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start" className="w-40">
|
||
<DropdownMenuItem
|
||
onClick={() => setEditModalOpen(true)}
|
||
className="gap-2"
|
||
>
|
||
<Edit2 size={16} />
|
||
ویرایش
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
onClick={() => setDeleteModalOpen(true)}
|
||
className="gap-2 text-RED focus:text-RED"
|
||
>
|
||
<Trash2 size={16} />
|
||
حذف ست
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : undefined
|
||
}
|
||
/>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1">
|
||
{isLoading ? (
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||
<Skeleton className="h-14 w-full rounded-xl" />
|
||
{[1, 2, 3].map((i) => (
|
||
<Skeleton key={i} className="h-24 w-full rounded-xl" />
|
||
))}
|
||
</div>
|
||
) : collection ? (
|
||
<div className="flex flex-col">
|
||
{/* Hero Section with Product Images Grid */}
|
||
<div className="relative bg-gradient-to-b from-PRIMARY/10 to-WHITE2 p-4 pb-6">
|
||
{/* Product Images Grid Preview */}
|
||
{collection.products && collection.products.length > 0 && (
|
||
<div className="flex gap-2 mb-4 overflow-hidden">
|
||
<ProductImagesPreview
|
||
productIds={collection.products.slice(0, 4)}
|
||
totalCount={collection.products.length}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Collection Info & AI Try-on */}
|
||
<div className="bg-WHITE rounded-2xl p-4 shadow-sm">
|
||
{/* Collection Header */}
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-12 h-12 rounded-xl bg-PRIMARY/10 flex items-center justify-center flex-shrink-0">
|
||
<Layers size={24} className="text-PRIMARY" />
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<h2 className="text-B16 font-bold truncate">{collection.title}</h2>
|
||
{collection.description && (
|
||
<p className="text-R12 text-GRAY mt-1 line-clamp-2">
|
||
{collection.description}
|
||
</p>
|
||
)}
|
||
<div className="flex items-center gap-4 mt-2">
|
||
<span className="text-R12 text-GRAY">
|
||
{collection.products?.length || 0} محصول
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Try-on Section */}
|
||
{collection.products && collection.products.length > 0 && (
|
||
<>
|
||
<div className="border-t border-BLACK/5 my-4" />
|
||
|
||
{/* Tip message */}
|
||
<div className="flex items-center gap-2 px-3 py-2 bg-PRIMARY/5 rounded-lg mb-3">
|
||
<span className="text-R11">💡</span>
|
||
<p className="text-R11 text-GRAY">
|
||
نکته: از هر دسته فقط یک محصول اضافه کنید (یک پیراهن، یک شلوار، یک کفش)
|
||
</p>
|
||
</div>
|
||
|
||
{/* AI Button */}
|
||
<Button
|
||
variant="dark"
|
||
size="lg"
|
||
className="w-full gap-2 relative overflow-hidden group"
|
||
onClick={() => setAiModalOpen(true)}
|
||
>
|
||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-700 ease-in-out" />
|
||
<Sparkles size={18} className="animate-pulse" />
|
||
پرو کردن با هوش مصنوعی
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Products List */}
|
||
<div className="p-4">
|
||
{collection.products && collection.products.length > 0 ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<p className="text-B14 font-bold">محصولات این ست</p>
|
||
<span className="text-R12 text-GRAY bg-WHITE3 px-2 py-1 rounded-full">
|
||
{collection.products.length} عدد
|
||
</span>
|
||
</div>
|
||
{collection.products.map((productId) => (
|
||
<ProductItem
|
||
key={productId}
|
||
productId={productId}
|
||
onRemove={() => setRemoveProductId(productId)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-12 bg-WHITE rounded-2xl">
|
||
<ShoppingBag size={48} className="text-GRAY mb-4" />
|
||
<p className="text-B14 text-BLACK mb-1">این ست خالی است</p>
|
||
<p className="text-R12 text-GRAY text-center">
|
||
محصولی به این ست اضافه نشده است
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col items-center justify-center py-16">
|
||
<Layers size={64} className="text-GRAY mb-4" />
|
||
<p className="text-B16 text-BLACK mb-2">ست یافت نشد</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Delete collection modal */}
|
||
<DeleteSetModal
|
||
open={deleteModalOpen}
|
||
onOpenChange={setDeleteModalOpen}
|
||
collection={collection || null}
|
||
onSuccess={() => {
|
||
navigate("/profile/sets");
|
||
}}
|
||
/>
|
||
|
||
{/* Edit collection modal */}
|
||
<EditSetModal
|
||
open={editModalOpen}
|
||
onOpenChange={setEditModalOpen}
|
||
collection={collection || null}
|
||
onSuccess={() => {
|
||
refetch();
|
||
setEditModalOpen(false);
|
||
}}
|
||
/>
|
||
|
||
{/* Remove product modal */}
|
||
<RemoveProductModal
|
||
open={!!removeProductId}
|
||
onOpenChange={(open) => !open && setRemoveProductId(null)}
|
||
collectionId={collectionId}
|
||
productId={removeProductId}
|
||
isLastProduct={(collection?.products?.length || 0) === 1}
|
||
onSuccess={(wasLastProduct) => {
|
||
setRemoveProductId(null);
|
||
if (wasLastProduct) {
|
||
navigate("/profile/sets");
|
||
} else {
|
||
refetch();
|
||
}
|
||
}}
|
||
/>
|
||
|
||
{/* AI Promote Modal */}
|
||
<AIPromoteModal
|
||
isOpen={aiModalOpen}
|
||
onClose={() => setAiModalOpen(false)}
|
||
collectionId={collectionId}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Product Images Preview Grid
|
||
const ProductImagesPreview = ({
|
||
productIds,
|
||
totalCount,
|
||
}: {
|
||
productIds: string[];
|
||
totalCount: number;
|
||
}) => {
|
||
const extraCount = totalCount - 4;
|
||
|
||
return (
|
||
<div className="flex gap-2 w-full">
|
||
{productIds.map((productId, index) => (
|
||
<ProductImageThumbnail
|
||
key={productId}
|
||
productId={productId}
|
||
isLast={index === 3 && extraCount > 0}
|
||
remainingCount={extraCount}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ProductImageThumbnail = ({
|
||
productId,
|
||
isLast,
|
||
remainingCount,
|
||
}: {
|
||
productId: string;
|
||
isLast?: boolean;
|
||
remainingCount?: number;
|
||
}) => {
|
||
const { data: product, isLoading } = useQuery({
|
||
queryKey: ["product-thumb", productId],
|
||
queryFn: async (): Promise<ProductDetail> => {
|
||
const response = await fetch(
|
||
`${
|
||
typeof window !== "undefined"
|
||
? import.meta.env.VITE_API_BASE_URL || "https://api.prod.vitrown.com"
|
||
: process.env.VITE_API_BASE_URL || "https://api.prod.vitrown.com"
|
||
}/api/product/v1/products/${productId}/`
|
||
);
|
||
if (!response.ok) throw new Error("Failed to fetch product");
|
||
return response.json();
|
||
},
|
||
enabled: !!productId,
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
if (isLoading) {
|
||
return <Skeleton className="flex-1 aspect-square rounded-xl" />;
|
||
}
|
||
|
||
return (
|
||
<div className="relative flex-1 aspect-square rounded-xl overflow-hidden bg-WHITE">
|
||
{product?.imageUrls?.[0] ? (
|
||
<img
|
||
src={product.imageUrls[0]}
|
||
alt=""
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full bg-GRAY/10 flex items-center justify-center">
|
||
<ShoppingBag size={20} className="text-GRAY" />
|
||
</div>
|
||
)}
|
||
{isLast && remainingCount && remainingCount > 0 && (
|
||
<div className="absolute inset-0 bg-BLACK/60 flex items-center justify-center">
|
||
<span className="text-WHITE text-B14 font-bold">+{remainingCount}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Product item component
|
||
const ProductItem = ({
|
||
productId,
|
||
onRemove,
|
||
}: {
|
||
productId: string;
|
||
onRemove: () => void;
|
||
}) => {
|
||
const { data: product, isLoading } = useQuery({
|
||
queryKey: ["product-basic", productId],
|
||
queryFn: async (): Promise<ProductDetail> => {
|
||
const response = await fetch(
|
||
`${
|
||
typeof window !== "undefined"
|
||
? import.meta.env.VITE_API_BASE_URL || "https://api.prod.vitrown.com"
|
||
: process.env.VITE_API_BASE_URL || "https://api.prod.vitrown.com"
|
||
}/api/product/v1/products/${productId}/`
|
||
);
|
||
if (!response.ok) throw new Error("Failed to fetch product");
|
||
return response.json();
|
||
},
|
||
enabled: !!productId,
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
|
||
if (isLoading) {
|
||
return <Skeleton className="h-24 w-full rounded-xl" />;
|
||
}
|
||
|
||
if (!product) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-3 p-3 rounded-xl bg-WHITE shadow-sm">
|
||
<Link
|
||
to={`/product/${productId}`}
|
||
className="flex items-center gap-3 flex-1"
|
||
>
|
||
{product.imageUrls?.[0] ? (
|
||
<img
|
||
src={product.imageUrls[0]}
|
||
alt={product.title}
|
||
className="w-20 h-20 rounded-lg object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-20 h-20 rounded-lg bg-GRAY/10 flex items-center justify-center">
|
||
<ShoppingBag size={24} className="text-GRAY" />
|
||
</div>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-B14 font-bold line-clamp-2">{product.title}</p>
|
||
{product.basePrice && (
|
||
<p className="text-B14 text-PRIMARY mt-2 font-bold">
|
||
{Number(product.basePrice).toLocaleString("fa-IR")} تومان
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Link>
|
||
<button
|
||
onClick={onRemove}
|
||
className="p-2.5 rounded-full bg-RED/10 hover:bg-RED/20 transition-colors"
|
||
>
|
||
<X size={18} className="text-RED" />
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DeleteSetModal = ({
|
||
open,
|
||
onOpenChange,
|
||
collection,
|
||
onSuccess,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
collection: UserCollection | null;
|
||
onSuccess: () => void;
|
||
}) => {
|
||
const { mutate: deleteCollection, isPending } = useDeleteCollection();
|
||
|
||
const handleDelete = () => {
|
||
if (!collection?.id) return;
|
||
|
||
deleteCollection(collection.id, {
|
||
onSuccess: () => {
|
||
onSuccess();
|
||
},
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogOverlay />
|
||
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex flex-col gap-3 items-center w-full">
|
||
<div className="w-16 h-16 rounded-full bg-RED/10 flex items-center justify-center">
|
||
<Trash2 size={32} className="text-RED" />
|
||
</div>
|
||
<p className="text-B14 font-bold mt-1">حذف ست</p>
|
||
<p className="text-R12 text-GRAY text-center">
|
||
آیا از حذف ست "{collection?.title}" مطمئن هستید؟
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-row gap-4 w-full">
|
||
<Button
|
||
className="w-[100%]"
|
||
size="sm"
|
||
variant="dark"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isPending}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
className="w-[100%] bg-RED hover:bg-RED/90"
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleDelete}
|
||
disabled={isPending}
|
||
>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin ml-2" />
|
||
در حال حذف...
|
||
</>
|
||
) : (
|
||
"حذف ست"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
const EditSetModal = ({
|
||
open,
|
||
onOpenChange,
|
||
collection,
|
||
onSuccess,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
collection: UserCollection | null;
|
||
onSuccess: () => void;
|
||
}) => {
|
||
const [title, setTitle] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const { mutate: updateCollection, isPending } = useUpdateCollection();
|
||
|
||
useEffect(() => {
|
||
if (collection) {
|
||
setTitle(collection.title || "");
|
||
setDescription(collection.description || "");
|
||
}
|
||
}, [collection]);
|
||
|
||
const handleUpdate = () => {
|
||
if (!collection?.id || !title.trim()) return;
|
||
|
||
updateCollection(
|
||
{
|
||
collectionId: collection.id,
|
||
data: {
|
||
title: title.trim(),
|
||
description: description.trim() || undefined,
|
||
},
|
||
},
|
||
{
|
||
onSuccess: () => {
|
||
onSuccess();
|
||
},
|
||
}
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogOverlay />
|
||
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex flex-col gap-3 items-center w-full">
|
||
<div className="w-16 h-16 rounded-full bg-PRIMARY/10 flex items-center justify-center">
|
||
<Edit2 size={32} className="text-PRIMARY" />
|
||
</div>
|
||
<p className="text-B14 font-bold mt-1">ویرایش ست</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="edit-title" className="text-R12 text-BLACK">
|
||
نام ست *
|
||
</label>
|
||
<Input
|
||
placeholder="نام ست"
|
||
id="edit-title"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
className="w-full"
|
||
maxLength={100}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<label htmlFor="edit-description" className="text-R12 text-BLACK">
|
||
توضیحات (اختیاری)
|
||
</label>
|
||
<Input
|
||
placeholder="توضیحات"
|
||
id="edit-description"
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
className="w-full"
|
||
maxLength={500}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-row gap-4 w-full">
|
||
<Button
|
||
className="w-[100%]"
|
||
size="sm"
|
||
variant="dark"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isPending}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
className="w-[100%]"
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleUpdate}
|
||
disabled={!title.trim() || isPending}
|
||
>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin ml-2" />
|
||
در حال ذخیره...
|
||
</>
|
||
) : (
|
||
"ذخیره تغییرات"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
const RemoveProductModal = ({
|
||
open,
|
||
onOpenChange,
|
||
collectionId,
|
||
productId,
|
||
isLastProduct,
|
||
onSuccess,
|
||
}: {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
collectionId: string;
|
||
productId: string | null;
|
||
isLastProduct: boolean;
|
||
onSuccess: (wasLastProduct: boolean) => void;
|
||
}) => {
|
||
const { mutate: removeProduct, isPending } = useRemoveProductFromCollection();
|
||
|
||
const handleRemove = () => {
|
||
if (!collectionId || !productId) return;
|
||
|
||
removeProduct(
|
||
{ collectionId, productId },
|
||
{
|
||
onSuccess: () => {
|
||
onSuccess(isLastProduct);
|
||
},
|
||
}
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogOverlay />
|
||
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)]">
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex flex-col gap-3 items-center w-full">
|
||
<div className="w-16 h-16 rounded-full bg-RED/10 flex items-center justify-center">
|
||
<X size={32} className="text-RED" />
|
||
</div>
|
||
<p className="text-B14 font-bold mt-1">حذف محصول از ست</p>
|
||
<p className="text-R12 text-GRAY text-center">
|
||
{isLastProduct
|
||
? "این آخرین محصول این ست است. با حذف آن، کل ست حذف خواهد شد."
|
||
: "آیا از حذف این محصول از ست مطمئن هستید؟"}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-row gap-4 w-full">
|
||
<Button
|
||
className="w-[100%]"
|
||
size="sm"
|
||
variant="dark"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isPending}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
className="w-[100%] bg-RED hover:bg-RED/90"
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleRemove}
|
||
disabled={isPending}
|
||
>
|
||
{isPending ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 animate-spin ml-2" />
|
||
در حال حذف...
|
||
</>
|
||
) : isLastProduct ? (
|
||
"حذف ست"
|
||
) : (
|
||
"حذف محصول"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "جزئیات ست | ویترون" },
|
||
{ name: "description", content: "مشاهده جزئیات ست" },
|
||
{ name: "robots", content: "noindex, follow" },
|
||
];
|
||
};
|