Add guest mini-cart with header icon, popup, and customer dashboard handoff

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 <noreply@anthropic.com>
This commit is contained in:
amirhosein.ashourloo
2026-09-11 08:50:38 +03:30
co-authored by Claude Sonnet 5
parent d7b490e442
commit 12d39bc020
10 changed files with 400 additions and 21 deletions
+4 -3
View File
@@ -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] : []),
+93
View File
@@ -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<Listener>();
// 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 };
}
+3 -11
View File
@@ -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
</div>
<div className="flex items-center gap-2 md:gap-3 ms-auto md:ms-0">
<a
href="#"
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
aria-label="سبد خرید"
>
<BagIcon className="size-5" />
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
0
</span>
</a>
<MiniCart />
<a
href={CUSTOMER_PORTAL_URL}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCart } from "./CartProvider";
import { getContinueUrl } from "@/lib/cart";
import { formatToman } from "@/lib/format";
import { BagIcon, CloseIcon, MinusIcon, PlusIcon, TrashIcon } from "./icons";
export default function MiniCart() {
const { items, count, total, updateQuantity, removeItem } = useCart();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(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 (
<div className="relative" ref={rootRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="relative flex items-center justify-center size-10 md:size-11 rounded-lg border border-line text-ink hover:border-brand hover:text-brand transition-colors"
aria-label="سبد خرید"
>
<BagIcon className="size-5" />
{count > 0 && (
<span className="absolute -top-1.5 -end-1.5 flex items-center justify-center size-4.5 rounded-full bg-gold text-[10px] font-bold text-ink">
{count > 99 ? "99+" : count}
</span>
)}
</button>
{open && (
<div className="absolute end-0 top-full mt-2 w-80 sm:w-96 bg-white border border-line rounded-xl shadow-xl z-50 flex flex-col max-h-[70vh]">
<div className="flex items-center justify-between px-4 h-12 border-b border-line shrink-0">
<h3 className="text-sm font-bold text-ink">سبد خرید</h3>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="p-1 text-body hover:text-ink"
>
<CloseIcon className="size-4" />
</button>
</div>
{items.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-body">سبد خرید شما خالی است</div>
) : (
<div className="flex-1 overflow-y-auto divide-y divide-line">
{items.map((line) => (
<div key={line.id} className="flex gap-3 px-4 py-3">
<img
src={line.image}
alt={line.name}
className="size-16 shrink-0 rounded-lg object-cover border border-line"
/>
<div className="flex-1 min-w-0 flex flex-col">
<p className="text-[13px] text-ink text-right line-clamp-2">{line.name}</p>
<div className="mt-auto flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 rounded-full border border-line px-1 h-7">
<button
type="button"
onClick={() => updateQuantity(line.id, line.quantity + 1)}
aria-label="افزایش تعداد"
className="flex items-center justify-center size-5 text-body hover:text-brand"
>
<PlusIcon className="size-3.5" />
</button>
<span className="w-5 text-center text-xs font-bold text-ink">
{line.quantity}
</span>
<button
type="button"
onClick={() => updateQuantity(line.id, line.quantity - 1)}
aria-label="کاهش تعداد"
className="flex items-center justify-center size-5 text-body hover:text-brand"
>
<MinusIcon className="size-3.5" />
</button>
</div>
<span className="text-xs font-bold text-brand whitespace-nowrap">
{formatToman(line.price * line.quantity)}
</span>
</div>
</div>
<button
type="button"
onClick={() => removeItem(line.id)}
aria-label="حذف از سبد خرید"
className="self-start p-1 text-body hover:text-red-600"
>
<TrashIcon className="size-4" />
</button>
</div>
))}
</div>
)}
{items.length > 0 && (
<div className="px-4 py-3 border-t border-line shrink-0">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-body">جمع کل</span>
<span className="text-sm font-bold text-ink">{formatToman(total)}</span>
</div>
<a
href={getContinueUrl(items)}
className="flex items-center justify-center w-full rounded-full bg-brand text-white text-sm font-bold h-11 hover:bg-brand-dark transition-colors"
>
ادامه فرآیند خرید
</a>
</div>
)}
</div>
)}
</div>
);
}
+15
View File
@@ -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);
}
+16
View File
@@ -191,6 +191,22 @@ export function PlusIcon({ className }: IconProps) {
);
}
export function MinusIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M5 12h14" />
</svg>
);
}
export function TrashIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m-9 0 1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
);
}
export function CommentIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} {...base}>
+44 -4
View File
@@ -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({
<button
type="button"
onClick={handleQuickAdd}
disabled={loading}
aria-label="افزودن سریع به سبد خرید"
className={`shrink-0 flex items-center justify-center size-8 rounded-full transition-colors ${
className={`shrink-0 flex items-center justify-center size-8 rounded-full transition-colors disabled:opacity-60 ${
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
}`}
>
+7 -3
View File
@@ -9,7 +9,7 @@ import type {
PagedResult,
ProductVariation,
StaticImageSlot,
StoreItem,
StoreItemGroup,
StoreSpecial,
TechnicalInfoValue,
} from "./types";
@@ -91,8 +91,12 @@ export function getProductVariations(
return apiGet(`/tenants/${SITE_DOMAIN}/products/by-id/${productId}/variations`);
}
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
return apiGet(`/tenants/${SITE_DOMAIN}/store-items`, { productId, pageSize: 100 });
// Dedicated add-to-cart lookup: variants grouped under the product's store
// item, per the guest-cart contract in AI_PROMPT.md.
export function getStoreItemByProduct(
productId: string
): Promise<{ storeItem: StoreItemGroup | null }> {
return apiGet(`/tenants/${SITE_DOMAIN}/store-items/by-product/${productId}`);
}
export function getProductTechnicalInfo(
+67
View File
@@ -0,0 +1,67 @@
import { CUSTOMER_PORTAL_URL, SITE_DOMAIN } from "./config";
import type { CartLine } from "./types";
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
const GUEST_CART_COOKIE_NAME = "meshkee-guest-cart";
const ACCESS_TOKEN_COOKIE_NAME = "meshkee_customer_access_token";
// Cookies cap out around 4KB per-cookie; stay well under that so the
// Set-Cookie header itself (name + attributes) never gets truncated.
const MAX_COOKIE_VALUE_LENGTH = 3500;
// Exact encoding mandated by the customer dashboard's guest-cart contract
// (AI_PROMPT.md → Shopping cart) — base64url, no padding.
export function encodeGuestCart(items: CartLine[]): string {
const json = JSON.stringify(items);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function readCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
// Storefront only ever reads this cookie to detect a logged-in customer
// session (set by the customer dashboard) — never writes or clears it.
export function hasAccessTokenCookie(): boolean {
return !!readCookie(ACCESS_TOKEN_COOKIE_NAME);
}
// Shared parent-domain cookie so the customer dashboard can pick the guest
// cart up without a URL param, when it fits comfortably under browsers'
// per-cookie size limit. Silently skipped when the cart is too large — the
// URL-param handoff on Continue is the guaranteed path either way.
export function writeGuestCartCookie(items: CartLine[]) {
if (typeof document === "undefined") return;
const value = encodeGuestCart(items);
const domainAttr = SITE_DOMAIN.includes("localhost") ? "" : `; Domain=.${SITE_DOMAIN}`;
if (value.length > MAX_COOKIE_VALUE_LENGTH) {
document.cookie = `${GUEST_CART_COOKIE_NAME}=; Max-Age=0; Path=/${domainAttr}`;
return;
}
const maxAge = 60 * 60 * 24 * 30;
document.cookie = `${GUEST_CART_COOKIE_NAME}=${encodeURIComponent(
value
)}; Max-Age=${maxAge}; Path=/${domainAttr}; SameSite=Lax`;
}
export function clearGuestCartCookie() {
if (typeof document === "undefined") return;
const domainAttr = SITE_DOMAIN.includes("localhost") ? "" : `; Domain=.${SITE_DOMAIN}`;
document.cookie = `${GUEST_CART_COOKIE_NAME}=; Max-Age=0; Path=/${domainAttr}`;
}
// "Continue" always leaves this site for the customer dashboard, which owns
// cart/checkout/auth. Logged-in customers go straight to checkout; guests go
// through login first and are bounced back with the cart preserved.
export function getContinueUrl(items: CartLine[]): string {
const encoded = encodeGuestCart(items);
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
if (hasAccessTokenCookie()) {
return `${CUSTOMER_PORTAL_URL}${checkoutPath}`;
}
return `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(checkoutPath)}`;
}
+25
View File
@@ -195,6 +195,31 @@ export interface StoreItem {
updatedAt: string;
}
// Wrapper returned by the dedicated by-product store-items endpoint, used
// specifically for the add-to-cart flow (variants grouped under one item).
export interface StoreItemGroup {
id: string;
productId: string;
isActive: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
variants: StoreItem[];
}
// Guest mini-cart line — shape mandated by the Meshkee customer dashboard
// contract (see AI_PROMPT.md → Shopping cart). `id` is always the
// store-item *variant* id, never a product id.
export interface CartLine {
id: string;
name: string;
slug: string;
price: number;
originalPrice?: number;
image: string;
quantity: number;
}
export interface StoreSpecialVariant {
id: string;
label: string;