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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user