182 lines
13 KiB
Markdown
182 lines
13 KiB
Markdown
# 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`)
|
||
```ts
|
||
{
|
||
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`)
|
||
```ts
|
||
{
|
||
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
|
||
|
||
1. **Trigger**: `ProductCard` / home / store / search call `useCartStore.setAddedToCartProduct({ productId, product })` → the `AddToCartDialog` opens (regular) OR `useFlashCartStore.setAddedFlashProduct` (flash).
|
||
2. **Dialog** (`AddToCartDialog.tsx`):
|
||
- Lists **available slots** for the product (from `useSlots()` → `productSlotIdsMap`, filtered to future `freezeTime`).
|
||
- Shows **"1 hr Delivery"** option if `productSlotsMap[id].isFlashAvailable` AND flash is enabled.
|
||
- Quantity via `Quantifier` (step 1 — not the product's `incrementStep`!).
|
||
- **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).
|
||
3. **Mutation** (`useAddToCart` / `useUpdateCartItem`):
|
||
- `addToLocalCart`: if `skuId` exists → **merge quantity** (add to existing) + optionally update slotId; else push new item.
|
||
- Requires `slotId` (throws if null).
|
||
- `updateLocalCartItem`: set `quantity` on the item (by local `id`).
|
||
- `removeFromLocalCart`: filter out by local `id`.
|
||
4. **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 `useGetCart` loading + (regular only) `getCartSlots` loading.
|
||
- **Per-item**:
|
||
- Image, name, unit, `Quantifier` (step = product `incrementStep`).
|
||
- Quantity to **0** → confirmation alert → remove.
|
||
- **Slot selection** (regular only): `BottomDropdown` from `trpc.user.cart.getCartSlots({ productIds })`, filtered to future slots. Flash forces `slotId = 0`.
|
||
- Price: regular uses `product.price`; flash uses `product.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".
|
||
- **Auto-slot effect**: on `slotsData`/`cartItems` change, 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 at `maxValue`) 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/checkout` or `/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 `useGetCart` for 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 `slotId` is no longer in `productSlotsMap[skuId].slots`, it **removes + re-adds** the item with the nearest slot (`removeFromCart.mutate` + `addToCartHook.addToCart`). (Note: `addToCart(item.skuId, item.quantity, nearestSlotId)` — passing `quantity` as 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
|
||
1. **Missing product or slot data → item dropped from view** (`useGetCart` returns `null` for items lacking `productBasic` or `productAvailability`). The item stays in storage but is invisible — potential "phantom cart" if product is later removed from catalog.
|
||
2. **Duplicate add merges quantity** (same `skuId` → `quantity +=`). No separate line items.
|
||
3. **`slotId: 0` sentinel** for flash — 0 is not a real slot; code must treat 0 as "flash/unspecified".
|
||
4. **Local `id` generation** = `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).
|
||
5. **Storage failures** (`StorageServiceCasual.setItem` error) 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
|
||
6. **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.
|
||
7. **Slot changes in cart page are local-only** (`selectedSlots` state) — they're NOT written back to `LocalCartItem.slotId` until checkout builds the URL. (The `useEffect` reads `cartData` slotId but the dropdown only updates `selectedSlots`.) → **If the user leaves the cart without checking out, the new slot choice is lost.**
|
||
8. **`AddToCartDialog` requires 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".
|
||
9. **Flash eligibility** is from `productSlotsMap[id].isFlashAvailable` — but the flash cart page also checks `flashEligibleProductIds` (derived from `products` + `productSlotsMap`). Two sources that should agree.
|
||
10. **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
|
||
11. **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.)
|
||
12. **`baseTotalPrice` (coupon eligibility) uses `product.price`** (not flash price) even in flash cart — coupon min-order check may be wrong for flash.
|
||
13. **`totalPrice` filters OOS items** — good — but the `Quantifier` still shows them; quantity edits on OOS items are allowed.
|
||
14. **Flash pricing**: `flashPrice ?? price` — if `flashPrice` is `0` or `null`, falls back to base price; if `flashPrice` is a valid string, used. (Floating bar + cart page consistent.)
|
||
15. **Discount cap for flat coupons**: `Math.min(flatDiscount, maxValue || totalPrice)` — reasonable; percent capped at `maxValue || Infinity`.
|
||
|
||
### Coupons
|
||
16. **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".
|
||
17. **Min-order coupons show disabled** with reason — good.
|
||
18. **Selected coupon is only applied to available items' total** — matches backend intent (coupon applies to non-OOS items).
|
||
19. **Coupon passed by id to checkout** — checkout re-validates on the backend; client discount is a preview.
|
||
|
||
### Checkout / navigation
|
||
20. **Cart is snapshotted into URL params** — long carts → long URLs; quantities/slots frozen at navigation.
|
||
21. **`availableItems.length === 0` guard** produces specific alerts (all OOS / none flash-eligible / missing slots) — good UX.
|
||
22. **Partial availability**: if only some items are available, checkout proceeds with only the available ones — the unavailable ones stay in the cart (not removed).
|
||
23. **Cart type switcher** navigates between regular/flash flows, but does NOT move items — each cart type is independent.
|
||
|
||
### Flash-specific
|
||
24. **Flash cart items have `slotId: 0`** — the cart page forces `selectedSlots[item.id] = 0` for flash.
|
||
25. **Adding via "1 hr Delivery" in the dialog does NOT add to the regular cart** — it sets `addedFlashProduct` and navigates to flash products; the actual add happens on the flash page. (If the user abandons the flash page, nothing was added.)
|
||
26. **`isFlashDeliveryEnabled` gate** — flash option only shows if the const is true.
|
||
|
||
### Concurrency / timing
|
||
27. **`useGetCart` enabled only when `productsById` is populated** — before the central product store loads, the cart query is disabled (no stale empty cart flash). Good.
|
||
28. **`refetchOnWindowFocus`** default 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.
|
||
29. **Floating bar slot-reassignment runs once (`[]` deps)** — on mount only; slot validity isn't re-checked on later slot-data changes.
|
||
30. **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 `selectedSlots` back to `LocalCartItem.slotId` so 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`.*
|