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:
amirhosein.ashourloo
2026-09-06 13:29:30 +03:30
co-authored by Claude Sonnet 5
parent d624ad8a32
commit 4ffdf03727
17 changed files with 434 additions and 381 deletions
+69 -38
View File
@@ -26,8 +26,8 @@ fallbacks should update it.
| `enabledModules` (`GET /tenants/moalem.shop`) | `["products", "blog", "finance"]` — **neither `workshops` nor `portfolio` is enabled.** Do not build a page/nav entry for either until it is; `/portfolios` and `/workshops` both return empty lists today regardless, but the real signal is `enabledModules`, not an empty list (an enabled-but-unfilled module still gets its page, per "Fallbacks" below). |
| `/products` | **479 items**, real titles, images, categories |
| `/categories?entityType=product` | **48 items**, 3 levels under «کالای دیجیتال». Products are filed on **leaf** categories only — a parent category page/carousel must query its whole subtree (`subtreeIds`/`subtreeIdsAll` in `src/lib/categoryTree.ts`). |
| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`, and `/store-items` (the `getStoreItems()` list used by `/store-items`) has nothing to page through either. `/store-items/by-product/{id}` returns a single `{ storeItem }` (or null) — for a product's full variant list use `/store-items?productId=&pageSize=100` (`{ items }`) instead, see `getStoreItemsByProduct` in `src/lib/api.ts`. |
| Cart (`/businesses/{id}/cart*`) | Never exercised — nothing has ever been added, since there are no real store-items to add. `Cart`/`CartItem` in `src/lib/types.ts` are a **best-effort guess** at the response shape (the OpenAPI doc gives no field-level schema, only "{ cart }" / "{ message, cart }") — read every field defensively, and treat the shape as unverified until real cart data is seen. |
| `/store-items`, `/store-specials` | **empty** → no prices, no discounts, everything `inStock:false`, and `/store-items` (the `getStoreItems()` list used by `/store-items`) has nothing to page through either. `/store-items/by-product/{id}` → `{ storeItem: { variants: [...] } \| null }` is the documented source for a product's full variant list — `getStoreItemByProduct()` in `src/lib/api.ts`. |
| Cart (`/businesses/{id}/cart*`) | **Off-limits from this storefront by design**, not a data gap — see "Shopping cart" below. Never call it. |
| `/website/sliders`, `/website/category-groups` | **empty** |
| `/website/static-images` | **filled** — `slider` (2), `categories` (8 of 15), `one/two/three-banner-bg` all have real images. Verified live and rendering correctly; if a future check finds otherwise, suspect a stale build/deploy before suspecting the wiring. |
| `/blogs` | **empty** (module enabled) |
@@ -103,52 +103,83 @@ Login itself happens off-site, on the customer portal (`customer.{domain}`) —
this storefront never renders a login form. For the header/bottom-nav account
menu to show "نام عزیز" instead of "ورود | ثبت‌نام", the customer portal needs
to set a **non-HttpOnly cookie on the shared parent domain** (`.{domain}`)
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, currently `"meshkee_token"`)
holding the bearer access token. Until that exists, `getSession()` in
`src/lib/auth.ts` always resolves to `null` and the anonymous view is all
anyone will see — this is expected, not a bug, and nothing else breaks.
named `AUTH_TOKEN_COOKIE` (`src/lib/config.ts`, `"meshkee_customer_access_token"`
— the documented name from AI_PROMPT.md's "Shared login" contract, not a
guess) holding the bearer access token, URL-encoded and possibly **chunked**
into `${name}_0`, `${name}_1`, … with `${name}_n` holding the chunk count
when the JWT is too long for one cookie. Until the dashboard sets it,
`getSession()` in `src/lib/auth.ts` always resolves to `null` and the
anonymous view is all anyone will see — this is expected, not a bug, and
nothing else breaks.
- `reassembleChunkedCookie()` in `src/lib/authUser.ts` — pure, takes a
`(name) => value` reader so both server and client call the same
reassembly logic: tries the bare cookie first, then falls back to the
`_n` + `_0..n-1` chunks.
- `src/lib/auth.ts` — **server-only** (`next/headers`): `getSession()` reads
the cookie and calls `GET /auth/me`.
- `src/lib/authUser.ts` — pure helpers (`displayName`, `isAdmin`), safe to
import from client components. **Never import `auth.ts` from a client
component** — it pulls in `next/headers` and breaks the build; that's the
entire reason this file is split out.
the (possibly chunked) cookie via `reassembleChunkedCookie()`, URL-decodes
it, and calls `GET /auth/me`.
- `src/lib/authUser.ts` — pure helpers safe to import from client components:
`displayName`, `isAdmin`, `reassembleChunkedCookie`, and
`hasCustomerSessionCookie()` (reads `document.cookie` — used by
`CartButton.tsx`'s Continue logic, see "Shopping cart"). **Never import
`auth.ts` from a client component** — it pulls in `next/headers` and
breaks the build; that's the entire reason this file is split out.
- Admin detection (`isAdmin()`) reads `user.isAdmin`/`user.role` defensively —
the OpenAPI schema doesn't document a role field on the user object, so
this is a best guess. Update it once the real shape is confirmed.
## Shopping cart
Every cart verb (`GET/POST/PATCH/DELETE /businesses/{businessId}/cart...`)
requires a bearer token — there is **no anonymous/guest cart** at the API
level. `businessId` is the tenant's numeric id (27 for moalem.shop), fetched
once via `getTenant()` in the root layout and passed down as a prop, not
hardcoded.
**Guest cart only — this storefront never calls the server cart API.**
Per AI_PROMPT.md's "Shopping cart" section, `/businesses/{id}/cart*` (and
`/cart/checkout`) are customer-dashboard-only; the site's whole cart is a
local, unauthenticated mini-cart that hands off to the dashboard on
"ادامه". Do not add a `businessId` prop, a bearer-authed cart call, or a
checkout/login/address/payment page here — that is explicitly out of scope
per the same doc.
- `src/components/providers/CartProvider.tsx` — client context: `cart`,
`count`, `addItem`/`updateItem`/`removeItem`/`refresh`. Reads the same
`AUTH_TOKEN_COOKIE` as everything else auth-related; only fetches when
`user` is non-null (an anonymous visitor always sees an empty cart — by
design, not a bug). Mounted once in `layout.tsx`, wrapping everything.
- `src/lib/guestCart.ts` — the entire contract, copied verbatim from
AI_PROMPT.md, not invented: storage key **and** cookie name
`meshkee-guest-cart` (`GUEST_CART_STORAGE_KEY` in `config.ts`), persisted
to both `localStorage` and a `Domain=.{domain}; Path=/; SameSite=Lax;
Max-Age=~30d` cookie (skipped when `encodeURIComponent(json)` exceeds
~3500 chars — the URL param carries it instead on Continue). Each line is
`{ id, name, slug, price, originalPrice?, image, quantity }` where `id`
**must** be the `storeItemVariantId` — adding an id already in the cart
increments `quantity` instead of duplicating the line. `encodeGuestCart()`
is the exact base64url-of-JSON function from the doc — must stay
byte-for-byte identical since the customer dashboard decodes it.
- `src/components/providers/CartProvider.tsx` — thin React context wrapping
`guestCart.ts` (`items`, `count`, `total`, `addItem`/`updateItem`/
`removeItem`) so every consumer re-renders on change. No `user` or
`businessId` prop — works identically for anonymous and signed-in
shoppers, since the cart never touches the server. Reads localStorage
post-mount only (via `queueMicrotask`), never during the initial render,
so SSR markup (always empty) doesn't mismatch.
- `src/components/CartButton.tsx` — the header/bottom-nav icon: live count
badge, a popup previewing cart contents. Its "ادامه" (continue) button
hands off to `${CUSTOMER_PORTAL_URL}/cart` rather than a page on this
site — **this storefront has no checkout UI** (address entry, payment
gateway selection); that whole flow lives on the customer portal per this
project's established account/order architecture. Don't build a checkout
page here without an explicit ask — it's a large, separate feature.
`placement="up"` (bottom nav) renders the popup `fixed` against the
viewport rather than `absolute` against the button's own narrow `flex-1`
column — an `absolute` inset there would squeeze the whole preview into
~1/4 of the screen width instead of spanning near it.
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" needs a real
`storeItemVariantId`, which only exists once a product has store-items —
today that's none of them, so the button always shows "این محصول هنوز برای
خرید آنلاین فعال نشده است" regardless of login state, and the quantity
stepper never renders. That's correct given the data, not a bug — this
whole path (add → badge updates → popup shows the item) is unverified
against a real cart for the same reason.
badge, a popup with per-line qty/remove/total. "ادامه" builds the Continue
URL exactly per spec: `encodeGuestCart(items)` in the `guestCart` query
param, to `${CUSTOMER_PORTAL_URL}/checkout/cart?guestCart=<encoded>` when
`hasCustomerSessionCookie()` is true, else to
`${CUSTOMER_PORTAL_URL}/login?redirect=<url-encoded checkout path>`. This
storefront has no checkout/address/payment UI of its own — that whole flow
is the customer dashboard's, by design; don't build one here without an
explicit ask. `placement="up"` (bottom nav) renders the popup `fixed`
against the viewport rather than `absolute` against the button's own
narrow `flex-1` column — an `absolute` inset there would squeeze the whole
preview into ~1/4 of the screen width instead of spanning near it.
- `ProductBuyPanel.tsx`'s "افزودن به سبد خرید" — variants come from
`getStoreItemByProduct()` (`GET .../store-items/by-product/{productId}`).
In-stock means `stockQuantity === null` (**unlimited**, not
out-of-stock — see `isInStock()` in `src/lib/format.ts`) or `> 0`; with
more than one in-stock variant the shopper must pick one before the button
enables. No login is required to add — only "ادامه" cares about auth.
Today every product has zero store-items, so the button always shows "این
محصول هنوز برای خرید آنلاین فعال نشده است" and the quantity stepper never
renders — correct given the data, not a bug. Once store-items exist, add
→ badge update → popup line is the same guest-cart path already wired and
manually verified with injected `localStorage` data.
## Static-image slots
+3 -5
View File
@@ -6,7 +6,7 @@ import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TopBar from "@/components/TopBar";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getCategories, getFavicon, getTenant, safe } from "@/lib/api";
import { getCategories, getFavicon, safe } from "@/lib/api";
import { buildCategoryTree, carouselCategories } from "@/lib/categoryTree";
import { getSession } from "@/lib/auth";
import { SITE_NAME } from "@/data/site";
@@ -60,21 +60,19 @@ export async function generateMetadata(): Promise<Metadata> {
}
export default async function RootLayout({ children }: LayoutProps<"/">) {
const [categories, user, brand, tenant] = await Promise.all([
const [categories, user, brand] = await Promise.all([
safe(getCategories().then(buildCategoryTree), []),
getSession(),
safe(getFavicon(), NO_BRAND_ASSETS),
safe(getTenant(), null),
]);
const menu = carouselCategories(categories);
const logoUrl = brand.logoUrl || "/images/logo.png";
const businessId = tenant ? String(tenant.id) : "";
return (
<html lang="fa" dir="rtl" className={`${iranYekan.variable} ${montserrat.variable} h-full antialiased`}>
<body className="flex min-h-full flex-col">
<ToastProvider>
<CartProvider businessId={businessId} user={user}>
<CartProvider>
<TopBar />
<Header menu={menu} user={user} logoUrl={logoUrl} />
<main className="flex-1 pb-6">{children}</main>
+5 -4
View File
@@ -10,7 +10,7 @@ import {
getComments,
getProductById,
getProductTechnicalInfo,
getStoreItemsByProduct,
getStoreItemByProduct,
safe,
} from "@/lib/api";
import { getSession } from "@/lib/auth";
@@ -50,12 +50,13 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }),
const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemByProduct(product.id), { storeItem: null }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const variants = storeItem?.variants ?? [];
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
@@ -78,7 +79,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
<ProductGallery images={images} title={title} />
<ProductBuyPanel product={product} items={storeItems} user={user} />
<ProductBuyPanel product={product} variants={variants} />
</div>
<ProductInfoTabs
+5 -4
View File
@@ -11,7 +11,7 @@ import {
getProductById,
getProductTechnicalInfo,
getStoreItemById,
getStoreItemsByProduct,
getStoreItemByProduct,
safe,
} from "@/lib/api";
import { getSession } from "@/lib/auth";
@@ -64,12 +64,13 @@ export default async function StoreItemDetailPage({ params }: { params: Promise<
const { product, relatedProducts } = data;
const title = product.nameFa || product.title;
const [{ items: storeItems }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemsByProduct(product.id), { items: [] }),
const [{ storeItem }, technicalInfo, { items: comments }, user] = await Promise.all([
safe(getStoreItemByProduct(product.id), { storeItem: null }),
safe(getProductTechnicalInfo(product.id), { form: null, values: [] }),
safe(getComments("product", product.id), { items: [] }),
getSession(),
]);
const variants = storeItem?.variants ?? [];
const images = [
...(product.thumbnailUrl ? [product.thumbnailUrl] : []),
@@ -92,7 +93,7 @@ export default async function StoreItemDetailPage({ params }: { params: Promise<
<div className="grid grid-cols-1 gap-8 md:gap-10 lg:grid-cols-[380px_1fr]">
<ProductGallery images={images} title={title} />
<ProductBuyPanel product={product} items={storeItems} user={user} initialVariantId={variant.id} />
<ProductBuyPanel product={product} variants={variants} initialVariantId={variant.id} />
</div>
<ProductInfoTabs
+66 -61
View File
@@ -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>
</>
)}
+1 -4
View File
@@ -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>
-1
View File
@@ -132,7 +132,6 @@ export default function MobileBottomNav({
</Link>
<CartButton
user={user}
placement="up"
wrapperClassName="relative flex-1"
className={`${item} w-full text-ink`}
+52 -59
View File
@@ -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">
+36 -91
View File
@@ -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>
);
+14 -70
View File
@@ -6,7 +6,6 @@ import type {
AuthUser,
BrandAssets,
BusinessInfo,
Cart,
ContactFormInput,
EntityComment,
PagedResult,
@@ -14,6 +13,7 @@ import type {
SliderItem,
StaticImageSlot,
StoreItem,
StoreItemVariants,
StoreSpecial,
Tenant,
TechnicalInfoForm,
@@ -68,30 +68,6 @@ async function apiGetAuthed<T>(path: string, token: string): Promise<T> {
return res.json() as Promise<T>;
}
// Cart lives under /businesses/{businessId}/..., not /tenants/{domain}/... —
// a different base path from every other function in this file — and every
// call needs the bearer token. All four verbs share this one helper.
async function apiBusiness<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
token: string,
body?: unknown
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
cache: "no-store",
});
if (!res.ok) {
throw new Error(`API request failed (${res.status}): ${path}`);
}
return res.json() as Promise<T>;
}
// Swallows errors so one empty/broken section degrades instead of taking
// the whole page down — several modules on this tenant are still unfilled.
export async function safe<T>(promise: Promise<T>, fallback: T): Promise<T> {
@@ -192,12 +168,15 @@ export function getProductTechnicalInfo(
return apiGet(t(`/products/by-id/${productId}/technical-info`));
}
// Not /store-items/by-product/{id} — that one returns a single { storeItem }
// (or null), not the full variant list a buy panel needs. The paginated
// /store-items list, filtered by productId, is the one that returns every
// purchasable variant.
export function getStoreItemsByProduct(productId: string): Promise<{ items: StoreItem[] }> {
return apiGet(t("/store-items"), { productId, pageSize: 100 });
// The documented source for a product's purchasable variants (AI_PROMPT.md
// "Shopping cart", step 1) — { storeItem: null } when the product has no
// store-item at all. storeItem.variants is what the buy panel and the
// add-to-cart flow both read; never call /store-items?productId= for this,
// that's a different, unrelated listing (see getStoreItems below).
export function getStoreItemByProduct(
productId: string
): Promise<{ storeItem: StoreItemVariants | null }> {
return apiGet(t(`/store-items/by-product/${productId}`));
}
// The full, flat catalog of purchasable variants — every card here always
@@ -253,45 +232,10 @@ export function submitContactForm(
return apiPost(t("/contact-submissions"), data);
}
// Cart — every verb requires a bearer token (see AUTH_TOKEN_COOKIE in
// config.ts) and there is no anonymous/guest cart; CartProvider only calls
// these once a session exists. `businessId` is the tenant's business id
// (27 for moalem.shop), read from getTenant()/getFavicon() at the root
// layout rather than hardcoded here.
export function getCart(businessId: string, token: string): Promise<{ cart: Cart }> {
return apiBusiness("GET", `/businesses/${businessId}/cart`, token);
}
export function addCartItem(
businessId: string,
token: string,
storeItemVariantId: string,
quantity = 1
): Promise<{ cart: Cart }> {
return apiBusiness("POST", `/businesses/${businessId}/cart/items`, token, {
storeItemVariantId,
quantity,
});
}
export function updateCartItem(
businessId: string,
token: string,
itemId: string,
quantity: number
): Promise<{ message: string; cart: Cart }> {
return apiBusiness("PATCH", `/businesses/${businessId}/cart/items/${itemId}`, token, {
quantity,
});
}
export function removeCartItem(
businessId: string,
token: string,
itemId: string
): Promise<{ message: string; cart: Cart }> {
return apiBusiness("DELETE", `/businesses/${businessId}/cart/items/${itemId}`, token);
}
// No cart functions here on purpose — AI_PROMPT.md "Hard rules" #3 and
// "Shopping cart" are explicit that `/businesses/{id}/cart*` is
// customer-dashboard-only. The mini-cart is local-only; see
// src/lib/guestCart.ts.
export function findSlot(slots: StaticImageSlot[], key: string): StaticImageSlot | undefined {
const slot = slots.find((s) => s.key === key);
+9 -6
View File
@@ -1,18 +1,21 @@
import { cookies } from "next/headers";
import { getMe, safe } from "./api";
import { AUTH_TOKEN_COOKIE } from "./config";
import { reassembleChunkedCookie } from "./authUser";
import type { AuthUser } from "./types";
export { displayName, isAdmin } from "./authUser";
// Server-side "am I logged in" check for the header/account menu. See the
// AUTH_TOKEN_COOKIE comment in config.ts for the one external dependency
// this relies on. Resolves to null whenever the cookie is absent or /auth/me
// fails — never throws, so a missing session degrades to the anonymous view.
// Server-side "am I logged in" check for the header/account menu. Reads the
// cookie the customer dashboard writes on login (see AUTH_TOKEN_COOKIE in
// config.ts and AI_PROMPT.md "Shared login") — this storefront never issues
// it. Resolves to null whenever the cookie is absent or /auth/me fails —
// never throws, so a missing session degrades to the anonymous view.
export async function getSession(): Promise<AuthUser | null> {
const store = await cookies();
const token = store.get(AUTH_TOKEN_COOKIE)?.value;
if (!token) return null;
const raw = reassembleChunkedCookie((name) => store.get(name)?.value, AUTH_TOKEN_COOKIE);
if (!raw) return null;
const token = decodeURIComponent(raw);
const result = await safe(getMe(token), null);
return result?.user ?? null;
+42
View File
@@ -1,5 +1,6 @@
// Pure AuthUser helpers with no server-only dependency (unlike auth.ts,
// which reads next/headers) — safe to import from client components.
import { AUTH_TOKEN_COOKIE } from "./config";
import type { AuthUser } from "./types";
// "علی عزیز" — first name (or the fullest name field available) + عزیز.
@@ -14,3 +15,44 @@ export function displayName(user: AuthUser): string {
export function isAdmin(user: AuthUser): boolean {
return Boolean(user.isAdmin) || user.role === "admin" || user.role === "owner";
}
// Per AI_PROMPT.md "Shared login": the access-token cookie "may be chunked
// as name_n + name_0…" when the JWT is too long for one cookie — name_n
// holds the chunk count, name_0.._n-1 the pieces, joined in order. Shared by
// the server (auth.ts, reading next/headers) and the client (CartButton's
// Continue, reading document.cookie) — both just need to hand this a
// `(name) => value | undefined` reader for whichever cookie source they have.
export function reassembleChunkedCookie(
getCookie: (name: string) => string | undefined,
baseName: string
): string | undefined {
const direct = getCookie(baseName);
if (direct) return direct;
const count = Number(getCookie(`${baseName}_n`));
if (!Number.isInteger(count) || count <= 0) return undefined;
const parts: string[] = [];
for (let i = 0; i < count; i++) {
const part = getCookie(`${baseName}_${i}`);
if (!part) return undefined; // a chunk is missing — treat as absent rather than truncated
parts.push(part);
}
return parts.join("");
}
function readCookieFromDocument(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
return document.cookie
.split("; ")
.find((c) => c.startsWith(`${name}=`))
?.slice(name.length + 1);
}
// Client-side "am I logged in" check — just the cookie's presence, per
// AI_PROMPT.md ("if the access-token cookie exists, treat them as logged
// in"). Used by CartButton to decide the Continue URL without needing the
// full session fetch getSession() does server-side.
export function hasCustomerSessionCookie(): boolean {
return Boolean(reassembleChunkedCookie(readCookieFromDocument, AUTH_TOKEN_COOKIE));
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { discountPercent } from "./format";
import { discountPercent, isInStock } from "./format";
import { productHref } from "./slug";
import { DEMO_PRICING, demoPrice } from "./demoPricing";
import type { ApiProduct, ProductCardData, StoreItem, StoreSpecial } from "./types";
@@ -65,7 +65,7 @@ export function specialToCards(special: StoreSpecial): ProductCardData[] {
price,
oldPrice,
percent: price !== null && oldPrice !== null ? discountPercent(price, oldPrice) : 0,
inStock: (variant?.stockQuantity ?? 0) > 0,
inStock: isInStock(variant?.stockQuantity),
};
});
}
+13 -8
View File
@@ -7,14 +7,19 @@ export const CUSTOMER_PORTAL_URL = `https://customer.${SITE_DOMAIN}`;
// "مدیریت وبسایت" account-menu item shown to admins.
export const BUSINESS_PORTAL_URL = `https://business.${SITE_DOMAIN}`;
// Login happens on the customer portal (a separate app), not on this site.
// For the account menu to know a visitor is signed in, that portal needs to
// set this cookie on the shared parent domain (".{SITE_DOMAIN}", not
// HttpOnly) after login, so requests to this site carry it too. Until it
// does, getSession() in src/lib/auth.ts always resolves to null and the
// header simply shows the anonymous "ورود | ثبت‌نام" link — nothing breaks,
// the logged-in state just never activates.
export const AUTH_TOKEN_COOKIE = "meshkee_token";
// Written by the customer dashboard on login, on the shared parent domain
// (".{SITE_DOMAIN}", not HttpOnly) — this storefront only ever reads it, per
// the official AI_PROMPT.md "Shared login" contract. May be chunked into
// `${AUTH_TOKEN_COOKIE}_0`, `_1`, … with `${AUTH_TOKEN_COOKIE}_n` holding the
// chunk count, when the JWT is too long for one cookie — see readAuthToken()
// in src/lib/auth.ts / src/lib/authUser.ts, never read this raw.
export const AUTH_TOKEN_COOKIE = "meshkee_customer_access_token";
// The mini-cart is local-only per AI_PROMPT.md "Shopping cart" — never a
// server cart. Same storage key is used for both localStorage and the
// mirrored cookie, and must match the customer dashboard's own key exactly
// so it can read a guest cart handed off via the Continue button.
export const GUEST_CART_STORAGE_KEY = "meshkee-guest-cart";
// Pulled from the live moalem.shop theme (--wb-primary-color / --wb-secondary-color).
export const BRAND = {
+7
View File
@@ -15,6 +15,13 @@ export function discountPercent(price: number, oldPrice: number): number {
return Math.round(((oldPrice - price) / oldPrice) * 100);
}
// Per AI_PROMPT.md "Shopping cart" step 3: a variant's stockQuantity of
// `null` means unlimited stock (in stock), not zero/out of stock — never
// treat it as falsy.
export function isInStock(stockQuantity: number | null | undefined): boolean {
return stockQuantity === null || (stockQuantity ?? 0) > 0;
}
// The static-image-slot API documents aspectRatio as "16:9", but CSS's
// aspect-ratio property needs a slash ("16/9"), not a colon.
export function toCssAspectRatio(value: string | number | null | undefined): string | undefined {
+95
View File
@@ -0,0 +1,95 @@
// The mini-cart per AI_PROMPT.md "Shopping cart": local-only, never the
// server cart (`/businesses/{id}/cart*` is off-limits from this site). Must
// match the customer dashboard's own guest-cart contract exactly — storage
// key, cookie attributes, line shape, and the encoding function below are
// all copied verbatim from that spec, not invented.
import { GUEST_CART_STORAGE_KEY, SITE_DOMAIN } from "./config";
export interface GuestCartItem {
id: string; // storeItemVariantId — never a product id or store-item id
name: string;
slug: string;
price: number;
originalPrice?: number;
image: string | null;
quantity: number;
}
// Verbatim from AI_PROMPT.md — base64url of the UTF-8 JSON, no padding.
export function encodeGuestCart(items: GuestCartItem[]): string {
const json = JSON.stringify(items);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Cookie mirror is capped ~3500 chars (skip it and rely on the URL param
// instead) — the spec's own number, not a guess.
const COOKIE_SIZE_LIMIT = 3500;
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // ~30 days
function readLocalStorage(): GuestCartItem[] {
if (typeof window === "undefined") return [];
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 writeStorage(items: GuestCartItem[]): void {
if (typeof window === "undefined") return;
const json = JSON.stringify(items);
try {
window.localStorage.setItem(GUEST_CART_STORAGE_KEY, json);
} catch {
// localStorage can throw (private mode, quota) — the cookie mirror below
// still gets a chance to persist the cart.
}
const encoded = encodeURIComponent(json);
if (encoded.length > COOKIE_SIZE_LIMIT) return; // rely on the URL param on Continue instead
try {
document.cookie = `${GUEST_CART_STORAGE_KEY}=${encoded}; Domain=.${SITE_DOMAIN}; Path=/; Max-Age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
} catch {
// Cookie writes can fail under strict privacy settings — localStorage is
// the primary store, this is just the cross-subdomain mirror.
}
}
export function readGuestCart(): GuestCartItem[] {
return readLocalStorage();
}
// `id` is the storeItemVariantId — the one field the dashboard resolves to
// stock/price server-side. If it's already in the cart, increment quantity
// instead of adding a second line (per AI_PROMPT.md step 6).
export function addGuestCartItem(item: GuestCartItem): GuestCartItem[] {
const items = readGuestCart();
const existing = items.find((i) => i.id === item.id);
const next = existing
? items.map((i) => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i))
: [...items, item];
writeStorage(next);
return next;
}
export function updateGuestCartItem(id: string, quantity: number): GuestCartItem[] {
const items = readGuestCart();
const next =
quantity <= 0 ? items.filter((i) => i.id !== id) : items.map((i) => (i.id === id ? { ...i, quantity } : i));
writeStorage(next);
return next;
}
export function removeGuestCartItem(id: string): GuestCartItem[] {
const next = readGuestCart().filter((i) => i.id !== id);
writeStorage(next);
return next;
}
+15 -28
View File
@@ -175,11 +175,21 @@ export interface StoreSpecialVariant {
selections: StoreItemSelection[];
price: number | null;
discountedPrice: number | null;
stockQuantity: number;
stockQuantity: number | null;
isFestival: boolean;
sortOrder: number;
}
// GET /store-items/by-product/{productId} → { storeItem: StoreItemVariants |
// null } — the documented source for a product's purchasable variants (see
// AI_PROMPT.md "Shopping cart"). `stockQuantity: null` means unlimited
// stock, not out of stock — check `=== 0`, not falsiness.
export interface StoreItemVariants {
id: string;
productId: string;
variants: StoreSpecialVariant[];
}
export interface StoreSpecialItem {
id: string;
productId: string;
@@ -311,30 +321,7 @@ export interface AuthUser {
role?: string;
}
// The cart endpoints (GET/POST/PATCH/DELETE .../cart) document their
// responses only as "{ cart }" / "{ message, cart }" — no field-level
// schema — and this tenant's cart has never been exercised (store-items are
// empty, so nothing has ever been added). Every field below is a best-effort
// guess at a conventional e-commerce cart shape; read all of them
// defensively (CartIcon.tsx does) and treat this as unverified until real
// cart data is seen. See AGENTS.md "Shopping cart".
export interface CartItem {
id: string;
storeItemVariantId?: string;
productId?: string;
productTitle?: string;
productNameFa?: string;
image?: string | null;
selections?: StoreItemSelection[];
price?: number | null;
discountedPrice?: number | null;
quantity: number;
}
export interface Cart {
id?: string;
items: CartItem[];
itemCount?: number;
subtotal?: number;
total?: number;
}
// The mini-cart is local-only (see AGENTS.md "Shopping cart" and
// src/lib/guestCart.ts for GuestCartItem) — there is no server Cart/CartItem
// type here on purpose, calling /businesses/{id}/cart* from this site is
// against the documented contract.