Vitron-Front/app/components/product/ProductImageCarousel.tsx
Arda Samadi 7a385ba18e fix(pwa): top safe-area inset on products page + owner product carousel
Two more iOS-notch overlaps:
- Seller محصولات page: top container now carries pt-[env(safe-area-inset-top)]
  on mobile (lg:pt-0) so the heading drops below the notch.
- Owner single-product view: the carousel's ⋮/back buttons sat at top-3 (under
  the notch); offset them by calc(env(safe-area-inset-top,0px)+12px), matching
  the buyer back button. Only the owner buttons (product detail, top of screen)
  use this — grid tiles don't pass isOwner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:16:28 +03:30

478 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { useCallback, useState, useRef, useEffect } from "react";
import { useNavigate } from "@remix-run/react";
import { safeGoBack } from "~/utils/helpers";
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from "~/components/ui/carousel";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "~/components/ui/drawer";
import { Dialog, DialogContent, DialogOverlay } from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { useCarousel } from "~/hooks/useCarousel";
import { isVideo } from "../MondrianProductList";
import {
Edit3,
Trash2,
X,
EllipsisVertical,
EyeOff,
Eye,
ArrowRight,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import {
useDeleteProduct,
useToggleProductStatus,
} from "~/requestHandler/use-product-hooks";
import placeholderImage from "~/assets/icons/product.png";
import placeholderImageDark from "~/assets/icons/product-dark.png";
import { useRootData } from "~/hooks/use-root-data";
interface ProductImageCarouselProps {
images: string[];
productId?: string;
storeId?: string;
hideIndex?: boolean;
height?: number;
borderRadius?: number;
isOwner?: boolean;
isActive?: boolean;
controlsIsOn?: boolean;
showArrows?: boolean;
onStatusChange?: (newStatus: boolean) => void;
isChangeStatusDialogOpen?: boolean;
setIsChangeStatusDialogOpen?: (open: boolean) => void;
}
export const ProductImageCarousel = ({
images,
productId,
storeId,
hideIndex = false,
height,
borderRadius,
controlsIsOn = false,
showArrows = false,
isOwner = false,
isActive = true,
onStatusChange,
isChangeStatusDialogOpen,
setIsChangeStatusDialogOpen,
}: ProductImageCarouselProps) => {
const navigate = useNavigate();
const [currentIndex, setCurrentIndex] = useState(0);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [currentActiveStatus, setCurrentActiveStatus] = useState(isActive);
const { theme } = useRootData();
const { api, setApi } = useCarousel({
onSelect: useCallback(
(carouselApi: CarouselApi) => {
if (!carouselApi) return;
setCurrentIndex(carouselApi.selectedScrollSnap());
},
[setCurrentIndex]
),
});
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
// Hooks for product management
const deleteProductMutation = useDeleteProduct();
const toggleProductStatusMutation = useToggleProductStatus();
// Sync local state with prop changes
useEffect(() => {
setCurrentActiveStatus(isActive);
}, [isActive]);
const handleSettingsClick = () => {
setIsDrawerOpen(true);
};
const handleEditProduct = () => {
if (productId && storeId) {
navigate(`/store/add/manual/${storeId}?edit=${productId}`);
}
setIsDrawerOpen(false);
};
const handleDeleteClick = () => {
setIsDrawerOpen(false);
setIsDeleteDialogOpen(true);
};
const handleDeactiveClick = () => {
setIsDrawerOpen(false);
if (setIsChangeStatusDialogOpen) setIsChangeStatusDialogOpen(true);
};
const handleConfirmDelete = () => {
if (productId) {
deleteProductMutation.mutate(productId, {
onSuccess: () => {
setIsDeleteDialogOpen(false);
// Navigate back or refresh the page
navigate(-1);
},
});
}
};
const handleConfirmStatusChange = () => {
if (productId) {
const newStatus = !currentActiveStatus;
toggleProductStatusMutation.mutate(
{ productId, isActive: newStatus },
{
onSuccess: () => {
if (setIsChangeStatusDialogOpen) setIsChangeStatusDialogOpen(false);
// Update local state
setCurrentActiveStatus(newStatus);
// Call parent callback if provided
if (onStatusChange) {
onStatusChange(newStatus);
}
},
}
);
}
};
return (
<div
className="w-full aspect-square relative overflow-hidden"
style={{
height: height ? `${height}px` : "100%",
borderRadius: borderRadius !== undefined ? `${borderRadius}px` : "10px",
}}
>
{!hideIndex && images.length > 1 && (
<div className="absolute bottom-3 left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 px-3 py-2 rounded-[10px] text-B12">
{images.length} / {currentIndex + 1}
</div>
)}
{isOwner && (
<>
<button
onClick={handleSettingsClick}
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute left-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<EllipsisVertical size={20} className="text-WHITE2" />
</button>
<button
onClick={() => safeGoBack(navigate)}
style={{ top: "calc(env(safe-area-inset-top, 0px) + 12px)" }}
className="absolute right-2 z-10 bg-DARK_OVERLAY/90 text-WHITE2 rounded-full h-9 w-9 flex items-center justify-center hover:bg-DARK_OVERLAY_HOVER transition-colors"
>
<ArrowRight size={20} className="text-WHITE2" />
</button>
</>
)}
<Carousel
setApi={setApi}
opts={{
align: "start",
loop: true,
}}
className="w-full h-full"
>
<CarouselContent className="-ml-0 h-full">
{images.map((media, index) => (
<CarouselItem key={index} className={`pl-0 h-full`}>
<div className="w-full aspect-square overflow-hidden h-full relative">
{isVideo(media) ? (
<div className="w-full h-full relative">
<video
ref={(el) => (videoRefs.current[index] = el)}
src={media}
className="w-full object-cover"
style={{
height: height ? `${height}px` : "100%",
borderRadius:
borderRadius !== undefined
? `${borderRadius}px`
: "10px",
}}
controls={controlsIsOn}
muted
autoPlay
loop
playsInline
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 = `تصویر ${index + 1}`;
img.className = "w-full object-contain";
img.style.height = height ? `${height}px` : "100%";
img.style.borderRadius =
borderRadius !== undefined
? `${borderRadius}px`
: "10px";
img.style.backgroundColor =
theme === "dark" ? "#121212" : "#f2f0f0";
parent.replaceChild(img, target);
}
}}
/>
</div>
) : (
<img
src={
media ||
(theme === "dark"
? placeholderImageDark
: placeholderImage)
}
loading={index === 0 ? "eager" : "lazy"}
onError={(e) => {
e.currentTarget.src =
theme === "dark"
? placeholderImageDark
: placeholderImage;
e.currentTarget.style.objectFit = "contain";
e.currentTarget.style.backgroundColor =
theme === "dark" ? "#121212" : "#f2f0f0";
}}
alt={`تصویر ${index + 1}`}
className={`w-full ${media ? "object-cover" : "object-contain"}`}
style={{
height: height ? `${height}px` : "100%",
borderRadius:
borderRadius !== undefined
? `${borderRadius}px`
: "10px",
}}
/>
)}
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2">
{images.map((_, index) => (
<button
key={index}
onClick={() => api?.scrollTo(index)}
className={`w-2 h-2 rounded-full transition-colors ${
index === currentIndex ? "bg-WHITE" : "bg-WHITE/50"
}`}
/>
))}
</div>
)}
{/* Prev / next arrows (desktop, opt-in) */}
{showArrows && images.length > 1 && (
<>
<button
type="button"
onClick={() => api?.scrollPrev()}
aria-label="تصویر قبلی"
className="hidden lg:flex absolute top-1/2 right-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronRight size={20} />
</button>
<button
type="button"
onClick={() => api?.scrollNext()}
aria-label="تصویر بعدی"
className="hidden lg:flex absolute top-1/2 left-3 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-WHITE/90 shadow-md items-center justify-center text-BLACK hover:bg-WHITE transition-colors"
>
<ChevronLeft size={20} />
</button>
</>
)}
{/* Settings Drawer */}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerContent>
<DrawerHeader className="flex items-center relative justify-center">
<X
size={24}
className="text-BLACK2 absolute left-4"
onClick={() => setIsDrawerOpen(false)}
/>
<DrawerTitle className="text-center text-B12 font-bold">
تنظیمات محصول
</DrawerTitle>
</DrawerHeader>
<div className="flex gap-10 p-4 pb-6 items-center justify-center">
<div
className="flex flex-col items-center justify-center gap-1"
onClick={handleEditProduct}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleEditProduct();
}
}}
role="button"
tabIndex={0}
aria-label="edit product"
>
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
<Edit3 size={24} className="text-WHITE" />
</div>
<p className="text-R12 text-center">ویرایش محصول</p>
</div>
<div
className="flex flex-col items-center justify-center gap-1"
onClick={handleDeactiveClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleDeactiveClick();
}
}}
role="button"
tabIndex={0}
aria-label={
currentActiveStatus ? "deactive product" : "active product"
}
>
<div className="bg-BLACK h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
{currentActiveStatus ? (
<EyeOff size={24} className="text-WHITE" />
) : (
<Eye size={24} className="text-WHITE" />
)}
</div>
<p className="text-R12 text-center">
{currentActiveStatus ? "عدم نمایش محصول" : "نمایش محصول"}
</p>
</div>
<div
className="flex flex-col items-center justify-center gap-1"
onClick={handleDeleteClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleDeleteClick();
}
}}
role="button"
tabIndex={0}
aria-label="Delete product"
>
<div className="bg-red-500 h-12 w-12 shrink-0 flex flex-col items-center justify-center rounded-full">
<Trash2 size={24} className="text-WHITE" />
</div>
<p className="text-R12 text-center">حذف محصول</p>
</div>
</div>
</DrawerContent>
</Drawer>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogOverlay className="bg-BLACK/50 z-[51]" />
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)] z-[51]">
<div className="flex flex-col gap-6 items-center w-full p-6 overflow-hidden">
<div className="text-center flex flex-col gap-4 items-center justify-center">
<Trash2 className="text-BLACK" size={32} />
<h3 className="text-B16 font-bold">حذف محصول</h3>
<p className="text-R14 text-BLACK2">
آیا میخواهید این محصول را حذف کنید؟
</p>
</div>
<div className="flex gap-3 w-full">
<Button
onClick={handleConfirmDelete}
variant="dark"
size="xl"
className="w-full"
disabled={deleteProductMutation.isPending}
>
{deleteProductMutation.isPending ? "در حال حذف..." : "حذف پست"}
</Button>
<Button
onClick={() => setIsDeleteDialogOpen(false)}
variant="outline"
size="xl"
className="w-full"
disabled={deleteProductMutation.isPending}
>
انصراف
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Status Change Confirmation Dialog */}
<Dialog
open={isChangeStatusDialogOpen}
onOpenChange={setIsChangeStatusDialogOpen}
>
<DialogOverlay className="bg-BLACK/50 z-[51]" />
<DialogContent className="p-0 bg-WHITE overflow-y-auto rounded-[15px] w-[calc(100%-40px)] z-[51]">
<div className="flex flex-col gap-6 items-center w-full p-6 overflow-hidden">
<div className="text-center flex flex-col gap-4 items-center justify-center">
{currentActiveStatus ? (
<EyeOff className="text-BLACK" size={32} />
) : (
<Eye className="text-BLACK" size={32} />
)}
<h3 className="text-B16 font-bold">
{currentActiveStatus ? "عدم نمایش محصول" : "نمایش محصول"}
</h3>
<p className="text-R14 text-BLACK2">
{currentActiveStatus
? "آیا می‌خواهید این محصول را غیرفعال کنید؟"
: "آیا می‌خواهید این محصول را فعال کنید؟"}
</p>
</div>
<div className="flex gap-3 w-full">
<Button
onClick={handleConfirmStatusChange}
variant="dark"
size="xl"
className="w-full"
disabled={toggleProductStatusMutation.isPending}
>
{toggleProductStatusMutation.isPending
? "در حال تغییر..."
: currentActiveStatus
? "غیرفعال کردن"
: "فعال کردن"}
</Button>
<Button
onClick={() => {
if (setIsChangeStatusDialogOpen)
setIsChangeStatusDialogOpen(false);
}}
variant="outline"
size="xl"
className="w-full"
disabled={toggleProductStatusMutation.isPending}
>
انصراف
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};