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