Rewrite cart as client-only guest cart per AI_PROMPT.md spec
Replaces the previous server-cart implementation (which wrongly called
the customer-dashboard-only /businesses/{id}/cart* API) with the
documented guest-cart architecture:
- src/lib/guestCart.ts: storage key/cookie name, cookie attributes,
line shape, and encodeGuestCart() copied verbatim from AI_PROMPT.md
- CartProvider/CartButton: pure client state (localStorage + mirrored
cookie), no bearer auth, no businessId
- CartButton's "ادامه" builds the exact documented Continue URL,
branching on hasCustomerSessionCookie() to either
customer.<domain>/checkout/cart?guestCart=<encoded> or
.../login?redirect=<encoded checkout path>
- Auth cookie corrected to the real documented name
(meshkee_customer_access_token, possibly chunked) via a shared
reassembleChunkedCookie() helper used by both auth.ts (server) and
authUser.ts (client)
- ProductBuyPanel/product pages switched to the documented
GET .../store-items/by-product/{id} endpoint (getStoreItemByProduct)
- isInStock() treats stockQuantity === null as unlimited stock, not
out-of-stock, per spec
- AGENTS.md updated to describe this architecture and remove the
incorrect server-cart documentation
Manually verified in-browser: badge count, popup lines/qty/remove/
total all update correctly against injected guest-cart data; tsc,
eslint, and next build all pass clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d624ad8a32
commit
4ffdf03727
@@ -1,27 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CartIcon as CartGlyph } from "./icons";
|
||||
import { useCart } from "./providers/CartProvider";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { encodeGuestCart } from "@/lib/guestCart";
|
||||
import { hasCustomerSessionCookie } from "@/lib/authUser";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import type { AuthUser } from "@/lib/types";
|
||||
|
||||
// Header/bottom-nav cart control: badge with the live item count, a popup
|
||||
// previewing the cart, and a "ادامه" button that hands off to the customer
|
||||
// portal to actually complete checkout (address + payment aren't built on
|
||||
// this storefront — see AGENTS.md "Shopping cart").
|
||||
// Header/bottom-nav mini-cart per AI_PROMPT.md "Shopping cart": badge with
|
||||
// the live item count, a popup previewing the (local-only) cart, and a
|
||||
// "ادامه" button that hands the guest cart off to the customer dashboard —
|
||||
// this storefront has no login/checkout/address/payment pages of its own,
|
||||
// by design (see AGENTS.md).
|
||||
export default function CartButton({
|
||||
user,
|
||||
className = "",
|
||||
labelClassName = "",
|
||||
showLabel = false,
|
||||
placement = "down",
|
||||
wrapperClassName = "relative",
|
||||
}: {
|
||||
user: AuthUser | null;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
showLabel?: boolean;
|
||||
@@ -31,7 +30,7 @@ export default function CartButton({
|
||||
* match its three sibling nav items' width — the header doesn't. */
|
||||
wrapperClassName?: string;
|
||||
}) {
|
||||
const { cart, count, loading } = useCart();
|
||||
const { items, count, total, updateItem, removeItem } = useCart();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -43,7 +42,14 @@ export default function CartButton({
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, []);
|
||||
|
||||
const items = cart?.items ?? [];
|
||||
function handleContinue() {
|
||||
const encoded = encodeGuestCart(items);
|
||||
const checkoutPath = `/checkout/cart?guestCart=${encoded}`;
|
||||
const url = hasCustomerSessionCookie()
|
||||
? `${CUSTOMER_PORTAL_URL}${checkoutPath}`
|
||||
: `${CUSTOMER_PORTAL_URL}/login?redirect=${encodeURIComponent(checkoutPath)}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={wrapperClassName}>
|
||||
@@ -72,74 +78,73 @@ export default function CartButton({
|
||||
// squeeze the whole cart preview into it instead of spanning
|
||||
// near-full width above the bar.
|
||||
className={`z-50 overflow-hidden rounded-xl border border-line bg-white shadow-xl ${
|
||||
placement === "up"
|
||||
? "fixed inset-x-4 bottom-[76px]"
|
||||
: "absolute left-0 top-full mt-2 w-80"
|
||||
placement === "up" ? "fixed inset-x-4 bottom-[76px]" : "absolute left-0 top-full mt-2 w-80"
|
||||
}`}
|
||||
>
|
||||
<div className="border-b border-line px-4 py-3">
|
||||
<h3 className="text-[14px] font-bold text-ink">سبد خرید</h3>
|
||||
</div>
|
||||
|
||||
{!user ? (
|
||||
<div className="flex flex-col items-center gap-3 p-6 text-center">
|
||||
<p className="text-[13px] text-body">برای مشاهده سبد خرید ابتدا وارد شوید.</p>
|
||||
<Link
|
||||
href={CUSTOMER_PORTAL_URL}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg bg-brand px-5 py-2 text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
ورود | ثبتنام
|
||||
</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<p className="p-6 text-center text-[13px] text-body">در حال بارگذاری…</p>
|
||||
) : items.length === 0 ? (
|
||||
{items.length === 0 ? (
|
||||
<p className="p-6 text-center text-[13px] text-body">سبد خرید شما خالی است.</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="max-h-80 divide-y divide-line overflow-auto">
|
||||
{items.map((item) => {
|
||||
const title = item.productNameFa || item.productTitle || "کالا";
|
||||
const price = item.discountedPrice ?? item.price ?? null;
|
||||
return (
|
||||
<li key={item.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={title} fill sizes="48px" className="object-contain" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{title}</span>
|
||||
<span className="mt-0.5 block text-[11px] text-body">
|
||||
{toFaDigits(item.quantity)} عدد
|
||||
</span>
|
||||
</span>
|
||||
{price !== null && (
|
||||
<span className="shrink-0 text-[12px] font-bold text-ink">{formatPrice(price)}</span>
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="relative size-12 shrink-0 overflow-hidden rounded-lg bg-surface">
|
||||
{item.image && (
|
||||
<Image src={item.image} alt={item.name} fill sizes="48px" className="object-contain" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="clamp-2 block text-[12.5px] leading-5 text-ink-soft">{item.name}</span>
|
||||
<span className="mt-1 flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => updateItem(item.id, item.quantity - 1)}
|
||||
aria-label="کاهش تعداد"
|
||||
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-5 text-center text-[11px] text-body">{toFaDigits(item.quantity)}</span>
|
||||
<button
|
||||
onClick={() => updateItem(item.id, item.quantity + 1)}
|
||||
aria-label="افزایش تعداد"
|
||||
className="flex size-5 items-center justify-center rounded border border-line text-[12px] text-ink-soft transition hover:border-brand hover:text-brand"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-2">
|
||||
<span className="text-[12px] font-bold text-ink">{formatPrice(item.price)}</span>
|
||||
<button
|
||||
onClick={() => removeItem(item.id)}
|
||||
aria-label="حذف از سبد خرید"
|
||||
className="text-[11px] text-body underline-offset-2 transition hover:text-brand hover:underline"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="border-t border-line p-4">
|
||||
{cart?.total != null && (
|
||||
<div className="mb-3 flex items-center justify-between text-[13px]">
|
||||
<span className="text-body">جمع کل</span>
|
||||
<span className="font-bold text-ink">
|
||||
{formatPrice(cart.total)}
|
||||
<span className="mr-1 font-normal text-body">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
href={`${CUSTOMER_PORTAL_URL}/cart`}
|
||||
onClick={() => setOpen(false)}
|
||||
<div className="mb-3 flex items-center justify-between text-[13px]">
|
||||
<span className="text-body">جمع کل</span>
|
||||
<span className="font-bold text-ink">
|
||||
{formatPrice(total)}
|
||||
<span className="mr-1 font-normal text-body">تومان</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="flex h-11 w-full items-center justify-center rounded-lg bg-brand text-[13px] font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
ادامه
|
||||
</Link>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -51,10 +51,7 @@ export default function Header({
|
||||
|
||||
<div className="mr-auto hidden items-center gap-2 lg:flex">
|
||||
<AccountMenu user={user} />
|
||||
<CartButton
|
||||
user={user}
|
||||
className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark"
|
||||
/>
|
||||
<CartButton className="flex h-11 items-center gap-2 rounded-xl bg-brand px-3 text-[13px] text-white transition hover:bg-brand-dark" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ export default function MobileBottomNav({
|
||||
</Link>
|
||||
|
||||
<CartButton
|
||||
user={user}
|
||||
placement="up"
|
||||
wrapperClassName="relative flex-1"
|
||||
className={`${item} w-full text-ink`}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { CartIcon, CheckIcon, RotateIcon, ShieldIcon, TruckIcon } from "./icons";
|
||||
import { useCart } from "./providers/CartProvider";
|
||||
import { useToast } from "./providers/ToastProvider";
|
||||
import { formatPrice, toFaDigits } from "@/lib/format";
|
||||
import { CUSTOMER_PORTAL_URL } from "@/lib/config";
|
||||
import { formatPrice, isInStock, toFaDigits } from "@/lib/format";
|
||||
import { DEMO_PRICING, demoPrice } from "@/lib/demoPricing";
|
||||
import type { ApiProduct, AuthUser, StoreItem } from "@/lib/types";
|
||||
import type { ApiProduct, StoreSpecialVariant } from "@/lib/types";
|
||||
|
||||
const trustPoints = [
|
||||
{ icon: ShieldIcon, title: "ضمانت اصالت کالا" },
|
||||
@@ -16,29 +14,34 @@ const trustPoints = [
|
||||
{ icon: RotateIcon, title: "تا ۷ روز امکان مرجوعی" },
|
||||
];
|
||||
|
||||
// Add-to-cart follows AI_PROMPT.md "Shopping cart" exactly: variants come
|
||||
// from GET .../store-items/by-product/{id}, "in stock" means
|
||||
// stockQuantity === null (unlimited) or > 0, a shopper with more than one
|
||||
// in-stock variant must pick one before adding, and the cart line's `id` is
|
||||
// the variant id (storeItemVariantId) — never the product id. No login is
|
||||
// required to add; the mini-cart is a local guest cart (see
|
||||
// CartProvider/guestCart.ts) — only "ادامه" in CartButton cares about auth.
|
||||
export default function ProductBuyPanel({
|
||||
product,
|
||||
items = [],
|
||||
user,
|
||||
variants = [],
|
||||
initialVariantId,
|
||||
}: {
|
||||
product: ApiProduct;
|
||||
items?: StoreItem[];
|
||||
user: AuthUser | null;
|
||||
variants?: StoreSpecialVariant[];
|
||||
/** Preselect a specific variant — used when entering via its own
|
||||
* /store-items/{id} URL rather than the product page. */
|
||||
initialVariantId?: string;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? items[0]?.id);
|
||||
const inStockVariants = variants.filter((v) => isInStock(v.stockQuantity));
|
||||
const [selectedId, setSelectedId] = useState(initialVariantId ?? inStockVariants[0]?.id);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [added, setAdded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { addItem } = useCart();
|
||||
const { showToast } = useToast();
|
||||
const selected = items.find((i) => i.id === selectedId) ?? items[0];
|
||||
const selected = inStockVariants.find((v) => v.id === selectedId) ?? inStockVariants[0];
|
||||
|
||||
const title = product.nameFa || product.title;
|
||||
const image = product.thumbnailUrl || product.image || product.thumbnail;
|
||||
const variationName = selected?.selections[0]?.variationName;
|
||||
|
||||
let price: number | null = null;
|
||||
@@ -55,31 +58,31 @@ export default function ProductBuyPanel({
|
||||
oldPrice = demo.oldPrice;
|
||||
}
|
||||
|
||||
const percent = price !== null && oldPrice !== null && oldPrice > price
|
||||
? Math.round(((oldPrice - price) / oldPrice) * 100)
|
||||
: 0;
|
||||
const percent =
|
||||
price !== null && oldPrice !== null && oldPrice > price
|
||||
? Math.round(((oldPrice - price) / oldPrice) * 100)
|
||||
: 0;
|
||||
|
||||
// Purchasing needs a real store-item variant (cart items are added by
|
||||
// storeItemVariantId, not productId) — until this product has one, there
|
||||
// is nothing a bearer token could add to a cart, logged in or not.
|
||||
const purchasable = items.length > 0 && Boolean(selected);
|
||||
const inStock = selected ? selected.stockQuantity > 0 : product.store.inStock;
|
||||
const maxQuantity = selected ? Math.max(1, selected.stockQuantity) : 99;
|
||||
// A shopper with more than one in-stock variant must choose before adding
|
||||
// — the variant picker below doubles as that requirement, so "ready to
|
||||
// add" just means a selection exists.
|
||||
const purchasable = inStockVariants.length > 0 && Boolean(selected);
|
||||
const maxQuantity = selected?.stockQuantity == null ? 99 : Math.max(1, selected.stockQuantity);
|
||||
|
||||
async function handleAddToCart() {
|
||||
if (!selected || !user) return;
|
||||
setAdding(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addItem(selected.storeItemId, quantity);
|
||||
setAdded(true);
|
||||
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
||||
setTimeout(() => setAdded(false), 1800);
|
||||
} catch {
|
||||
setError("افزودن به سبد خرید ناموفق بود. لطفاً دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
function handleAddToCart() {
|
||||
if (!selected) return;
|
||||
addItem({
|
||||
id: selected.id,
|
||||
name: title,
|
||||
slug: product.slug,
|
||||
price: selected.discountedPrice ?? selected.price ?? 0,
|
||||
originalPrice: selected.discountedPrice != null ? (selected.price ?? undefined) : undefined,
|
||||
image,
|
||||
quantity,
|
||||
});
|
||||
setAdded(true);
|
||||
showToast(`${quantity > 1 ? `${toFaDigits(quantity)} عدد ` : ""}به سبد خرید اضافه شد`);
|
||||
setTimeout(() => setAdded(false), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -95,28 +98,27 @@ export default function ProductBuyPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
{inStockVariants.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2.5 text-sm font-bold text-ink">{variationName || "انتخاب گزینه"}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => {
|
||||
const rawValue = item.selections[0]?.value;
|
||||
{inStockVariants.map((variant) => {
|
||||
const rawValue = variant.selections[0]?.value;
|
||||
const colorHex = rawValue?.startsWith("#") ? rawValue : null;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
key={variant.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedId(item.id);
|
||||
setSelectedId(variant.id);
|
||||
setQuantity(1);
|
||||
}}
|
||||
disabled={item.stockQuantity <= 0}
|
||||
title={item.label}
|
||||
className={`flex items-center justify-center transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
title={variant.label}
|
||||
className={`flex items-center justify-center transition ${
|
||||
colorHex
|
||||
? `size-9 rounded-full border-2 ${item.id === selectedId ? "border-brand" : "border-line"}`
|
||||
? `size-9 rounded-full border-2 ${variant.id === selectedId ? "border-brand" : "border-line"}`
|
||||
: `h-10 rounded-lg border px-4 text-sm font-bold ${
|
||||
item.id === selectedId
|
||||
variant.id === selectedId
|
||||
? "border-brand bg-brand text-white"
|
||||
: "border-line text-ink hover:border-brand hover:text-brand"
|
||||
}`
|
||||
@@ -125,7 +127,7 @@ export default function ProductBuyPanel({
|
||||
{colorHex ? (
|
||||
<span className="block size-6 rounded-full" style={{ background: colorHex }} />
|
||||
) : (
|
||||
item.label
|
||||
variant.label
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -149,10 +151,10 @@ export default function ProductBuyPanel({
|
||||
<div className="mb-4 flex items-center justify-between border-b border-line pb-4">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-bold ${
|
||||
inStock ? "bg-brand-soft text-brand" : "bg-surface text-body"
|
||||
purchasable ? "bg-brand-soft text-brand" : "bg-surface text-body"
|
||||
}`}
|
||||
>
|
||||
{inStock ? "آماده ارسال" : "ناموجود"}
|
||||
{purchasable ? "آماده ارسال" : "ناموجود"}
|
||||
</span>
|
||||
<TruckIcon className="size-5 text-body" />
|
||||
</div>
|
||||
@@ -186,13 +188,6 @@ export default function ProductBuyPanel({
|
||||
<p className="rounded-lg bg-surface px-3 py-2.5 text-center text-[12.5px] text-body">
|
||||
این محصول هنوز برای خرید آنلاین فعال نشده است.
|
||||
</p>
|
||||
) : !user ? (
|
||||
<Link
|
||||
href={CUSTOMER_PORTAL_URL}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-lg bg-brand text-sm font-bold text-white transition hover:bg-brand-dark"
|
||||
>
|
||||
برای خرید وارد شوید
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="flex h-12 items-center rounded-lg border border-line">
|
||||
@@ -220,8 +215,7 @@ export default function ProductBuyPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!inStock || adding}
|
||||
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
className={`flex h-12 flex-1 items-center justify-center gap-2 rounded-lg text-sm font-bold transition ${
|
||||
added ? "bg-brand-dark text-white" : "bg-brand text-white hover:bg-brand-dark"
|
||||
}`}
|
||||
>
|
||||
@@ -233,13 +227,12 @@ export default function ProductBuyPanel({
|
||||
) : (
|
||||
<>
|
||||
<CartIcon className="size-4.5" />
|
||||
{adding ? "در حال افزودن…" : "افزودن به سبد خرید"}
|
||||
افزودن به سبد خرید
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-center text-[12px] text-brand">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 rounded-2xl border border-dashed border-line p-5">
|
||||
|
||||
@@ -1,112 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { addCartItem, getCart, removeCartItem, updateCartItem } from "@/lib/api";
|
||||
import { AUTH_TOKEN_COOKIE } from "@/lib/config";
|
||||
import type { AuthUser, Cart } from "@/lib/types";
|
||||
|
||||
function readToken(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
return (
|
||||
document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith(`${AUTH_TOKEN_COOKIE}=`))
|
||||
?.split("=")[1] ?? null
|
||||
);
|
||||
}
|
||||
addGuestCartItem,
|
||||
readGuestCart,
|
||||
removeGuestCartItem,
|
||||
updateGuestCartItem,
|
||||
type GuestCartItem,
|
||||
} from "@/lib/guestCart";
|
||||
|
||||
interface CartContextValue {
|
||||
cart: Cart | null;
|
||||
items: GuestCartItem[];
|
||||
count: number;
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
addItem: (storeItemVariantId: string, quantity?: number) => Promise<void>;
|
||||
updateItem: (itemId: string, quantity: number) => Promise<void>;
|
||||
removeItem: (itemId: string) => Promise<void>;
|
||||
total: number;
|
||||
addItem: (item: GuestCartItem) => void;
|
||||
updateItem: (id: string, quantity: number) => void;
|
||||
removeItem: (id: string) => void;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartContextValue | null>(null);
|
||||
|
||||
// Cart is not guest-accessible at the API level (every verb needs a bearer
|
||||
// token — see api.ts), so this only ever fetches once a session exists.
|
||||
// Anonymous visitors always see an empty cart, which is correct, not a bug.
|
||||
export function CartProvider({
|
||||
businessId,
|
||||
user,
|
||||
children,
|
||||
}: {
|
||||
businessId: string;
|
||||
user: AuthUser | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const token = readToken();
|
||||
if (!token) {
|
||||
setCart(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { cart } = await getCart(businessId, token);
|
||||
setCart(cart);
|
||||
} catch {
|
||||
setCart(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [businessId]);
|
||||
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — no server
|
||||
// call, no auth required. State lives in localStorage (mirrored to a cookie
|
||||
// for the customer dashboard to read); this provider is just a thin React
|
||||
// wrapper around src/lib/guestCart.ts so every consumer re-renders on change.
|
||||
export function CartProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<GuestCartItem[]>([]);
|
||||
|
||||
// Reading localStorage during the initial render would mismatch the
|
||||
// server-rendered (empty) markup, so the real cart loads post-mount —
|
||||
// deferred to a microtask so the effect body itself never calls setState
|
||||
// synchronously.
|
||||
useEffect(() => {
|
||||
// refresh() calls setLoading synchronously as its first statement —
|
||||
// deferred to a microtask so the effect body itself never calls
|
||||
// setState synchronously.
|
||||
if (user) queueMicrotask(() => void refresh());
|
||||
}, [user, refresh]);
|
||||
queueMicrotask(() => setItems(readGuestCart()));
|
||||
}, []);
|
||||
|
||||
const addItem = useCallback(
|
||||
async (storeItemVariantId: string, quantity = 1) => {
|
||||
const token = readToken();
|
||||
if (!token) throw new Error("not-authenticated");
|
||||
const { cart } = await addCartItem(businessId, token, storeItemVariantId, quantity);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
const addItem = useCallback((item: GuestCartItem) => {
|
||||
setItems(addGuestCartItem(item));
|
||||
}, []);
|
||||
|
||||
const updateItem = useCallback(
|
||||
async (itemId: string, quantity: number) => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
const { cart } = await updateCartItem(businessId, token, itemId, quantity);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
const updateItem = useCallback((id: string, quantity: number) => {
|
||||
setItems(updateGuestCartItem(id, quantity));
|
||||
}, []);
|
||||
|
||||
const removeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
const token = readToken();
|
||||
if (!token) return;
|
||||
const { cart } = await removeCartItem(businessId, token, itemId);
|
||||
setCart(cart);
|
||||
},
|
||||
[businessId]
|
||||
);
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems(removeGuestCartItem(id));
|
||||
}, []);
|
||||
|
||||
const count = cart?.itemCount ?? cart?.items?.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
|
||||
const count = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
|
||||
return (
|
||||
<CartContext.Provider value={{ cart, count, loading, refresh, addItem, updateItem, removeItem }}>
|
||||
<CartContext.Provider value={{ items, count, total, addItem, updateItem, removeItem }}>
|
||||
{children}
|
||||
</CartContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user