added zoom function, fixed cart quantitu bug

This commit is contained in:
@jcarymuhammedow
2026-02-05 16:38:39 +05:00
parent a1b766fb3b
commit b546deeac0
5 changed files with 413 additions and 39 deletions

View File

@@ -171,7 +171,7 @@ export default function CartPage() {
{Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6">
<p className="text-base font-semibold mb-3">{seller.name}</p>
{/* <p className="text-base font-semibold mb-3">{seller.name}</p> */}
<div className="space-y-4">
{items.map((item) => {
const price = parseFloat(

View File

@@ -48,6 +48,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isInitializedRef = useRef(false); // Track if component has been initialized
// Function refs to solve circular dependency
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
@@ -62,6 +63,10 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
// Initialize from server state
useEffect(() => {
setLocalQuantity(item.quantity);
// Mark as initialized after first render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
}, [item.quantity]);
// Save to sessionStorage
@@ -81,13 +86,13 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
JSON.stringify(pending),
);
} catch (error) {
console.error("Failed to save pending update:", error);
}
},
[item.product_id]
[item.product_id],
);
// Remove from sessionStorage
@@ -103,7 +108,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
} else {
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
JSON.stringify(pending),
);
}
}
@@ -200,7 +205,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
retrySyncRef.current?.(quantity);
},
}
},
);
}
},
@@ -211,13 +216,16 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
removeItem,
onUpdate,
clearPendingUpdate,
]
],
);
// Update ref
syncToServerRef.current = syncToServer;
// Load pending updates from sessionStorage on mount
// DISABLED: This was causing automatic sync on mount, sending 0 quantity to server
// Users should manually refresh or re-add items if there were pending updates
/*
useEffect(() => {
const loadPendingUpdates = () => {
try {
@@ -246,9 +254,15 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
loadPendingUpdates();
}, [item.product_id, item.quantity]);
*/
// Debounced sync
useEffect(() => {
// Don't sync on initial mount - only sync after user interaction
if (!isInitializedRef.current) {
return;
}
// Clear existing timers
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
@@ -259,6 +273,14 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
return;
}
// Don't sync if quantity is 0 or invalid (unless it's a delete operation)
if (localQuantity <= 0 && item.quantity > 0) {
// This is a delete operation, allow it
} else if (localQuantity <= 0) {
// Invalid state, don't sync
return;
}
// Save to sessionStorage immediately
savePendingUpdate(localQuantity);
@@ -336,14 +358,14 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
</div>
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3>
<p className="text-sm text-gray-600">
{/* <p className="text-sm text-gray-600">
{item.seller?.name || "Store"}
</p>
{availableStock <= 5 && (
</p> */}
{/* {availableStock <= 5 && (
<p className="text-xs text-orange-600 font-medium">
{t("only_left", { count: availableStock })}
</p>
)}
)} */}
<Button
variant="ghost"
size="sm"

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { X, ZoomIn, ZoomOut, RotateCw, RotateCcw, Maximize2, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslations } from "next-intl";
interface ProductImageGalleryProps {
images: string[];
productName: string;
@@ -13,10 +14,18 @@ export function ProductImageGallery({
noImageText,
}: ProductImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const t = useTranslations();
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const modalImageRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (images.length <= 1) return;
if (images.length <= 1 || isModalOpen) return;
const startAutoplay = () => {
autoplayTimerRef.current = setInterval(() => {
@@ -28,35 +37,135 @@ export function ProductImageGallery({
return () => {
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
};
}, [images.length]);
}, [images.length, isModalOpen]);
useEffect(() => {
if (isModalOpen) {
document.body.style.overflow = "hidden";
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isModalOpen]);
const handleImageSelect = useCallback(
(index: number) => {
setSelectedImage(index);
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
if (images.length > 1) {
if (images.length > 1 && !isModalOpen) {
autoplayTimerRef.current = setInterval(() => {
setSelectedImage((prev) => (prev + 1) % images.length);
}, 3000);
}
},
[images.length]
[images.length, isModalOpen]
);
const openModal = () => {
setIsModalOpen(true);
resetTransform();
};
const closeModal = () => {
setIsModalOpen(false);
resetTransform();
};
const resetTransform = () => {
setZoom(1);
setRotation(0);
setPosition({ x: 0, y: 0 });
};
const handleZoomIn = () => {
setZoom((prev) => Math.min(prev + 0.25, 5));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(prev - 0.25, 0.5));
};
const handleRotateClockwise = () => {
setRotation((prev) => (prev + 90) % 360);
};
const handleRotateCounterClockwise = () => {
setRotation((prev) => (prev - 90 + 360) % 360);
};
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
if (zoom > 1) {
setIsDragging(true);
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
setDragStart({
x: clientX - position.x,
y: clientY - position.y,
});
}
};
const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
if (isDragging && zoom > 1) {
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
setPosition({
x: clientX - dragStart.x,
y: clientY - dragStart.y,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
if (e.deltaY < 0) {
handleZoomIn();
} else {
handleZoomOut();
}
};
const handleModalImageChange = (direction: "prev" | "next") => {
if (direction === "next") {
setSelectedImage((prev) => (prev + 1) % images.length);
} else {
setSelectedImage((prev) => (prev - 1 + images.length) % images.length);
}
resetTransform();
};
return (
<>
<div className="contents max-w-2xl">
<div className="relative">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white">
<div
className="relative aspect-square w-full rounded-xl md:rounded-2xl overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100 cursor-pointer group shadow-sm hover:shadow-md transition-all"
onClick={openModal}
>
{images.length > 0 ? (
<>
<Image
src={images[selectedImage]}
alt={productName}
fill
className="object-contain"
className="object-contain transition-transform group-hover:scale-105"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 md:p-3 transform translate-y-4 group-hover:translate-y-0 transition-transform">
<Maximize2 className="w-4 h-4 md:w-5 md:h-5 text-gray-800" />
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="flex items-center justify-center h-full text-gray-400 text-sm md:text-base">
{noImageText}
</div>
)}
@@ -86,5 +195,247 @@ export function ProductImageGallery({
)}
</div>
</div>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-99 bg-gradient-to-br from-gray-900/95 via-gray-800/95 to-gray-900/95 backdrop-blur-xl flex flex-col">
{/* Top Bar */}
<div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-20 bg-gradient-to-b from-black/20 to-transparent">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-3 min-w-0 flex-1">
<div className="w-1 h-4 md:h-6 bg-blue-500 rounded-full shrink-0" />
<span className="text-white font-medium text-sm md:text-base truncate">{productName}</span>
</div>
<button
onClick={closeModal}
className="p-2 md:p-2.5 bg-white/10 hover:bg-white/20 rounded-lg md:rounded-xl transition-all backdrop-blur-sm border border-white/10 shrink-0 ml-2"
aria-label="Close"
>
<X className="w-4 h-4 md:w-5 md:h-5 text-white" />
</button>
</div>
</div>
{/* Main Image Area */}
<div className="flex-1 flex items-center justify-center relative px-2 md:px-16 lg:px-20">
{/* Left Arrow - Desktop */}
{images.length > 1 && (
<button
onClick={() => handleModalImageChange("prev")}
className="hidden md:flex absolute left-3 lg:left-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
aria-label="Previous image"
>
<ChevronLeft className="w-5 h-5 md:w-6 md:h-6 text-white" />
</button>
)}
{/* Image Container */}
<div
ref={modalImageRef}
className="w-full h-full flex items-center justify-center overflow-hidden"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
onWheel={handleWheel}
style={{ cursor: zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default" }}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom}) rotate(${rotation}deg)`,
transition: isDragging ? "none" : "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
className="relative w-[90vw] h-[60vh] md:w-[75vw] md:h-[70vh]"
>
<Image
src={images[selectedImage]}
alt={productName}
fill
className="object-contain pointer-events-none select-none"
priority
draggable={false}
/>
</div>
</div>
{/* Right Arrow - Desktop */}
{images.length > 1 && (
<button
onClick={() => handleModalImageChange("next")}
className="hidden md:flex absolute right-3 lg:right-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
aria-label="Next image"
>
<ChevronRight className="w-5 h-5 md:w-6 md:h-6 text-white" />
</button>
)}
</div>
{/* Bottom Control Bar */}
<div className="bg-gradient-to-t from-black/40 via-black/20 to-transparent backdrop-blur-xl border-t border-white/10">
<div className="max-w-7xl mx-auto px-3 md:px-6 py-3 md:py-4">
{/* Mobile Layout */}
<div className="md:hidden flex flex-col gap-2">
{/* Row 1: Navigation */}
{images.length > 1 && (
<div className="flex items-center justify-between gap-2">
<button
onClick={() => handleModalImageChange("prev")}
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
aria-label="Previous"
>
<ChevronLeft className="w-5 h-5 text-white mx-auto" />
</button>
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
<span className="text-white text-sm font-medium whitespace-nowrap">
{selectedImage + 1} / {images.length}
</span>
</div>
<button
onClick={() => handleModalImageChange("next")}
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
aria-label="Next"
>
<ChevronRight className="w-5 h-5 text-white mx-auto" />
</button>
</div>
)}
{/* Row 2: Zoom & Rotate */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10 flex-1">
<button
onClick={handleZoomOut}
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4 text-white mx-auto" />
</button>
<div className="px-2 py-1 bg-white/10 rounded text-center min-w-[50px]">
<span className="text-white text-xs font-medium">{Math.round(zoom * 100)}%</span>
</div>
<button
onClick={handleZoomIn}
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4 text-white mx-auto" />
</button>
</div>
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleRotateCounterClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate counter-clockwise"
>
<RotateCcw className="w-4 h-4 text-white" />
</button>
<button
onClick={handleRotateClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate clockwise"
>
<RotateCw className="w-4 h-4 text-white" />
</button>
</div>
<button
onClick={resetTransform}
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-xs font-medium border border-white/10"
aria-label="Reset view"
>
{t("reset")}
</button>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden md:flex items-center justify-center gap-2">
<button
onClick={() => handleModalImageChange("prev")}
disabled={images.length <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
aria-label="Previous"
>
<ChevronLeft className="w-4 h-4 text-white" />
</button>
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleZoomOut}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4 text-white" />
</button>
<div className="px-3 py-1 bg-white/10 rounded min-w-[60px] text-center">
<span className="text-white text-sm font-medium">{Math.round(zoom * 100)}%</span>
</div>
<button
onClick={handleZoomIn}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4 text-white" />
</button>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleRotateCounterClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate counter-clockwise"
>
<RotateCcw className="w-4 h-4 text-white" />
</button>
<button
onClick={handleRotateClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate clockwise"
>
<RotateCw className="w-4 h-4 text-white" />
</button>
</div>
<button
onClick={resetTransform}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-sm font-medium border border-white/10"
aria-label="Reset view"
>
Reset
</button>
<div className="w-px h-8 bg-white/20" />
<button
onClick={() => handleModalImageChange("next")}
disabled={images.length <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
aria-label="Next"
>
<ChevronRight className="w-4 h-4 text-white" />
</button>
{images.length > 1 && (
<>
<div className="w-px h-8 bg-white/20" />
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
<span className="text-white text-sm font-medium">
{selectedImage + 1} / {images.length}
</span>
</div>
</>
)}
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -19,6 +19,7 @@
"loading": "Загрузка...",
"all_collections_loaded": "Все коллекции загружены"
},
"category": "Категория",
"checkout": "Оформить заказ",
"price_label": "Цена:",

View File

@@ -11,8 +11,8 @@ const nextConfig: NextConfig = {
unoptimized: true,
remotePatterns: [
{
protocol: "http",
hostname: "shop.post.tm",
protocol: "https",
hostname: "hyzmat.app",
// port: "8080",
},
],