From 12d39bc020f394991ab41e7caf4dddb5137dd15e Mon Sep 17 00:00:00 2001 From: "amirhosein.ashourloo" Date: Fri, 11 Sep 2026 08:50:38 +0330 Subject: [PATCH] Add guest mini-cart with header icon, popup, and customer dashboard handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires a localStorage/cookie-backed guest cart (per AI_PROMPT.md contract) into the header cart icon and popup, with add-to-cart in the product card and buy panel resolved via the dedicated by-product store-items endpoint. Continue always hands off to the Meshkee customer dashboard for auth/checkout — no login, register, or payment UI is built on the storefront. Co-Authored-By: Claude Sonnet 5 --- src/app/products/[id]/[slug]/page.tsx | 7 +- src/components/CartProvider.tsx | 93 +++++++++++++++++++ src/components/Header.tsx | 14 +-- src/components/MiniCart.tsx | 126 ++++++++++++++++++++++++++ src/components/ProductBuyPanel.tsx | 15 +++ src/components/icons.tsx | 16 ++++ src/components/ui/ProductCard.tsx | 48 +++++++++- src/lib/api.ts | 10 +- src/lib/cart.ts | 67 ++++++++++++++ src/lib/types.ts | 25 +++++ 10 files changed, 400 insertions(+), 21 deletions(-) create mode 100644 src/components/CartProvider.tsx create mode 100644 src/components/MiniCart.tsx create mode 100644 src/lib/cart.ts diff --git a/src/app/products/[id]/[slug]/page.tsx b/src/app/products/[id]/[slug]/page.tsx index 84fa63e..686683f 100644 --- a/src/app/products/[id]/[slug]/page.tsx +++ b/src/app/products/[id]/[slug]/page.tsx @@ -4,7 +4,7 @@ import { getComments, getProductById, getProductTechnicalInfo, - getStoreItemsByProduct, + getStoreItemByProduct, safe, } from "@/lib/api"; import { categoryHref } from "@/lib/slug"; @@ -30,11 +30,12 @@ export default async function ProductDetailPage({ const { product, relatedProducts } = data; - const [{ items: storeItems }, technicalInfo, { items: comments }] = await Promise.all([ - safe(getStoreItemsByProduct(product.id), { items: [] }), + const [{ storeItem }, technicalInfo, { items: comments }] = await Promise.all([ + safe(getStoreItemByProduct(product.id), { storeItem: null }), safe(getProductTechnicalInfo(id), { form: null, values: [] }), safe(getComments("product", product.id), { items: [] }), ]); + const storeItems = storeItem?.variants ?? []; const images = [ ...(product.thumbnailUrl ? [product.thumbnailUrl] : []), diff --git a/src/components/CartProvider.tsx b/src/components/CartProvider.tsx new file mode 100644 index 0000000..ab43a4d --- /dev/null +++ b/src/components/CartProvider.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { GUEST_CART_STORAGE_KEY, writeGuestCartCookie } from "@/lib/cart"; +import type { CartLine } from "@/lib/types"; + +// Guest cart as a module-level external store (one cart per browser, +// backed by localStorage) rather than React Context — sidesteps the +// SSR/hydration mismatch that a `useState` + hydration-effect pattern would +// hit, per React's useSyncExternalStore docs. +type Listener = () => void; + +let cart: CartLine[] = []; +let initialized = false; +const listeners = new Set(); +// Stable reference required by useSyncExternalStore — a fresh [] literal +// on every call would fail its identity check and loop forever. +const EMPTY_CART: CartLine[] = []; + +function loadFromStorage(): CartLine[] { + try { + const raw = window.localStorage.getItem(GUEST_CART_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function ensureInitialized() { + if (initialized || typeof window === "undefined") return; + cart = loadFromStorage(); + initialized = true; +} + +function commit(next: CartLine[]) { + cart = next; + window.localStorage.setItem(GUEST_CART_STORAGE_KEY, JSON.stringify(cart)); + writeGuestCartCookie(cart); + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: Listener) { + ensureInitialized(); + listeners.add(listener); + return () => listeners.delete(listener); +} + +function getSnapshot(): CartLine[] { + ensureInitialized(); + return cart; +} + +function getServerSnapshot(): CartLine[] { + return EMPTY_CART; +} + +function addItem(item: CartLine) { + ensureInitialized(); + const existing = cart.find((line) => line.id === item.id); + if (existing) { + commit( + cart.map((line) => + line.id === item.id ? { ...line, quantity: line.quantity + item.quantity } : line + ) + ); + } else { + commit([...cart, item]); + } +} + +function updateQuantity(id: string, quantity: number) { + ensureInitialized(); + if (quantity <= 0) { + commit(cart.filter((line) => line.id !== id)); + return; + } + commit(cart.map((line) => (line.id === id ? { ...line, quantity } : line))); +} + +function removeItem(id: string) { + ensureInitialized(); + commit(cart.filter((line) => line.id !== id)); +} + +export function useCart() { + const items = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + const count = items.reduce((sum, line) => sum + line.quantity, 0); + const total = items.reduce((sum, line) => sum + line.price * line.quantity, 0); + + return { items, count, total, addItem, updateQuantity, removeItem }; +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 51b13da..d312596 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -4,7 +4,8 @@ import { useState } from "react"; import Link from "next/link"; import Logo from "./Logo"; import SearchBox from "./SearchBox"; -import { BagIcon, ChevronDownIcon, CloseIcon, MenuIcon } from "./icons"; +import MiniCart from "./MiniCart"; +import { ChevronDownIcon, CloseIcon, MenuIcon } from "./icons"; import { socialLinks } from "./socialLinks"; import { headerNavLinks } from "@/data/site"; import { CUSTOMER_PORTAL_URL } from "@/lib/config"; @@ -38,16 +39,7 @@ export default function Header({ categoryColumns }: { categoryColumns: CategoryN
- - - - 0 - - + (null); + + useEffect(() => { + if (!open) return; + function onClickAway(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onClickAway); + return () => document.removeEventListener("mousedown", onClickAway); + }, [open]); + + return ( +
+ + + {open && ( +
+
+

سبد خرید

+ +
+ + {items.length === 0 ? ( +
سبد خرید شما خالی است
+ ) : ( +
+ {items.map((line) => ( +
+ {line.name} +
+

{line.name}

+
+
+ + + {line.quantity} + + +
+ + {formatToman(line.price * line.quantity)} + +
+
+ +
+ ))} +
+ )} + + {items.length > 0 && ( +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/ProductBuyPanel.tsx b/src/components/ProductBuyPanel.tsx index 9266fc7..26696f0 100644 --- a/src/components/ProductBuyPanel.tsx +++ b/src/components/ProductBuyPanel.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import type { ApiProduct, StoreItem } from "@/lib/types"; +import { productHref } from "@/lib/slug"; +import { useCart } from "./CartProvider"; import { BagIcon, CheckIcon, PhoneIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons"; const trustPoints = [ @@ -20,9 +22,12 @@ export default function ProductBuyPanel({ }) { const [selectedId, setSelectedId] = useState(items[0]?.id); const [added, setAdded] = useState(false); + const { addItem } = useCart(); const selected = items.find((i) => i.id === selectedId) ?? items[0]; const title = product.nameFa || product.title; + const image = + product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg"; const variationName = selected?.selections[0]?.variationName; const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock; const stockQuantity = selected @@ -45,6 +50,16 @@ export default function ProductBuyPanel({ } function handleAddToCart() { + if (!selected || !inStock) return; + addItem({ + id: selected.id, + name: title, + slug: productHref(product), + price: finalPrice ?? 0, + originalPrice: oldPrice ?? undefined, + image, + quantity: 1, + }); setAdded(true); setTimeout(() => setAdded(false), 1800); } diff --git a/src/components/icons.tsx b/src/components/icons.tsx index c4a0f42..081319a 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -191,6 +191,22 @@ export function PlusIcon({ className }: IconProps) { ); } +export function MinusIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function TrashIcon({ className }: IconProps) { + return ( + + + + ); +} + export function CommentIcon({ className }: IconProps) { return ( diff --git a/src/components/ui/ProductCard.tsx b/src/components/ui/ProductCard.tsx index 9bd3ae4..84d9f94 100644 --- a/src/components/ui/ProductCard.tsx +++ b/src/components/ui/ProductCard.tsx @@ -2,9 +2,12 @@ import { useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import type { ApiProduct, ProductDiscount } from "@/lib/types"; import { formatToman } from "@/lib/format"; import { productHref } from "@/lib/slug"; +import { getStoreItemByProduct } from "@/lib/api"; +import { useCart } from "../CartProvider"; import { CheckIcon, PlusIcon } from "../icons"; export default function ProductCard({ @@ -15,17 +18,53 @@ export default function ProductCard({ discount?: ProductDiscount; }) { const [added, setAdded] = useState(false); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const { addItem } = useCart(); const title = product.nameFa || product.title; const image = product.thumbnailUrl || product.image || product.thumbnail || "/images/products/product-1.svg"; const store = product.store; const price = discount ? discount.price : store?.minPrice; - function handleQuickAdd(e: React.MouseEvent) { + async function handleQuickAdd(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); - setAdded(true); - setTimeout(() => setAdded(false), 1500); + if (loading || added) return; + setLoading(true); + try { + const { storeItem } = await getStoreItemByProduct(product.id); + const variants = storeItem?.variants ?? []; + const inStock = variants.filter((v) => v.stockQuantity == null || v.stockQuantity > 0); + + if (inStock.length === 0) return; + + // More than one purchasable variant (size/color) — the buyer must + // choose, so send them to the product page instead of guessing. + if (inStock.length > 1) { + router.push(productHref(product)); + return; + } + + const variant = inStock[0]; + const finalPrice = variant.discountedPrice ?? variant.price ?? 0; + const hasRealDiscount = + variant.discountedPrice != null && variant.price != null && variant.discountedPrice < variant.price; + + addItem({ + id: variant.id, + name: title, + slug: productHref(product), + price: finalPrice, + originalPrice: hasRealDiscount ? variant.price! : undefined, + image, + quantity: 1, + }); + setAdded(true); + setTimeout(() => setAdded(false), 1500); + } finally { + setLoading(false); + } } return ( @@ -76,8 +115,9 @@ export default function ProductCard({