winofy-front/src/components/store/product-grid-card.tsx
2026-08-23 09:24:22 +03:30

123 lines
5.1 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.

"use client";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { Minus, Plus } from "lucide-react";
import { addToCart, updateCartItemQuantity, removeCartItem } from "@/lib/api/cart";
import type { CartItem, ProductListItem } from "@/types/api";
/** designspec.md §2.5 — product-card, 157×254, with the two-state add/stepper control. */
export function ProductGridCard({ product, cartItem }: { product: ProductListItem; cartItem: CartItem | null }) {
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
const [localItem, setLocalItem] = useState(cartItem);
const [error, setError] = useState<string | null>(null);
const quantity = localItem?.quantity ?? 0;
const disabled = !product.is_active || product.is_out_of_stock;
useEffect(() => {
if (!error) return;
const timeout = setTimeout(() => setError(null), 3000);
return () => clearTimeout(timeout);
}, [error]);
function handleAdd() {
startTransition(async () => {
const result = await addToCart(product.uuid, 1);
if (result.ok) {
setLocalItem(result.data);
router.refresh();
} else if (result.status === 401) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
} else {
setError(result.message);
}
});
}
function handleChange(nextQuantity: number) {
if (!localItem) return;
startTransition(async () => {
if (nextQuantity <= 0) {
const result = await removeCartItem(localItem.uuid);
// Only clear locally once the backend actually confirms removal — otherwise
// the card would show "add" again while the item still exists server-side.
if (result.ok) setLocalItem(null);
else setError(result.message);
} else {
const result = await updateCartItemQuantity(localItem.uuid, nextQuantity);
if (result.ok) setLocalItem(result.data);
else setError(result.message);
}
router.refresh();
});
}
return (
<article className="relative flex h-[254px] w-[157px] flex-col overflow-hidden rounded-xl border border-gray-300 bg-white shadow-[0_2px_10px_-6px_rgba(0,0,0,0.039),0_14px_28px_-12px_rgba(0,0,0,0.071)]">
{error && (
<div className="absolute inset-x-1 top-1 z-10 rounded-lg bg-error px-2 py-1 text-center text-[11px] leading-tight text-white">
{error}
</div>
)}
<Link href={`/products/${product.uuid}`} className="block shrink-0">
<div className="relative h-[130px] w-full overflow-hidden rounded-t-xl bg-muted">
{product.image_url && (
// eslint-disable-next-line @next/next/no-img-element
<img src={product.image_url} alt={product.name} className="h-full w-full object-cover" />
)}
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/40 to-transparent" />
</div>
</Link>
<div className="flex flex-1 flex-col justify-between p-3">
<div className="flex flex-col gap-1.5">
<Link href={`/products/${product.uuid}`}>
<h3 className="truncate text-right text-[14px] font-bold leading-none text-foreground">{product.name}</h3>
</Link>
<p className="text-right text-body-medium font-extrabold text-primary-action">
{product.price.toLocaleString("fa-IR")} تومان
</p>
</div>
{disabled ? (
<span className="text-center text-label text-gray-500">ناموجود</span>
) : quantity === 0 ? (
<button
type="button"
onClick={handleAdd}
disabled={isPending}
aria-label="افزودن به سبد"
className="flex h-8 w-8 items-center justify-center self-start rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
) : (
<div className="flex h-10 w-[110px] items-center justify-between gap-4 rounded-3xl border border-gray-300 bg-white p-1">
<button
type="button"
onClick={() => handleChange(quantity + 1)}
disabled={isPending}
aria-label="افزایش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary-action text-white disabled:opacity-40"
>
<Plus className="h-4 w-4" />
</button>
<span className="text-[18px] font-bold leading-none text-foreground">{quantity.toLocaleString("fa-IR")}</span>
<button
type="button"
onClick={() => handleChange(quantity - 1)}
disabled={isPending}
aria-label="کاهش تعداد"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-bg text-primary-action disabled:opacity-40"
>
<Minus className="h-4 w-4" />
</button>
</div>
)}
</div>
</article>
);
}