MyLayout only pads the bottom inset globally; each top bar must add its own top inset. Several inline page headers omitted it, so their content slid under the notch in the iOS standalone PWA (home was fine — its bar has it). Wrap the back-button headers the same way ProfilePagesHeader already does (outer sticky element carries pt-[env(safe-area-inset-top)]; inner relative bar holds the content) so the absolute back arrow stays out of the notch. Fixed: /profile hub, /discounts, /collections, /collection/:id, /sellers, seller store index, and financial transactions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { MetaFunction } from "@remix-run/node";
|
||
import { useParams, useNavigate } from "@remix-run/react";
|
||
import { safeGoBack } from "~/utils/helpers";
|
||
import {
|
||
useCollectionDetail,
|
||
useSimilarCollections,
|
||
} from "~/requestHandler/use-collection-hooks";
|
||
import { Skeleton } from "~/components/ui/skeleton";
|
||
import {
|
||
Package,
|
||
RefreshCw,
|
||
ArrowRight,
|
||
ShoppingBag,
|
||
Calendar,
|
||
Sparkles,
|
||
} from "lucide-react";
|
||
import MondrianProductList from "~/components/MondrianProductList";
|
||
import HorizontalCollectionsList from "~/components/HorizontalCollectionsList";
|
||
import AIPromoteModal from "~/components/AIPromoteModal";
|
||
import { useState } from "react";
|
||
|
||
export default function CollectionDetail() {
|
||
const params = useParams();
|
||
const navigate = useNavigate();
|
||
const collectionId = params.id || "";
|
||
|
||
// AI Promote Modal State
|
||
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
|
||
|
||
const {
|
||
data: collection,
|
||
isLoading,
|
||
isError,
|
||
refetch,
|
||
} = useCollectionDetail(collectionId);
|
||
|
||
const {
|
||
data: similarCollectionsData,
|
||
isLoading: isSimilarLoading,
|
||
isError: isSimilarError,
|
||
refetch: refetchSimilar,
|
||
} = useSimilarCollections(collectionId);
|
||
|
||
// Format date helper
|
||
const formatDate = (dateString?: string) => {
|
||
if (!dateString) return "";
|
||
const date = new Date(dateString);
|
||
return new Intl.DateTimeFormat("fa-IR", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
}).format(date);
|
||
};
|
||
|
||
const handleBack = () => {
|
||
safeGoBack(navigate);
|
||
};
|
||
|
||
// Loading state
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||
{/* Header */}
|
||
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||
<ArrowRight
|
||
onClick={handleBack}
|
||
className="absolute top-5 right-5 cursor-pointer"
|
||
/>
|
||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Loading Skeleton */}
|
||
<div className="flex flex-col gap-4 p-4">
|
||
<Skeleton className="w-full mx-auto h-[65vh] rounded-xl" />
|
||
<Skeleton className="w-3/4 h-6" />
|
||
<Skeleton className="w-full h-4" />
|
||
<div className="flex gap-2">
|
||
<Skeleton className="w-24 h-8" />
|
||
<Skeleton className="w-24 h-8" />
|
||
</div>
|
||
<Skeleton className="w-full h-px mt-4" />
|
||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||
{Array.from({ length: 6 }).map((_, i) => (
|
||
<Skeleton key={i} className="w-full h-48 rounded-xl" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Error state
|
||
if (isError || !collection) {
|
||
return (
|
||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||
{/* Header */}
|
||
<div className="sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||
<ArrowRight
|
||
onClick={handleBack}
|
||
className="absolute top-5 right-5 cursor-pointer"
|
||
/>
|
||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error State */}
|
||
<div className="flex flex-col items-center justify-center py-20 px-4">
|
||
<div className="w-20 h-20 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||
<Package size={40} className="text-red-500" />
|
||
</div>
|
||
<h3 className="text-B18 font-bold text-black mb-2">
|
||
خطا در بارگذاری کالکشن
|
||
</h3>
|
||
<p className="text-R14 text-GRAY text-center max-w-md mb-6">
|
||
متاسفانه در بارگذاری اطلاعات کالکشن مشکلی پیش آمد.
|
||
</p>
|
||
<button
|
||
onClick={() => refetch()}
|
||
className="flex items-center gap-2 bg-black text-white px-6 py-3 rounded-full hover:bg-gray-800 transition-all"
|
||
>
|
||
<RefreshCw size={16} />
|
||
<span className="text-R14 font-bold">تلاش مجدد</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const products = collection.products?.results || [];
|
||
|
||
return (
|
||
<div className="flex flex-col min-h-screen bg-WHITE pb-20">
|
||
{/* Header (mobile) */}
|
||
<div className="lg:hidden sticky top-0 z-30 bg-WHITE pt-[env(safe-area-inset-top)]">
|
||
<div className="relative flex flex-row h-[65px] w-full items-center justify-center border-b border-inner-border">
|
||
<ArrowRight
|
||
onClick={handleBack}
|
||
className="absolute top-5 right-5 cursor-pointer"
|
||
/>
|
||
<p className="text-B16 font-bold">جزئیات کالکشن</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex flex-col lg:max-w-[1320px] lg:mx-auto lg:w-full lg:px-8 lg:pt-6">
|
||
{/* Collection Header */}
|
||
<div className="flex flex-col gap-4 p-4 border-b border-inner-border lg:grid lg:grid-cols-[360px_1fr] lg:gap-8 lg:items-start lg:p-0 lg:py-6 lg:border-0">
|
||
{/* Cover Image */}
|
||
<div className="relative overflow-hidden rounded-xl w-full mx-auto h-[65vh] bg-gray-100 lg:h-auto lg:aspect-[3/4]">
|
||
{collection.coverImageUrl ? (
|
||
<img
|
||
src={collection.coverImageUrl}
|
||
alt={collection.title || "Collection"}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center bg-WHITE2">
|
||
<Package size={80} className="text-GRAY" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Collection Info */}
|
||
<div className="flex flex-col gap-3">
|
||
<h1 className="text-B20 lg:text-[28px] font-bold text-black">
|
||
{collection.title || "بدون عنوان"}
|
||
</h1>
|
||
|
||
{collection.description && (
|
||
<p className="text-R14 text-GRAY">{collection.description}</p>
|
||
)}
|
||
|
||
{/* Stats Row */}
|
||
<div className="flex items-center gap-4 flex-wrap justify-between">
|
||
{/* Product Count */}
|
||
{collection.productCount !== undefined && (
|
||
<div className="flex items-center gap-1.5 bg-WHITE2 px-3 py-2 rounded-lg">
|
||
<ShoppingBag size={16} className="text-black" />
|
||
<span className="text-R13 text-black font-bold">
|
||
{collection.productCount} محصول
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Created Date */}
|
||
{collection.createdAt && (
|
||
<div className="flex items-center gap-1.5">
|
||
<Calendar size={14} className="text-GRAY" />
|
||
<span className="text-R10 text-GRAY">
|
||
{formatDate(collection.createdAt)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Products Section */}
|
||
<div className="flex flex-col py-4">
|
||
<div className="px-4 mb-4">
|
||
<h2 className="text-B18 font-bold text-black">محصولات کالکشن</h2>
|
||
</div>
|
||
|
||
{products.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-20 px-4">
|
||
<div className="w-20 h-20 rounded-full bg-WHITE2 flex items-center justify-center mb-4">
|
||
<Package size={40} className="text-GRAY" />
|
||
</div>
|
||
<h3 className="text-B18 font-bold text-black mb-2">
|
||
محصولی در این کالکشن وجود ندارد
|
||
</h3>
|
||
<p className="text-R14 text-GRAY text-center max-w-md">
|
||
هنوز محصولی به این کالکشن اضافه نشده است.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<MondrianProductList
|
||
products={products.map((product) => ({
|
||
id: product.id || "",
|
||
title: product.title,
|
||
images: product.imageUrls || [],
|
||
basePrice: product.basePrice
|
||
? parseInt(product.basePrice)
|
||
: undefined,
|
||
}))}
|
||
fetchNextPage={() => {}}
|
||
hasNextPage={false}
|
||
isFetchingNextPage={false}
|
||
isLoading={false}
|
||
isError={false}
|
||
refetch={refetch}
|
||
emptyState={{
|
||
image: <Package size={80} className="text-GRAY" />,
|
||
title: "محصولی وجود ندارد",
|
||
description: "هنوز محصولی به این کالکشن اضافه نشده است.",
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Similar Collections Section */}
|
||
{similarCollectionsData &&
|
||
similarCollectionsData?.results &&
|
||
similarCollectionsData?.results.length > 0 && (
|
||
<div className="flex flex-col py-4 border-t border-inner-border">
|
||
<div className="px-4 mb-4">
|
||
<h2 className="text-B18 font-bold text-black">
|
||
کالکشنهای مرتبط
|
||
</h2>
|
||
</div>
|
||
|
||
<HorizontalCollectionsList
|
||
collections={similarCollectionsData?.results || []}
|
||
isLoading={isSimilarLoading}
|
||
isError={isSimilarError}
|
||
refetch={refetchSimilar}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* AI Promote Floating Button - با انیمیشن ورود از پایین */}
|
||
<button
|
||
onClick={() => setIsPromoteModalOpen(true)}
|
||
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-40
|
||
flex items-center gap-2 px-6 py-4 rounded-full
|
||
bg-BLACK text-WHITE font-bold shadow-2xl
|
||
hover:bg-BLACK2 hover:scale-105
|
||
active:scale-95
|
||
transition-all duration-300"
|
||
style={{
|
||
animation:
|
||
"slideUpBounce 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards",
|
||
}}
|
||
>
|
||
<Sparkles className="w-5 h-5" />
|
||
<span className="text-R14 font-bold">پرو کردن با AI</span>
|
||
</button>
|
||
|
||
{/* AI Promote Modal */}
|
||
<AIPromoteModal
|
||
isOpen={isPromoteModalOpen}
|
||
onClose={() => setIsPromoteModalOpen(false)}
|
||
collectionId={collectionId}
|
||
/>
|
||
|
||
{/* Keyframe Animation for Floating Button */}
|
||
<style>{`
|
||
@keyframes slideUpBounce {
|
||
0% {
|
||
transform: translateX(-50%) translateY(100px);
|
||
opacity: 0;
|
||
}
|
||
60% {
|
||
transform: translateX(-50%) translateY(-10px);
|
||
opacity: 1;
|
||
}
|
||
80% {
|
||
transform: translateX(-50%) translateY(5px);
|
||
}
|
||
100% {
|
||
transform: translateX(-50%) translateY(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const meta: MetaFunction = () => {
|
||
return [
|
||
{ title: "جزئیات کالکشن - ویترون" },
|
||
{
|
||
name: "description",
|
||
content: "مشاهده جزئیات کالکشن و محصولات موجود در آن.",
|
||
},
|
||
{
|
||
name: "keywords",
|
||
content: "کالکشن، محصولات، ویترون، خرید آنلاین",
|
||
},
|
||
{ name: "robots", content: "index, follow" },
|
||
{ property: "og:type", content: "website" },
|
||
{ property: "og:title", content: "جزئیات کالکشن - ویترون" },
|
||
{
|
||
property: "og:description",
|
||
content: "مشاهده جزئیات کالکشن و محصولات موجود در آن.",
|
||
},
|
||
{ property: "og:site_name", content: "ویترون" },
|
||
{ property: "og:locale", content: "fa_IR" },
|
||
{ name: "twitter:card", content: "summary_large_image" },
|
||
{ name: "twitter:title", content: "جزئیات کالکشن - ویترون" },
|
||
{
|
||
name: "twitter:description",
|
||
content: "مشاهده جزئیات کالکشن و محصولات موجود در آن.",
|
||
},
|
||
];
|
||
};
|