13 KiB
Cart Management in apps/user-ui — Detailed Documentation
How the customer cart works end-to-end: local storage, React Query hooks, the two cart types (regular + flash), pricing, slots, coupons, checkout handoff, and all edge cases.
1. Architecture Overview
The cart is 100% client-side / local-first. There is no server cart — items live in local storage (SecureStore on native, localStorage on web via StorageServiceCasual), and are resolved against the cached product catalog + slot/availability data on the device.
AddToCartDialog / ProductCard / FloatingCartBar
│ useCartStore.setAddedToCartProduct() (trigger dialog)
▼
useAddToCart / useUpdateCartItem / useRemoveFromCart (cart-query-hooks.tsx)
│ mutations write to local storage
▼
StorageServiceCasual (key: "cart_items" | "flash_cart_items")
▲
useGetCart() (query reads local storage + merges product/availability data)
│
cart-page.tsx / floating-cart-bar.tsx / checkout-page.tsx
Key files
| File | Role |
|---|---|
hooks/cart-query-hooks.tsx |
The core: local-storage CRUD + React Query hooks (useGetCart, useAddToCart, useUpdateCartItem, useRemoveFromCart, clearLocalCart). |
src/store/cartStore.ts |
Zustand: addedToCartProduct — which product opened the AddToCart dialog. |
src/store/flashCartStore.ts |
Zustand: addedFlashProduct — which product was routed to the flash flow. |
src/store/centralProductStore.ts |
Zustand: cached products + productsById map (from useAllProducts). |
src/store/centralSlotStore.ts |
Zustand: slots + productSlotsMap (per-product availability/out-of-stock/flash). |
components/cart-page.tsx |
The full cart screen (regular + flash via prop). |
components/floating-cart-bar.tsx |
Collapsible cart summary bar (count, total, free-delivery progress). |
src/components/AddToCartDialog.tsx |
Slot/quantity/flash chooser when adding/updating an item. |
components/checkout-page.tsx |
Reads cart via URL params (slots=, coupons=, deliveryPrice=) — the cart is passed by reference, not re-read. |
2. Data Model
Local storage item (LocalCartItem)
{
id: number, // local incrementing id (max existing id + 1)
skuId: number, // the product/SKU id
quantity: number,
slotId: number, // delivery slot id; 0 = flash/unspecified
addedAt: string, // ISO timestamp
}
Stored under cart_items (regular) or flash_cart_items (flash) via StorageServiceCasual (SecureStore native / localStorage web).
Computed cart (CartData)
{
items: CartItem[],
totalItems: number, // item count (not quantity)
totalAmount: number, // Σ(price × quantity)
}
CartItem = local item + subtotal (computed from productBasic.price).
Product resolution
useGetCart builds items by looking up each skuId in productsById (central product store) and requiring productSlotsMap[skuId] to exist — if either is missing, the item is silently dropped from the cart view (not deleted from storage).
3. Flow: Add to Cart
- Trigger:
ProductCard/ home / store / search calluseCartStore.setAddedToCartProduct({ productId, product })→ theAddToCartDialogopens (regular) ORuseFlashCartStore.setAddedFlashProduct(flash). - Dialog (
AddToCartDialog.tsx):- Lists available slots for the product (from
useSlots()→productSlotIdsMap, filtered to futurefreezeTime). - Shows "1 hr Delivery" option if
productSlotsMap[id].isFlashAvailableAND flash is enabled. - Quantity via
Quantifier(step 1 — not the product'sincrementStep!). - Pre-fills from existing cart item: if the product is already in the cart, it loads the existing quantity + slotId.
- "Add to Cart" / "Update Item" (if already in cart) / "Remove" (if updating).
- Lists available slots for the product (from
- Mutation (
useAddToCart/useUpdateCartItem):addToLocalCart: ifskuIdexists → merge quantity (add to existing) + optionally update slotId; else push new item.- Requires
slotId(throws if null). updateLocalCartItem: setquantityon the item (by localid).removeFromLocalCart: filter out by localid.
- Flash special-case: choosing "1 hr Delivery" sets
useFlashCartStore.setAddedFlashProduct(...)and navigates to the flash products page — it does NOT add to the regular cart via the dialog; the flash cart is a separate local list.
4. Flow: Cart Page
cart-page.tsx (isFlashDelivery prop selects cartType):
- Loading: blocks on
useGetCartloading + (regular only)getCartSlotsloading. - Per-item:
- Image, name, unit,
Quantifier(step = productincrementStep). - Quantity to 0 → confirmation alert → remove.
- Slot selection (regular only):
BottomDropdownfromtrpc.user.cart.getCartSlots({ productIds }), filtered to future slots. Flash forcesslotId = 0. - Price: regular uses
product.price; flash usesproduct.flashPrice ?? product.price. - Availability logic per item:
productSlotInfo.isOutOfStock→ unavailable "Out of Stock".- Flash: not flash-eligible → "Not available for flash delivery. Please remove".
- Regular: no slots → "No delivery slots available".
- Image, name, unit,
- Auto-slot effect: on
slotsData/cartItemschange, auto-selects each item's slot (keeps existing if still valid, else first upcoming; flash = 0). - Coupons:
trpc.user.coupon.getEligible→ eligible list; min-order + usage-limit filtering client-side; discount = percent (capped atmaxValue) or flat (capped at total); applied coupon id passed to checkout via URL. - Bill: Item Total → Product Discount → Coupon Applied → Delivery Fee (0 if
finalTotal >= threshold) → To Pay. Free-delivery nudge + "You saved ₹X" banner. - Checkout handoff: builds
slots=<slotId>:<itemId,itemId>;...+coupons=<id>+deliveryPrice=<charge>URL params → navigates to/home/checkoutor/flash-delivery/checkout. - Guard: if no available items → alert (all OOS / none flash-eligible / missing slots) and blocks checkout.
5. Flow: Floating Cart Bar
- Reads
useGetCartfor the active cart type; shows count +totalCartValue+ free-delivery progress (regular vs flash thresholds). - Expanded dialog: list with
MiniQuantifier(0 = remove), per-item slot dropdown (regular), subtotal, "Go to cart" → cart page. - Slot-reassignment effect (runs once on mount): for regular items whose
slotIdis no longer inproductSlotsMap[skuId].slots, it removes + re-adds the item with the nearest slot (removeFromCart.mutate+addToCartHook.addToCart). (Note:addToCart(item.skuId, item.quantity, nearestSlotId)— passingquantityas the quantity arg.)
6. Checkout integration
- Checkout does not re-read local storage directly — it receives the cart items by URL params (
slots=,coupons=,deliveryPrice=) from the cart page. - This means the checkout's item set/quantities/slots are frozen at navigation time; if the cart changes after, checkout uses the snapshot.
7. Edge Cases & Observations
Data / storage
- Missing product or slot data → item dropped from view (
useGetCartreturnsnullfor items lackingproductBasicorproductAvailability). The item stays in storage but is invisible — potential "phantom cart" if product is later removed from catalog. - Duplicate add merges quantity (same
skuId→quantity +=). No separate line items. slotId: 0sentinel for flash — 0 is not a real slot; code must treat 0 as "flash/unspecified".- Local
idgeneration =max(existing ids) + 1. Deleting the highest-id item then adding reuses ids (fine) — but concurrent adds (two rapid taps) could race since id is computed from the read-then-write sequence (no lock). - Storage failures (
StorageServiceCasual.setItemerror) are caught by the service and return false — the mutation still resolves, but the write may not have persisted (silent data loss risk).
Slot / availability
- Auto-slot reassignment: if a cart item's saved slot becomes invalid, the cart page picks the first upcoming slot silently; the floating bar instead does remove+re-add. Two different behaviors for the same situation.
- Slot changes in cart page are local-only (
selectedSlotsstate) — they're NOT written back toLocalCartItem.slotIduntil checkout builds the URL. (TheuseEffectreadscartDataslotId but the dropdown only updatesselectedSlots.) → If the user leaves the cart without checking out, the new slot choice is lost. AddToCartDialogrequires a slot — if a product has no future slots, the dialog can't add it (button disabled). Good, but the flash path can bypass via "1 hr Delivery".- Flash eligibility is from
productSlotsMap[id].isFlashAvailable— but the flash cart page also checksflashEligibleProductIds(derived fromproducts+productSlotsMap). Two sources that should agree. - Out-of-stock mid-cart: if a product becomes OOS after adding, cart page shows "Out of Stock" and excludes it from totals/checkout, but does NOT auto-remove it.
Quantity / pricing
- Dialog step = 1 vs cart page step = product.incrementStep — inconsistent stepping between the two add/update UIs. (A product with 500g increment could be set to any integer in the dialog.)
baseTotalPrice(coupon eligibility) usesproduct.price(not flash price) even in flash cart — coupon min-order check may be wrong for flash.totalPricefilters OOS items — good — but theQuantifierstill shows them; quantity edits on OOS items are allowed.- Flash pricing:
flashPrice ?? price— ifflashPriceis0ornull, falls back to base price; ifflashPriceis a valid string, used. (Floating bar + cart page consistent.) - Discount cap for flat coupons:
Math.min(flatDiscount, maxValue || totalPrice)— reasonable; percent capped atmaxValue || Infinity.
Coupons
- Usage-limit coupons are filtered out entirely from the dropdown (
.filter(coupon => coupon.ineligibilityReason !== "Usage limit exceeded")) — a used-up coupon disappears rather than showing "used up". - Min-order coupons show disabled with reason — good.
- Selected coupon is only applied to available items' total — matches backend intent (coupon applies to non-OOS items).
- Coupon passed by id to checkout — checkout re-validates on the backend; client discount is a preview.
Checkout / navigation
- Cart is snapshotted into URL params — long carts → long URLs; quantities/slots frozen at navigation.
availableItems.length === 0guard produces specific alerts (all OOS / none flash-eligible / missing slots) — good UX.- Partial availability: if only some items are available, checkout proceeds with only the available ones — the unavailable ones stay in the cart (not removed).
- Cart type switcher navigates between regular/flash flows, but does NOT move items — each cart type is independent.
Flash-specific
- Flash cart items have
slotId: 0— the cart page forcesselectedSlots[item.id] = 0for flash. - Adding via "1 hr Delivery" in the dialog does NOT add to the regular cart — it sets
addedFlashProductand navigates to flash products; the actual add happens on the flash page. (If the user abandons the flash page, nothing was added.) isFlashDeliveryEnabledgate — flash option only shows if the const is true.
Concurrency / timing
useGetCartenabled only whenproductsByIdis populated — before the central product store loads, the cart query is disabled (no stale empty cart flash). Good.refetchOnWindowFocusdefault true → returning to the app refreshes cart totals (from local storage) but prices come from cached products — price updates in the catalog reflect on next product refetch.- Floating bar slot-reassignment runs once (
[]deps) — on mount only; slot validity isn't re-checked on later slot-data changes. - Remove-then-add in floating bar for invalid slots changes the local item
id(new id) — any UI holding the old id could mismatch.
8. Potential improvements (suggestions, not required)
- Persist slot changes: write
selectedSlotsback toLocalCartItem.slotIdso slot choices survive leaving the cart. - Unify quantity step (dialog step=1 vs cart incrementStep).
- Fix flash coupon min-order to use flash price.
- Handle "phantom items": purge local cart entries whose product no longer exists (or show them as unavailable).
- Serialize add-to-cart writes (mutex/queue) to avoid id races on rapid taps.
- Surface storage write failures instead of silently succeeding.
Documentation based on code reading of cart-query-hooks.tsx, cartStore.ts, flashCartStore.ts, centralProductStore.ts, centralSlotStore.ts, cart-page.tsx, floating-cart-bar.tsx, AddToCartDialog.tsx, and checkout-page.tsx.