|
| 1 | +--- |
| 2 | +title: Configurable On-Demand Minicart (TanStack / React Query) |
| 3 | +description: Build an API-frugal, CMS-configurable VTEX minicart for Deco storefronts on TanStack Start with React Query. No getOrCreateCart on page load, lazy orderForm creation, canonical Minicart shape, micro-skeletons, and toast-vs-drawer toggle. |
| 4 | +reference: montecarlo-tanstack |
| 5 | +tags: [minicart, react-query, vtex, performance, cms, ux] |
| 6 | +--- |
| 7 | + |
| 8 | +# Configurable On-Demand Minicart (TanStack / React Query / VTEX) |
| 9 | + |
| 10 | +Turn a VTEX minicart into an API-frugal, CMS-configurable, replicable component on `@decocms/start` (TanStack Start / React / Cloudflare) with `@decocms/apps-vtex@7.20+`. |
| 11 | + |
| 12 | +**Reference implementation:** Monte Carlo (`montecarlo-tanstack`). |
| 13 | + |
| 14 | +## Goals this Delivers |
| 15 | + |
| 16 | +1. **Zero `getOrCreateCart` calls on page load / F5.** The cart is a react-query query gated so a returning shopper reloading a page triggers ZERO orderForm calls. Header badge renders from a lightweight `cart_item_count` cookie instead. |
| 17 | +2. **Empty cart without API calls.** A cookieless visitor opening the drawer sees an empty state with zero API calls; the orderForm is created only on the first add-to-cart. |
| 18 | +3. **Canonical `Minicart` shape.** Adopt the platform-agnostic `Minicart` type from `@decocms/apps-vtex/utils/minicart` so totals, currency, locale, and free-shipping math come from one boundary conversion, not ad-hoc field digging. |
| 19 | +4. **Micro-skeletons without layout shift.** Per-line quantity/price skeletons + footer total skeleton via pulse-in-place (not fixed boxes), preserving exact dimensions and preventing row collapse. |
| 20 | +5. **CMS-editable config + composable shelf.** A loader-based config with live Preview (Farm pattern) + a slot for dropping product shelves inside the cart via `SectionRenderer`. |
| 21 | +6. **Toast-vs-drawer toggle.** A "notification (toast)" switch: ON (default) = toast on add + drawer stays closed; OFF = drawer auto-opens. Driven by CMS config. |
| 22 | + |
| 23 | +## Architecture |
| 24 | + |
| 25 | +### On-Demand React Query Cart |
| 26 | + |
| 27 | +`src/sdk/cart/` holds the core SDK: |
| 28 | + |
| 29 | +- **`queries.ts`** — `cartKeys`, orderForm cookie helpers, the `cart_item_count` badge cookie, and `shouldFetchCart(displayCartIntent)` (the fetch gate). |
| 30 | +- **`useCartQuery.ts`** — `useCart()`: react-query query + optimistic mutations. Exposes legacy signal-shaped surface (`cart.value`, `loading.value`) AND the canonical `minicart` via `useMemo`. |
| 31 | +- **`config.ts`** — Module-level `cartConfig` signal + `setCartConfig` (toast, free-shipping threshold, coupon toggle, checkout href). Read by add-button and toast island without prop drilling. |
| 32 | + |
| 33 | +**The fetch gate** is the heart of goal #1/#2: |
| 34 | +```ts |
| 35 | +// shouldFetchCart(intent) — fetch ONLY when the shopper intends to open AND a cart exists |
| 36 | +// (a persisted orderFormId cookie OR a mutation ran this session). |
| 37 | +return displayCartIntent && (Boolean(readOrderFormCookie()) || _mutationRanThisSession); |
| 38 | +``` |
| 39 | + |
| 40 | +Badge count is served from the `cart_item_count` cookie (written on every commit + optimistic patch), read client-only in a `useEffect` to avoid SSR hydration mismatch. |
| 41 | + |
| 42 | +### Invoke vs Direct Fetch (Critical Reconciliation) |
| 43 | + |
| 44 | +`@decocms/apps-vtex@7.20`'s stock `hooks/useCart` does browser `fetch("/api/checkout/pub/orderForm")` — only works on a VTEX-proxied domain. Deco storefronts on custom domains do NOT proxy `/api/checkout`; they use the `invoke` server-function proxy. |
| 45 | + |
| 46 | +**Do NOT adopt the stock hook as-is.** Instead **graft** the pure/portable parts: |
| 47 | + |
| 48 | +- The canonical type `Minicart` (`@decocms/apps-commerce/types`) |
| 49 | +- The transform `vtexOrderFormToMinicart` (`@decocms/apps-vtex/utils/minicart`) |
| 50 | +- The `loaders/minicart` "empty shell when no cookie" pattern |
| 51 | + |
| 52 | +onto your existing `invoke`-based, on-demand local cart. Compute `minicart` with `useMemo`: |
| 53 | + |
| 54 | +```ts |
| 55 | +const minicart = useMemo(() => data ? vtexOrderFormToMinicart(data, { |
| 56 | + freeShippingTarget: config.freeShippingTarget, |
| 57 | + checkoutHref: config.checkoutHref, |
| 58 | + enableCoupon: config.enableCoupon, |
| 59 | +}) : null, [data, config.freeShippingTarget, config.checkoutHref, config.enableCoupon]); |
| 60 | +``` |
| 61 | + |
| 62 | +**Alternative (out of scope):** Reverse-proxy `/api/checkout` → VTEX at the edge to use the stock hook directly. Document, don't implement. |
| 63 | + |
| 64 | +### CMS Config as Loader with Preview (Not a Section) |
| 65 | + |
| 66 | +The minicart drawer is a **layout-shell overlay** (always mounted in `Header/Drawers`, opened by a global `displayCart` signal). It is NOT a page section — don't try to make it one. |
| 67 | + |
| 68 | +- **`src/loaders/minicart.tsx`** — Identity loader returning `MinicartConfig` (rich JSDoc: `freeShippingTarget`, `enableCoupon`, `checkoutHref`, `variant`, `showAddToCartToast`, `addedToast`, `emptyState`, `shelfSections?: Section[]`). Also `export const Preview = (config) => JSX` — a self-contained HTML preview of the configured minicart open and populated (Farm pattern, e.g. `deco-sites/farmrio/loaders/Layouts/Tags.tsx`). Keep Preview dependency-free (no runtime hooks). |
| 69 | +- **Header receives flat config object** (`cart.config?: MinicartConfig`), passes it through to `Drawers → Cart`. No `SectionRenderer` wrapping the drawer. |
| 70 | +- **Composable shelf slot** — `common/Cart.tsx` renders `shelfSections` via `SectionRenderer` so the admin can drop a product shelf (Granado style) inside the cart. Scope is localized. |
| 71 | + |
| 72 | +## File Map (Copy/Adapt per Site) |
| 73 | + |
| 74 | +| File | Role | |
| 75 | +|---|---| |
| 76 | +| `src/sdk/cart/queries.ts` | Fetch gate, orderForm + `cart_item_count` cookies | |
| 77 | +| `src/sdk/cart/useCartQuery.ts` | `useCart()`, react-query, optimistic mutations, `minicart` graft | |
| 78 | +| `src/sdk/cart/config.ts` | `cartConfig` signal + `setCartConfig` | |
| 79 | +| `src/loaders/minicart.tsx` | `MinicartConfig` + identity loader + `Preview` | |
| 80 | +| `src/components/miniCart/common/Cart.tsx` | Drawer body, empty state, shelf slot, micro-skeletons | |
| 81 | +| `src/components/miniCart/vtex/Cart.tsx` | Adapter: `minicart.storefront` → BaseCart props | |
| 82 | +| `src/components/miniCart/AddedToCartToast.tsx` | Toast island (photo, price, type, message) | |
| 83 | +| `src/components/Header/Drawers.tsx` | Hosts drawer + toast; **subscribes display signals via `useSignalValue`** | |
| 84 | +| `src/components/Header/Header.tsx` | Publishes config via `setCartConfig`, passes to Drawers | |
| 85 | +| `src/components/Header/Buttons/Cart/{common,vtex}.tsx` | Badge from cookie + hover prefetch | |
| 86 | +| `src/components/Product/AddToCartButton/{common,vtex}.tsx` | Optimistic toast/drawer + real product `image` prop | |
| 87 | + |
| 88 | +## Gotchas (These Cost the Most Time) |
| 89 | + |
| 90 | +### 1. Signal Reactivity (Preact → React) |
| 91 | +Reading `signal.value` directly in render does NOT re-render a React component (unlike @preact/signals). |
| 92 | + |
| 93 | +**Symptom:** Drawer "does not open" — the click fires (analytics logs) and sets `displayCart.value=true`, but nothing re-renders. |
| 94 | + |
| 95 | +**FIX:** Subscribe with `useSignalValue(sig)` (useSyncExternalStore) for every render-time read of a module signal (`displayCart`, `cartConfig`, `cartToast`). Writes in handlers stay `sig.value = x`. |
| 96 | + |
| 97 | +### 2. Optimistic Toast Timing |
| 98 | +Fire the toast / open the drawer BEFORE `await onAddItem()`, not after — otherwise feedback is delayed by the server round-trip and never shows if the mutation rejects. The mutation carries its own optimistic patch + rollback. |
| 99 | + |
| 100 | +### 3. Toast Photo |
| 101 | +`mapProductToAnalyticsItem` gives `item_url` (product page URL), NOT an image. Thread the real image (`product.image?.[0]?.url`) into the add button and use it for the toast. |
| 102 | + |
| 103 | +### 4. Directory Casing (macOS vs Linux CI) |
| 104 | +The git index may hold `minicart`/`ui` lowercase while the macOS working tree shows `miniCart`/`UI`. Import using git-indexed casing (`~/components/minicart/...`) or Linux CI + `tsc` (TS1149/TS1261) breaks. Check with `git ls-files | grep -i <path>`. |
| 105 | + |
| 106 | +### 5. CMS Codegen Migration Blocker (7.20+ Bump) |
| 107 | +After bumping to `@decocms/*@7.20+`, the generators (`@decocms/blocks-cli`) write to `.deco/` in a NEW format, but a repo migrated earlier still consumes OLD-format files in `src/server/{cms,admin}/` (from `@decocms/start`). Running the generators does NOT update what the app reads. |
| 108 | + |
| 109 | +**Consequence:** New CMS props (`cart.config`, toast toggle) are code-ready but NOT admin-editable until the repo does the codegen migration (switch `setup.ts` importers to `.deco/` OR regenerate all artifacts consistently). |
| 110 | + |
| 111 | +**Mitigation:** Design runtime defaults so the site behaves correctly WITHOUT any CMS config (e.g. `autoOpenOnAdd: false` → toast active, `freeShippingTarget: 500`). Then editability lands for free once the migration runs. |
| 112 | + |
| 113 | +## Verification Checklist |
| 114 | + |
| 115 | +- **Types:** `npx tsc --noEmit` — compare error COUNT to a pre-change baseline. Zero NEW errors is the bar. |
| 116 | +- **Browser (with dev server):** |
| 117 | + - F5 with an existing cart cookie → **no** `orderForm` POST. |
| 118 | + - Drawer opens cookieless → empty state, **zero** API calls. |
| 119 | + - Add-to-cart → orderForm created once; with toast ON → toast (photo/price/type), drawer stays closed; with toast OFF → drawer opens. |
| 120 | + - Change quantity → skeleton only on that line + total, rest stable (no layout shift). |
| 121 | + - Hover on icon with cookie → prefetch cart before click. |
| 122 | +- **SSR check:** `curl -s localhost:PORT/ | grep data-qa-minicart` returns nothing (drawer body must not be in SSR HTML). |
| 123 | +- **Admin (post-codegen-migration):** Edit Minicart config (free-shipping threshold, coupon toggle, toast label), see shelf composability work, Preview reflects changes. |
0 commit comments