- MondrianProductList: add desktop 4-column masonry (mobile 2-col unchanged); improves home "for you", search results and seller feed at lg+ - search: hide mobile SearchTopBar at lg, add results heading + centered 1320px container - cart list: responsive 2-column grid of cart cards + desktop heading - MyLayout: desktop chrome gate now covers / , /search/* and /cart Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
631 lines
21 KiB
TypeScript
631 lines
21 KiB
TypeScript
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], []);
|
||
|
||
// 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>
|
||
|
||
{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;
|