Vitron-Front/app/components/MondrianProductList.tsx
2026-04-29 01:44:16 +03:30

601 lines
20 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 { Link, useNavigate } from "@remix-run/react";
import { useEffect, useState, useRef, useMemo } from "react";
import { Skeleton } from "./ui/skeleton";
import { ProductImageCarousel } from "./product/ProductImageCarousel";
import { EmptyState, ErrorState } from "./UiProvider";
import { Brain, Eye, EyeOff, MoveRight, Pencil, X } from "lucide-react";
import { Dialog, DialogContent, DialogOverlay } from "./ui/dialog";
import { Button } from "./ui/button";
import { useDeleteInstagramPost } from "~/requestHandler/use-instagram-hooks";
import placeholderImage from "~/assets/icons/product.png";
import placeholderImageDark from "~/assets/icons/product-dark.png";
import { useRootData } from "~/hooks/use-root-data";
import discountRed from "~/assets/icons/product/discount-red.svg";
interface MondrianList {
id?: string;
title?: string;
images?: string[];
isDraft?: boolean;
isActive?: boolean;
discountPercent?: number;
discountPrice?: number;
basePrice?: number;
}
// Utility function to detect media type
export const isVideo = (url: string): boolean => {
const videoExtensions = [".mp4", ".webm", ".ogg", ".mov"];
return videoExtensions.some((ext) => url.toLowerCase().endsWith(ext));
};
// ProductList component to display product images
const MondrianProductList = ({
products,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
refetch,
emptyState,
isCommonList = false,
isDraft = false,
storeId,
storePage,
}: {
products: MondrianList[];
fetchNextPage: () => void;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
isLoading: boolean;
isError: boolean;
refetch: () => void;
emptyState: {
image?: React.ReactNode;
title?: string;
description?: string;
};
isCommonList?: boolean;
isDraft?: boolean;
storeId?: string;
storePage?: boolean;
}) => {
// Handle scroll for infinite loading with throttling
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const scrollPosition = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
if (
scrollPosition >= documentHeight - 300 &&
!isFetchingNextPage &&
hasNextPage
) {
fetchNextPage();
}
ticking = false;
});
ticking = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
// Memoize expensive filtering operations
const { oddProducts, evenProducts } = useMemo(() => {
const odd = products.filter((_, index) => index % 2 === 0);
const even = products.filter((_, index) => index % 2 !== 0);
return { oddProducts: odd, evenProducts: even };
}, [products]);
const oddHeight = useMemo(() => [220, 260, 320, 200, 280], []);
const evenHeight = useMemo(() => [280, 200, 260, 220, 320], []);
return (
<div className="px-4 flex flex-col w-full">
{!isCommonList ? (
<>
{isLoading ? (
<MondrianSkeleton />
) : isError ? (
<ErrorState onRetry={refetch} />
) : oddProducts.length === 0 && evenProducts.length === 0 ? (
<EmptyState
type="mondrian"
mondrianImage={emptyState.image}
mondrianTitle={emptyState.title}
mondrianDescription={emptyState.description}
/>
) : (
<>
<div className="flex gap-1 w-full">
{/* Column 1 (Odd products - index 0, 2, ...) */}
<div className="flex flex-col gap-1 w-[calc(50%)]">
{oddProducts.map((product, index: number) => {
const height = oddHeight[index % 5];
return (
<ProductCard
key={product.id}
product={product}
height={height}
storeId={storeId}
storePage={storePage}
/>
);
})}
</div>
{/* Column 2 (Even products - index 1, 3, ...) */}
<div className="flex flex-col gap-1 w-[calc(50%)]">
{evenProducts.map((product, index: number) => {
const height = evenHeight[index % 5];
return (
<ProductCard
key={product.id}
product={product}
height={height}
storeId={storeId}
storePage={storePage}
/>
);
})}
</div>
</div>
{hasNextPage && <MondrianSkeleton isMini={true} />}
</>
)}
</>
) : (
<>
{isLoading ? (
<CommonSkeleton />
) : isError ? (
<ErrorState onRetry={refetch} />
) : oddProducts.length === 0 && evenProducts.length === 0 ? (
<EmptyState
type="mondrian"
mondrianImage={emptyState.image}
mondrianTitle={emptyState.title}
mondrianDescription={emptyState.description}
/>
) : (
<>
<div className="gap-1 w-full grid grid-cols-3">
{products.map((product, index: number) => {
return (
<ProductCard
isDraft={isDraft}
key={index}
product={product}
storePage={storePage}
height={136}
storeId={storeId}
/>
);
})}
</div>
{hasNextPage && <CommonSkeleton />}
</>
)}
</>
)}
</div>
);
};
const ProductCard = ({
product,
height,
isDraft,
storeId,
storePage,
}: {
product: MondrianList;
height: number;
isDraft?: boolean;
storeId?: string;
storePage?: boolean;
}) => {
const [isPublishModalOpen, setIsPublishModalOpen] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const { theme } = useRootData();
const handleVideoClick = () => {
if (!videoRef.current) return;
if (videoRef.current.paused) {
videoRef.current.play();
} else {
videoRef.current.pause();
}
};
return (
<>
{isDraft ? (
<>
<div
className="w-full relative aspect-square overflow-hidden"
style={{ height: `${height}px` }}
>
{product.images?.[0] &&
(isVideo(product.images[0]) ? (
<div className="w-full h-full relative">
<video
ref={videoRef}
src={product.images[0]}
className={"w-full object-cover"}
style={{
height: height ? `${height}px` : "100%",
borderRadius: "10px",
}}
muted
loop
autoPlay
playsInline
onClick={handleVideoClick}
onError={(e) => {
const target = e.currentTarget;
const parent = target.parentElement;
if (parent) {
const img = document.createElement("img");
img.src =
theme === "dark"
? placeholderImageDark
: placeholderImage;
img.alt = product.title || "Product image";
img.className = "w-full h-full object-contain";
img.style.borderRadius = "10px";
img.style.backgroundColor =
theme === "dark" ? "#121212" : "#f2f0f0";
parent.replaceChild(img, target);
}
}}
/>
</div>
) : (
<img
src={
product.images[0] ||
(theme === "dark" ? placeholderImageDark : placeholderImage)
}
alt={product.title || "Product image"}
className={`w-full h-full ${product.images?.[0] ? "object-cover" : "object-contain"}`}
style={{
borderRadius: "10px",
backgroundColor: !product.images?.[0]
? theme === "dark"
? "#121212"
: "#f2f0f0"
: undefined,
}}
onError={(e) => {
e.currentTarget.src =
theme === "dark"
? placeholderImageDark
: placeholderImage;
e.currentTarget.style.objectFit = "contain";
e.currentTarget.style.backgroundColor =
theme === "dark" ? "#121212" : "#f2f0f0";
}}
/>
))}
{/* Draft mode overlay - clickable container */}
{product.isDraft && (
<div
className="absolute inset-0 cursor-pointer"
role="button"
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsPublishModalOpen(true);
}
}}
tabIndex={0}
onClick={() => {
setIsPublishModalOpen(true);
}}
>
{/* Top-right corner for EyeOff icon */}
<div className="absolute top-2 right-2 z-10">
<EyeOff className="text-WHITE" size={24} />
</div>
{/* Semi-transparent overlay for the whole area */}
<div className="absolute inset-0 bg-BLACK/50 rounded-lg" />
</div>
)}
{/* Discount badge */}
{product.discountPercent && product.discountPercent !== 0 && (
<DiscountBadge discountPercent={product.discountPercent} />
)}
</div>
<PublishModal
open={isPublishModalOpen}
onOpenChange={setIsPublishModalOpen}
storeId={storeId}
postId={product.id}
/>
</>
) : (
<div
className="relative rounded-lg overflow-hidden"
style={{ height: `${height}px` }}
>
<Link
to={`${storePage ? `/store/${storeId}/product/${product.id}` : `/product/${product.id}`}`}
className="block w-full h-full"
>
<ProductImageCarousel
images={product.images || []}
hideIndex={true}
height={height}
/>
</Link>
{/* Discount badge */}
{product.discountPercent && product.discountPercent !== 0 && (
<DiscountBadge discountPercent={product.discountPercent} />
)}
{/* Inactive product overlay */}
{product.isActive === false && (
<div className="absolute inset-0 pointer-events-none">
{/* Top-right corner for EyeOff icon */}
<div className="absolute top-2 right-2 z-10">
<EyeOff className="text-WHITE" size={24} />
</div>
{/* Semi-transparent overlay for the whole area */}
<div className="absolute inset-0 bg-BLACK/50 rounded-lg" />
</div>
)}
{/* Overlay info - Airbnb style */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 via-black/40 to-transparent p-2 pt-6 pointer-events-none">
<div className="flex w-full justify-between gap-1">
{/* Title */}
<h3 className="text-white text-xs font-semibold line-clamp-1 mb-0.5">
{product.title}
</h3>
{/* Price */}
<div className="flex flex-col items-center gap-0 relative">
{product.discountPrice && (
<p className="text-white text-xs font-bold">
{`${product.discountPrice.toLocaleString()}`}
<span className="text-white text-[8px] mr-[2px]">
تومان
</span>
</p>
)}
{product.basePrice && product.discountPercent ? (
<p className="text-white/80 text-[10px] line-through absolute bottom-4 left-0">
{product.basePrice.toLocaleString()}
</p>
) : null}
</div>
</div>
</div>
</div>
)}
</>
);
};
const DiscountBadge = ({ discountPercent }: { discountPercent?: number }) => {
return (
<div className="absolute items-center gap-1 flex top-1 right-1 z-10 bg-[#FDFDFD]/75 text-[#FF3B30] py-[2px] px-2 rounded-[15px]">
<p
className="text-B14 font-bold leading-[20px]"
dir="ltr"
style={{ fontFamily: "Arial, sans-serif" }}
>
{discountPercent}
</p>
<img src={discountRed} alt="discount-red" className="w-5 h-5" />
</div>
);
};
const PublishModal = ({
open,
onOpenChange,
storeId,
postId,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
storeId?: string;
postId?: string;
}) => {
const navigate = useNavigate();
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false);
const deletePostMutation = useDeleteInstagramPost();
const handleManualAddClick = (isAI: boolean) => {
// Navigate to manual add page
if (storeId && postId) {
navigate(`/store/add/manual/${storeId}?id=${postId}&ai=${isAI}`);
} else {
// Fallback to create store if no storeId available
navigate("/store/create");
}
onOpenChange(false);
};
const handleDeleteClick = () => {
onOpenChange(false); // Close the publish modal first
setIsDeleteConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (postId) {
try {
await deletePostMutation.mutateAsync(postId);
onOpenChange(false);
setIsDeleteConfirmOpen(false);
} catch (error) {
console.error("Failed to delete post:", error);
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogOverlay className="bg-BLACK/50" />
<DialogContent
className={`p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)]`}
>
{/* Close button */}
<X
className="absolute top-4 right-4 z-10"
onClick={() => onOpenChange(false)}
/>
<div className="flex flex-col gap-6 items-center w-full p-6 pt-12">
{/* Header text */}
<div className="text-center px-4 flex flex-col gap-2">
<div className="flex items-center justify-center gap-2">
<Eye size={36} />
<MoveRight className="text-B14 font-bold" size={32} />
<EyeOff size={36} />
</div>
<p className="text-B16 font-bold mt-2">نمایش پست</p>
<p className="text-R14 mb-2">
با تکمیل اطلاعات محصولات معلق، آنها را برای مخاطبین قابل مشاهده
کنید
</p>
</div>
{/* Action buttons */}
<div className="flex flex-col gap-4 w-full">
{/* تکمیل اطلاعات با AI button */}
<Button
onClick={() => handleManualAddClick(true)}
variant="dark"
size="xl"
className="w-full"
>
<Brain size={20} />
<span className="text-R14 font-medium">تکمیل اطلاعات با AI</span>
</Button>
{/* تکمیل اطلاعات button */}
<Button
onClick={() => handleManualAddClick(false)}
variant="outline"
size="xl"
className="w-full"
>
<Pencil size={20} />
<span className="text-R14 font-medium">تکمیل اطلاعات</span>
</Button>
</div>
{/* Bottom text */}
<Button
variant="ghost"
size="lg"
className="w-full text-R12 text-red-500 text-center"
onClick={handleDeleteClick}
disabled={deletePostMutation.isPending}
>
{deletePostMutation.isPending ? "در حال حذف..." : "حذف پست"}
</Button>
</div>
</DialogContent>
{/* Delete Confirmation Modal */}
<Dialog open={isDeleteConfirmOpen} onOpenChange={setIsDeleteConfirmOpen}>
<DialogOverlay className="bg-BLACK/50" />
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)]">
<div className="flex flex-col gap-6 items-center w-full p-6">
<div className="text-center flex flex-col gap-4">
<div className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center">
<X
className="text-red-500"
size={32}
onClick={() => setIsDeleteConfirmOpen(false)}
/>
</div>
<h3 className="text-B16 font-bold">حذف پست</h3>
<p className="text-R14 text-BLACK2">
آیا از حذف این پست اطمینان دارید؟ این عمل قابل بازگشت نیست.
</p>
</div>
<div className="flex flex-col gap-3 w-full">
<Button
onClick={handleConfirmDelete}
variant="destructive"
size="xl"
className="w-full"
disabled={deletePostMutation.isPending}
>
{deletePostMutation.isPending ? "در حال حذف..." : "بله، حذف کن"}
</Button>
<Button
onClick={() => setIsDeleteConfirmOpen(false)}
variant="outline"
size="xl"
className="w-full"
disabled={deletePostMutation.isPending}
>
انصراف
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Dialog>
);
};
export const CommonSkeleton = () => {
return (
<div className="gap-1 w-full grid grid-cols-3">
{Array(9)
.fill(1)
.map((_, index: number) => {
return (
<Skeleton
key={index}
className={`w-full `}
style={{ height: `136px` }}
/>
);
})}
</div>
);
};
const MondrianSkeleton = ({ isMini }: { isMini?: boolean }) => {
const oddHeight = isMini ? [200, 240] : [200, 240, 300, 180, 260];
const evenHeight = isMini ? [260, 180] : [260, 180, 240, 200, 300];
return (
<div className="flex gap-1 w-full mt-4">
{/* Column 1 (Odd products - index 0, 2, ...) */}
<div className="flex flex-col gap-1 w-1/2">
{oddHeight.map((height, index: number) => {
return (
<Skeleton
key={index}
className={`w-full `}
style={{ height: `${height}px` }}
/>
);
})}
</div>
{/* Column 2 (Even products - index 1, 3, ...) */}
<div className="flex flex-col gap-1 w-1/2">
{evenHeight.map((height, index: number) => {
return (
<Skeleton
key={index}
className={`w-full `}
style={{ height: `${height}px` }}
/>
);
})}
</div>
</div>
);
};
export default MondrianProductList;