Core
Catalog
Cart

Catalog Cart

Client-only shopping cart for the public storefront, persisted in localStorage and submitted as a JSON payload at checkout. Audience: new dev with Node/React experience, no Eziseller context.

1. Overview

The catalog cart is a pure client-side construct. There is no cart table in Postgres, no server session, and no "cart API". A React context (CartProvider) holds the item list in component state and mirrors it to localStorage under the key catalog_cart. The cart exists only in the shopper's browser until checkout, at which point the items array is POSTed to the backend as part of the order payload. The backend re-fetches products and re-prices everything from the database — the cart's price field is display-only. On a successful order response the frontend calls clearCart(), which empties state and (via the persistence effect) wipes localStorage.

2. Architecture

The provider is mounted once inside the catalog layout; every storefront page and component reads from it via useCart(). The backend never sees the cart until the final POST.

3. Data model

The cart is not a Prisma model. It is a TypeScript shape kept in memory and JSON-serialised to localStorage.

interface CartItem {
  productId: string;
  variantId?: string;       // optional — products without variants omit this
  productName: string;      // snapshot, display only
  variantName?: string;     // snapshot, display only
  quantity: number;
  price: number;            // snapshot at add-time, NOT trusted by BE
  imageUrl?: string;
}

Identity of a line is (productId, variantId) — adding the same pair merges quantities. Derived values itemCount and totalAmount are recomputed on every render.

The checkout POST strips everything except productId, variantId, quantity before sending — see CheckoutPageClient.tsx:L94-L98.

4. Key flows

4.1 Add → persist → checkout → clear

4.2 Update / remove

updateQuantity(productId, variantId, qty) with qty <= 0 delegates to removeItem, otherwise patches the matching line. Both paths trigger the same useEffect that writes catalog_cart to localStorage — there is no debounce, every keystroke on a quantity input writes synchronously.

4.3 Hydration

On first mount CartProvider reads localStorage.getItem('catalog_cart') and seeds state. There is a one-render window where items === [] before hydration completes; components that render server-side will briefly show an empty cart badge before the client effect fires.

5. Key files

6. Env vars & config

None. The cart is a pure browser feature; it has no env vars, no feature flag, and no backend configuration.

7. Gotchas & troubleshooting

  • localStorage key is global, not per-store. The key catalog_cart is hard-coded in CartContext.tsx:L32,L40. If a shopper visits store-a then store-b in the same browser, they see store-a's items in store-b's cart. Checkout will fail with Product not found: ... because the productIds belong to the other store's owner. Fix would be to key by catalog slug.
  • Cart survives forever. There is no TTL and no "session" concept. A shopper who abandoned a cart six months ago still sees it on return — with stale price and possibly deleted products.
  • Stored price is stale. The cart snapshots price at add-time. If the seller raises the price, the shopper keeps seeing the old price in the drawer, cart page, and checkout summary. The backend re-prices, so the final totalAmount returned by /checkout can differ from what the UI showed — the success screen reads BE's totalAmount so it self-corrects, but there is no "price changed" warning.
  • No server validation until checkout. Out-of-stock, unpublished, or deleted products stay in the cart silently. The first signal is a 400 from the checkout POST (Some items are out of stock / Product not found).
  • Variant identity matters. Items are merged on (productId, variantId). A product without variants has variantId === undefined; be careful passing null vs undefinedfindIndex compares with === and would treat them as distinct lines.
  • SSR hydration flicker. The provider starts with items: [] and populates on mount. Cart badge briefly shows 0 on first paint.
  • clearCart only runs on the success path. If the shopper closes the tab between Razorpay capture and the /checkout POST completing, the cart is not cleared and the Razorpay webhook-fallback path (public-catalog.ts:L722+) may create the order server-side, leaving the browser holding a cart for an already-placed order.
  • No cross-tab sync. Two tabs open on the same catalog each hold their own React state; only the last one to write wins in localStorage, and neither listens to storage events.

8. Extension points

  • Server-side cart. Introduce a Cart Prisma model keyed by an anonymous cartToken cookie; hydrate the context from GET /:slug/cart/:token and mirror mutations. Enables cross-device carts and price recalculation on read.
  • Per-store scoping. Change the localStorage key to catalog_cart:${slug} inside the provider (pass slug via props from the catalog layout). Fixes cross-store contamination without a backend change.
  • Abandoned-cart recovery. Persist (cartToken, customerPhone/email, items, updatedAt) server-side and hook WhatsApp/email templates into a scheduler (see cron-and-jobs.md).
  • Stale-price reconciliation. On cart page mount, call a batch POST /:slug/cart/refresh that returns current prices and availability; show a diff banner before the shopper proceeds to checkout.
  • Stock holds. Reserve inventory for N minutes when an item is added (today stock is only checked at checkout; see products/inventory.md).

9. Related docs