Vitron-Front/app/routes/profile.sets.$id.tsx
Arda Samadi 16389fdd85 fix(desktop): lay out dashboard sub-pages + form pages for wide screens
- HeaderWithSupport is now lg:hidden (all its callers are desktop-full-width
  store/profile pages), removing the stray mobile back+support bar on desktop.
- Constrain + title the previously mobile-only pages so their content doesn't
  stretch edge-to-edge on desktop: store edit/create form (StoreForm), add
  product, shipping method, financial sub-pages (cash-funds/reports/
  transactions), order detail, instagram auth; and profile add/edit address,
  set detail, and payment result. Forms use a narrow centered column, lists/
  details a wider one. Transactions keeps its filter button (header restyled,
  not hidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:06:13 +03:30

678 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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 { 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 lg:bg-transparent lg:max-w-[1100px] lg:mx-auto lg:w-full lg:pt-6">
{/* 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">
آیا از حذف ست &quot;{collection?.title}&quot; مطمئن هستید؟
</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" },
];
};