Files
smart-electronics-frontend/features/cart/components/CartItemCard.tsx
@jcarymuhammedow f32e7538e1 fixed some bugs
2026-02-05 19:36:12 +05:00

434 lines
14 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { Minus, Plus, Trash2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks";
import { useTranslations } from "next-intl";
import type { CartItem } from "@/lib/types/api";
interface CartItemCardProps {
item: CartItem;
onUpdate?: () => void;
}
const PENDING_CART_UPDATES_KEY = "pendingCartUpdates";
interface PendingUpdate {
quantity: number;
timestamp: number;
retryCount: number;
}
export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
const t = useTranslations();
const [localQuantity, setLocalQuantity] = useState(item.quantity);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isInitializedRef = useRef(false);
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const { mutate: updateQuantity } = useUpdateCartItemQuantity();
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart();
const availableStock = item.product.stock || 0;
useEffect(() => {
setLocalQuantity(item.quantity);
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
}, [item.quantity]);
const savePendingUpdate = useCallback(
(quantity: number) => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
? JSON.parse(stored)
: {};
pending[item.product_id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current,
};
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending),
);
} catch (error) {
console.error("Failed to save pending update:", error);
}
},
[item.product_id],
);
const clearPendingUpdate = useCallback(() => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[item.product_id];
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_CART_UPDATES_KEY);
} else {
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending),
);
}
}
} catch (error) {
console.error("Failed to clear pending update:", error);
}
}, [item.product_id]);
const retrySync = useCallback((quantity: number) => {
const maxRetries = 4;
const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
return;
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000);
retryCountRef.current++;
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity);
}, delay);
}, []);
retrySyncRef.current = retrySync;
const syncToServer = useCallback(
(quantity: number) => {
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
if (quantity <= 0) {
removeItem(item.product_id, {
onSuccess: () => {
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error("Remove failed:", error);
isRequestInFlightRef.current = false;
retrySyncRef.current?.(quantity);
},
});
} else {
updateQuantity(
{ productId: item.product_id, quantity },
{
onSuccess: () => {
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error("Update failed:", error);
isRequestInFlightRef.current = false;
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity);
},
},
);
}
},
[
item.product_id,
item.quantity,
updateQuantity,
removeItem,
onUpdate,
clearPendingUpdate,
],
);
syncToServerRef.current = syncToServer;
useEffect(() => {
if (!isInitializedRef.current) {
return;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (localQuantity === item.quantity) {
return;
}
if (localQuantity <= 0 && item.quantity > 0) {
// Delete operation
} else if (localQuantity <= 0) {
return;
}
savePendingUpdate(localQuantity);
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [localQuantity, item.quantity, savePendingUpdate]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
}
setLocalQuantity((prev) => prev + 1);
};
const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (localQuantity <= 1) {
handleDelete();
return;
}
setLocalQuantity((prev) => prev - 1);
};
const handleDelete = () => {
setLocalQuantity(0);
clearPendingUpdate();
};
const getImageSrc = () => {
if (item.product.image) return item.product.image;
if (item.product.images && item.product.images.length > 0)
return item.product.images[0];
return "/placeholder.svg";
};
return (
<>
<Card className="p-6 shadow-sm border border-gray-200 rounded-2xl hover:shadow-md transition-shadow duration-200">
<div className="flex flex-col sm:flex-row gap-6">
{/* Product Image & Info */}
<div className="flex gap-4 flex-1">
<div className="relative w-[100px] h-[133px] rounded-xl border border-gray-200 overflow-hidden shrink-0 bg-gray-50">
<Image
src={getImageSrc()}
alt={item.product.name}
fill
className="object-contain p-2"
/>
</div>
<div className="flex flex-col gap-2">
<h3 className="font-bold text-base text-gray-900 line-clamp-2">
{item.product.name}
</h3>
{/* <p className="text-sm text-gray-500 font-medium">
{item.seller?.name || "Store"}
</p> */}
{/* {availableStock <= 5 && (
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
<p className="text-xs text-amber-600 font-semibold">
{t("only_left", { count: availableStock })}
</p>
</div>
)} */}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isRemoving}
className="w-fit cursor-pointer p-0 h-auto hover:bg-transparent text-gray-600 hover:text-red-500 transition-colors group"
>
<Trash2 className="h-5 w-5 group-hover:scale-110 transition-transform" />
</Button>
</div>
</div>
{/* Price & Quantity */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-6 justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-600">
{t("unit_price")}{" "}
<span className="text-gray-900 font-bold">
{item.price_formatted}
</span>
</p>
{item.discount_formatted &&
item.discount_formatted !== "0 TMT" && (
<p className="text-sm font-medium text-emerald-600">
{t("discount")} {item.discount_formatted}
</p>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">
{t("total_price")}
</span>
<span className="bg-emerald-500 text-white px-4 py-1.5 rounded-[10px] font-bold text-base shadow-sm">
{(
parseFloat(item.product.price_amount || "0") * localQuantity
).toFixed(2)}{" "}
TMT
</span>
</div>
</div>
{/* Quantity Controls */}
<div className="flex items-center gap-3">
<Button
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
disabled={isSyncing}
className={`cursor-pointer rounded-[10px] h-10 w-10 border-2 border-gray-200 hover:border-gray-900 hover:bg-gray-50 transition-all duration-200 ${
isSyncing ? "opacity-50" : ""
}`}
>
<Minus className="h-4 w-4 text-gray-700" />
</Button>
<div className="min-w-[48px] text-center font-bold text-lg relative">
{isSyncing ? (
<div className="flex items-center justify-center">
<div className="w-4 h-4 border-2 border-gray-300 border-t-gray-900 rounded-full animate-spin" />
</div>
) : (
localQuantity
)}
{syncError && (
<span
className="absolute -top-1 -right-2 h-2.5 w-2.5 bg-red-500 rounded-full border-2 border-white shadow-sm"
title="Sync error"
/>
)}
</div>
<Button
variant="outline"
size="icon"
onClick={handleQuantityIncrease}
disabled={isSyncing || localQuantity >= availableStock}
className={`rounded-[10px] h-10 w-10 cursor-pointer border-2 transition-all duration-200 ${
localQuantity >= availableStock
? "opacity-30 cursor-not-allowed border-gray-200"
: "border-gray-900 bg-gray-900 hover:bg-gray-800"
} ${isSyncing ? "opacity-50" : ""}`}
>
<Plus
className={`h-4 w-4 ${localQuantity >= availableStock ? "text-gray-400" : "text-white"}`}
/>
</Button>
</div>
</div>
</div>
</Card>
{/* Stock Limit Modal */}
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md rounded-3xl border-gray-200">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-amber-100 p-4">
<AlertTriangle className="h-7 w-7 text-amber-600" />
</div>
</div>
<DialogTitle className="text-center text-2xl font-bold text-gray-900">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-3 text-gray-600 leading-relaxed">
{t("stock_limit_message", {
product: item.product.name,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-6">
<Button
onClick={() => setShowStockModal(false)}
className="w-full h-12 rounded-2xl cursor-pointer bg-gray-900 hover:bg-gray-800 font-semibold shadow-md"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}