Vitron-Front/app/components/MondrianProductList.tsx
Arda Samadi fa65db8804 Desktop fixes: product seller + actions, collection cover, masonry skeleton
- product: show seller (logo + name) in the desktop info column (was missing
  after hiding the mobile header); action icons (save/share/collection/AI) are
  now circular bordered buttons on desktop instead of a bare icon row
- collection detail: cover constrained to a 360px portrait column with
  object-cover (fixes over-long image + side margins); tighter spacing
- masonry: only render the loading skeleton while actively fetching (removes
  the persistent grey block before the footer); sentinel moved above it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 22:59:24 +03:30

650 lines
22 KiB
TypeScript
Raw 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]);
// Sentinel-based trigger — more reliable than scroll math on desktop, where
// the footer can keep the window-scroll threshold from being reached.
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: "600px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [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], []);
// Desktop masonry: distribute products across 4 columns (round-robin).
const desktopColumns = useMemo(() => {
const cols: MondrianList[][] = [[], [], [], []];
products.forEach((p, i) => cols[i % 4].push(p));
return cols;
}, [products]);
const desktopHeights = useMemo(() => [300, 360, 260, 340, 280, 320], []);
return (
<div className="px-4 lg:px-0 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}
/>
) : (
<>
{/* Mobile / tablet: original two-column masonry (unchanged) */}
<div className="flex gap-1 w-full lg:hidden">
{/* 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>
{/* Desktop: 4-column masonry */}
<div className="hidden lg:flex gap-4 w-full">
{desktopColumns.map((col, ci) => (
<div key={ci} className="flex flex-col gap-4 flex-1 min-w-0">
{col.map((product, index) => (
<ProductCard
key={product.id}
product={product}
height={
desktopHeights[(ci + index) % desktopHeights.length]
}
storeId={storeId}
storePage={storePage}
/>
))}
</div>
))}
</div>
<div ref={loadMoreRef} className="h-px w-full" />
{isFetchingNextPage && <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;