freshyo/docs/user-ui-cart-management.md
2026-08-16 15:45:05 +05:30

182 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`.*