From aed1d8c7d0fe5d2c7bb43960a0b4f7f27fb3acfd Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 15 Jul 2026 21:54:10 -0300 Subject: [PATCH] =?UTF-8?q?feat(apps-vtex):=20Cart=20v2=20=E2=80=94=20modu?= =?UTF-8?q?lar,=20granular,=20framework-agnostic=20cart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a two-dimension fragmentation system for the VTEX cart: - `sections` (expectedOrderFormSections) — limits what VTEX computes - `projection` (none/summary/summary+items/minicart/raw) — limits what goes to the browser Key outcomes vs the legacy cart: - Lazy: no OrderForm created until first add-to-cart (eliminates the eager mount-time VTEX call for browsing-only visitors) - Default add-to-cart sends only SECTIONS_MINIMAL (3 sections instead of 15) and returns a slim `{ totalItems, total, items:[slim] }` to the browser - Optimistic badge bump + server reconciliation built into the hook; storefront never re-implements this logic New files: - packages/apps-commerce/src/types/cart.ts — agnostic contract (CartSection, CartProjection, CartSummary, CartItemSlim, SECTIONS_MINIMAL/DRAWER/FULL) - packages/apps-vtex/src/utils/cartProjection.ts — projectOrderForm() - packages/apps-vtex/src/loaders/cart/{summary,full,shipping,gifts,attachments} - packages/apps-vtex/src/hooks/createCart.ts — factory (useCart, useCartSummary, useAddToCart, useShipping, useGifts, useAttachments, resetCart) - packages/apps-vtex/src/hooks/cartQuery.ts — optional TanStack Query adapter with 6 hooks (useShipping keyed by {postalCode,items} for client-side cache) - .agents/skills/vtex-cart-v2/SKILL.md — full usage, projection decision guide, gradual migration guide Modified: - actions/checkout.ts: addItemsToCartV2/updateCartItemsV2/addCouponToCartV2/ getOrCreateCartV2 + unified DEFAULT_EXPECTED_SECTIONS from SECTIONS_FULL - invoke.ts: v2 action entries for generate-invoke TanStack createServerFn - manifest.gen.ts: 5 new vtex/loaders/cart/* entries - apps-vtex package.json: @tanstack/react-query as optional peerDep add() returns the projected payload so callers can drive a toast without a second fetch; updateQuantity/removeItem/addCoupon return the projected Minicart. Co-Authored-By: Claude Sonnet 4.6 --- .agents/skills/vtex-cart-v2/SKILL.md | 410 +++++++++ bun.lock | 5 + packages/apps-commerce/package.json | 1 + packages/apps-commerce/src/types/cart.ts | 169 ++++ packages/apps-commerce/src/types/commerce.ts | 5 + packages/apps-vtex/package.json | 7 + packages/apps-vtex/src/actions/checkout.ts | 813 ++++++++++-------- .../hooks/__tests__/createCart.render.test.ts | 114 +++ .../src/hooks/__tests__/createCart.test.ts | 92 ++ packages/apps-vtex/src/hooks/cartQuery.ts | 204 +++++ packages/apps-vtex/src/hooks/createCart.ts | 427 +++++++++ packages/apps-vtex/src/hooks/index.ts | 6 + packages/apps-vtex/src/invoke.ts | 330 ++++--- .../apps-vtex/src/loaders/cart/attachments.ts | 53 ++ packages/apps-vtex/src/loaders/cart/full.ts | 67 ++ packages/apps-vtex/src/loaders/cart/gifts.ts | 43 + .../apps-vtex/src/loaders/cart/orderFormId.ts | 31 + .../apps-vtex/src/loaders/cart/shipping.ts | 73 ++ .../apps-vtex/src/loaders/cart/summary.ts | 36 + packages/apps-vtex/src/manifest.gen.ts | 86 +- .../utils/__tests__/cartProjection.test.ts | 118 +++ .../apps-vtex/src/utils/cartProjection.ts | 96 +++ 22 files changed, 2677 insertions(+), 509 deletions(-) create mode 100644 .agents/skills/vtex-cart-v2/SKILL.md create mode 100644 packages/apps-commerce/src/types/cart.ts create mode 100644 packages/apps-vtex/src/hooks/__tests__/createCart.render.test.ts create mode 100644 packages/apps-vtex/src/hooks/__tests__/createCart.test.ts create mode 100644 packages/apps-vtex/src/hooks/cartQuery.ts create mode 100644 packages/apps-vtex/src/hooks/createCart.ts create mode 100644 packages/apps-vtex/src/loaders/cart/attachments.ts create mode 100644 packages/apps-vtex/src/loaders/cart/full.ts create mode 100644 packages/apps-vtex/src/loaders/cart/gifts.ts create mode 100644 packages/apps-vtex/src/loaders/cart/orderFormId.ts create mode 100644 packages/apps-vtex/src/loaders/cart/shipping.ts create mode 100644 packages/apps-vtex/src/loaders/cart/summary.ts create mode 100644 packages/apps-vtex/src/utils/__tests__/cartProjection.test.ts create mode 100644 packages/apps-vtex/src/utils/cartProjection.ts diff --git a/.agents/skills/vtex-cart-v2/SKILL.md b/.agents/skills/vtex-cart-v2/SKILL.md new file mode 100644 index 00000000..624c5d44 --- /dev/null +++ b/.agents/skills/vtex-cart-v2/SKILL.md @@ -0,0 +1,410 @@ +--- +name: vtex-cart-v2 +description: Cart v2 for VTEX storefronts — modular, granular, framework-agnostic. Explains the thesis (reduce cart API traffic, lazy cart creation, projection/sections fragmentation), the available hooks and loaders, and how to wire them in a Next.js or TanStack Start site. +--- + +# VTEX Cart v2 — Modular, Granular, Framework-Agnostic + +## The problem with the legacy cart + +The legacy VTEX cart (`loaders/cart.ts`, `hooks/useCart.ts`, `hooks/createUseCart.ts`) has three structural inefficiencies: + +1. **Every mutation returns the full OrderForm.** All 15 `expectedOrderFormSections` are hardcoded. Adding one item, changing a quantity, or applying a coupon all return the same ~40 KB payload — even when the UI only needs to update a badge number. + +2. **Cart created on page load for every visitor.** `createUseCart` calls `getOrCreateCart` inside a `useEffect` on mount. A VTEX OrderForm is provisioned for ~70–90% of visitors who never click "add to cart". This is a real cost: the VTEX Checkout API creates a session, writes to their order-management store, and starts tracking an order — for a user who is just browsing. + +3. **No granularity, no cache opportunity.** There is no way to ask "just the gifts", "just the drawer shipping options", or "just the coupon fields". Because all data is fetched together and from the browser (with `credentials: "include"`), there is no server-side projection layer and nothing to cache. + +## The Cart v2 thesis + +**Two independent knobs per operation:** + +- **`sections`** — what you ask VTEX to compute (`expectedOrderFormSections`). Fewer sections = smaller VTEX payload + less server-side compute. +- **`projection`** — what the server sends to the browser. Independent of `sections`. You can request a full VTEX OrderForm and project only the badge totals, or request `SECTIONS_MINIMAL` and project the full drawer. + +**Default: the minimum.** Every hook and loader defaults to the cheapest option. Requesting more is always explicit (opt-in, not opt-out). + +**Lazy cart creation.** No OrderForm is provisioned until the first add-to-cart. A visitor reading product pages generates exactly zero calls to `/api/checkout/pub/orderForm`. + +**Optimistic updates + reconciliation in the hook.** The storefront never re-implements debounce, rollback, or server-reconciliation. The badge increments immediately and reconciles from the projected server response. + +--- + +## Contract (platform-agnostic, in `@decocms/apps-commerce`) + +```ts +import type { CartProjection, CartSection, CartSummary, CartSummaryWithItems, CartOk, CartItemSlim } from "@decocms/apps-commerce/types/cart"; +import { SECTIONS_MINIMAL, SECTIONS_DRAWER, SECTIONS_FULL, defaultSectionsFor } from "@decocms/apps-commerce/types/cart"; +``` + +### `CartProjection` + +| Value | What goes to the browser | When to use | +|---|---|---| +| `"none"` | `{ ok: true }` | Pure optimistic update, zero reconciliation data needed | +| `"summary"` | `{ orderFormId, totalItems, total }` | Badge-only refresh | +| `"summary+items"` | summary + slim line items | **Default for add-to-cart** | +| `"minicart"` | Full canonical `Minicart` | Opening the drawer | +| `"raw"` | Untouched VTEX OrderForm | GTM, pixels, custom integrations | + +### `CartSection` presets + +| Preset | Sections | Use | +|---|---|---| +| `SECTIONS_MINIMAL` | `items, totalizers, messages` | All mutations by default | +| `SECTIONS_DRAWER` | 9 sections (+ sellers, marketing, shipping…) | `cart/full` loader | +| `SECTIONS_FULL` | All 15 | Legacy parity / `projection: "raw"` | + +`defaultSectionsFor(projection)` returns the right preset if you don't specify sections explicitly. + +### Choosing the projection + +The projection is a latency-vs-data trade-off. Pick the cheapest one that carries what the UI actually renders — the whole point is not shipping data you won't use. + +| I want to… | `projection` | Why | +|---|---|---| +| Just update the badge number | `"summary"` | Smallest payload that still carries the count | +| Show a toast "added: ``" | `"summary+items"` *(default)* | Slim item (name/image/price/variant) already comes back from `add()` — no second fetch | +| Open the drawer right after add | `"minicart"` | Populates the drawer from the same response — avoids a follow-up `cart/full` round-trip | +| 100% optimistic UI, no confirmation | `"none"` | Discards the payload server-side; zero reconciliation | +| Feed GTM / a pixel / a custom integration | `"raw"` | Untouched OrderForm — you map it yourself | + +Rule of thumb: **badge → `summary`, toast → `summary+items`, drawer → `minicart`.** Only reach for `raw` when a third-party integration needs the native VTEX shape. + +--- + +## Loaders (read-path) + +Five loaders, each requesting only the sections it needs. All registered in the app manifest (`vtex/loaders/cart/*`) and callable via `invoke` in both frameworks. + +### `vtex/loaders/cart/summary` — badge + +```ts +// Returns CartSummary: { orderFormId, totalItems, total } +// No VTEX call if there is no orderForm cookie. +await invoke.vtex.loaders.cart.summary({ data: { orderFormId?: string } }); +``` + +Use case: SSR-hydrating the cart badge on first load. If the cookie is absent, returns `{ orderFormId: null, totalItems: 0, total: 0 }` without hitting VTEX. + +### `vtex/loaders/cart/full` — drawer + +```ts +// Returns Minicart +// Requests SECTIONS_DRAWER only (9 sections, not 15). +await invoke.vtex.loaders.cart.full({ + data: { + orderFormId?: string; + freeShippingTarget?: number; + locale?: string; + checkoutHref?: string; + enableCoupon?: boolean; + } +}); +``` + +### `vtex/loaders/cart/shipping` — shipping estimate for the drawer + +```ts +// Returns CartShipping: { postalCode, options: ShippingOption[] } +// Prices in major units. SLAs deduplicated across all line items. +await invoke.vtex.loaders.cart.shipping({ + data: { + items: Array<{ id: string | number; quantity: number; seller: string }>; + postalCode: string; + country?: string; + } +}); +``` + +> **Note on caching**: shipping options for a fixed `{ items, postalCode }` are not user-personalized, but `simulateCart` is a POST that rotates cookies. A bespoke cache layer is tracked at [GitHub issue #373](https://github.com/decocms/blocks/issues/373) — caching is not implemented yet. + +### `vtex/loaders/cart/gifts` — selectable gifts / promotions + +```ts +// Returns CartGifts: { orderFormId, selectableGifts, ratesAndBenefits } +// Requests only ["items", "ratesAndBenefitsData", "messages"]. +await invoke.vtex.loaders.cart.gifts({ data: { orderFormId?: string } }); +``` + +### `vtex/loaders/cart/attachments` — item attachments + +```ts +// Returns CartItemAttachments: { orderFormId, itemIndex, attachments, attachmentOfferings } +// Requests only ["items"]. +await invoke.vtex.loaders.cart.attachments({ data: { orderFormId?: string; itemIndex?: number } }); +``` + +--- + +## Actions v2 (write-path) + +Four cart actions with explicit `sections` + `projection` params, exported alongside the legacy v1 actions (zero breaking changes). + +```ts +import { + getOrCreateCartV2, + addItemsToCartV2, + updateCartItemsV2, + addCouponToCartV2, +} from "@decocms/apps-vtex/actions/checkout"; +``` + +Each accepts: + +```ts +interface CartV2Options { + sections?: CartSection[]; // default: SECTIONS_MINIMAL + projection?: CartProjection; // default: "summary+items" + minicartOptions?: ProjectOrderFormOptions; // freeShippingTarget, locale, etc. +} +``` + +VTEX always returns an OrderForm from mutation endpoints. The action sends `expectedOrderFormSections` to limit what VTEX computes, then calls `projectOrderForm` server-side before returning to the browser. The browser never sees raw VTEX data unless `projection: "raw"` is requested. + +--- + +## Hooks (client-side factory) + +### Setup — `createCart` + +Create the hooks once per site, inject the `invoke` proxy: + +```ts +// src/hooks/cart.ts +import { createCart } from "@decocms/apps-vtex/hooks/createCart"; +import { invoke } from "~/server/invoke"; // your generated TanStack / Next.js invoke + +export const { + useCart, useCartSummary, useAddToCart, useShipping, useGifts, useAttachments, resetCart, +} = createCart({ invoke }); +``` + +Optional params: + +```ts +createCart({ + invoke, + orderFormCookieName?: string, // default: "checkout.vtex.com__orderFormId" + orderFormCookieMaxAge?: number, // default: 7 days in seconds +}); +``` + +Each call to `createCart` returns a **new module-singleton**: hooks returned from the same call share state, hooks from different calls are isolated. Call once per site. + +--- + +### `useCartSummary()` — badge + +Reads local state. **Never triggers a VTEX call by itself.** + +```tsx +function CartBadge() { + const { totalItems, loading } = useCartSummary(); + return {loading ? "…" : totalItems}; +} +``` + +--- + +### `useAddToCart(opts?)` — add to cart with built-in optimistic update + +```tsx +function BuyButton({ id, seller }: { id: string; seller: string }) { + const { add, loading } = useAddToCart(); + // default projection: "summary+items" + + return ( + + ); +} +``` + +What happens on `add(...)`: +1. **Optimistic**: badge counter incremented immediately. +2. `ensureOrderFormId()` — checks cookie/state; calls `getOrCreateCartV2` only if no cart exists yet (lazy). +3. `addItemsToCartV2` called with `SECTIONS_MINIMAL` + the requested `projection`. +4. **Reconcile**: projected server response updates badge (or full minicart if `projection: "minicart"`). +5. On error: optimistic increment rolled back and the error re-thrown. + +**`add()` returns the projected payload** — so you can drive a toast / analytics without a second fetch. With the default `summary+items`, the returned object is `{ totalItems, total, items: [slim] }`: + +```tsx +const { add } = useAddToCart(); // default "summary+items" + +async function onClick() { + const res = await add({ id, seller }); // res: { totalItems, total, items: [{ item_name, image, price, item_variant, quantity }] } + const added = res.items?.[0]; + if (added) toast(`Added: ${added.item_name}`, { image: added.image }); +} +``` + +The drawer mutations (`updateQuantity`, `removeItem`, `addCoupon` on `useCart`) likewise return the projected `Minicart`. + +**Custom projection:** + +```ts +// Open the minicart drawer immediately after add — request full drawer data: +const { add } = useAddToCart({ projection: "minicart" }); + +// Pure optimistic, discard server data entirely: +const { add } = useAddToCart({ projection: "none" }); +``` + +--- + +### `useCart(opts?)` — drawer / full cart + +```tsx +function MiniCart({ open }: { open: boolean }) { + const { minicart, summary, loading, updateQuantity, removeItem, addCoupon } = useCart({ + include: { full: open }, // only fetches the full cart when the drawer is open + freeShippingTarget: 15000, + locale: "pt-BR", + checkoutHref: "/checkout", + enableCoupon: true, + }); + + // ... +} +``` + +- `include.full: false` (default) — only summary is available; no VTEX call. +- `include.full: true` — triggers `cart/full` once (cancelled on unmount). Subsequent renders use cached `_minicart`. +- `updateQuantity(index, qty)` and `removeItem(index)` call `updateCartItemsV2` with `projection: "minicart"` and reconcile the drawer. +- `addCoupon(text)` calls `addCouponToCartV2` and reconciles. + +--- + +### `useShipping()` — on-demand shipping estimate + +```tsx +function ShippingEstimate() { + const { estimate } = useShipping(); + + async function handlePostalCode(postalCode: string) { + const result = await estimate({ items, postalCode }); + setOptions(result.options); + } + // ... +} +``` + +--- + +### `useGifts()` — selectable gifts + +```tsx +function GiftSelector() { + const { load } = useGifts(); + useEffect(() => { load().then(setGifts); }, []); + // ... +} +``` + +--- + +### `useAttachments()` — single-line attachments + +On-demand read of one cart line's attachments + offered slots (engraving, gift wrap, …). Fetches only `["items"]`. + +```tsx +function ItemCustomizer({ itemIndex }: { itemIndex: number }) { + const { load } = useAttachments(); + useEffect(() => { load(itemIndex).then(setAttachments); }, [itemIndex]); + // load(itemIndex) → { orderFormId, itemIndex, attachments, attachmentOfferings } +} +``` + +--- + +### `resetCart()` — after logout or order placed + +```ts +import { resetCart } from "~/hooks/cart"; +resetCart(); // clears module-singleton state + notifies all subscribers +``` + +--- + +## Optional: TanStack Query adapter + +For sites already using `@tanstack/react-query`, the factory-less adapter provides the same API wired into a QueryClient: + +```ts +import { createCartQuery } from "@decocms/apps-vtex/hooks/cartQuery"; +import { invoke } from "~/server/invoke"; + +export const { + useCartSummary, useCartFull, useAddToCart, useShipping, useGifts, useAttachments, +} = createCartQuery({ invoke }); +``` + +Full parity with the factory — six hooks. Key differences vs `createCart`: +- `useCartSummary`, `useCartFull`, `useGifts`, `useAttachments(itemIndex)` return standard `useQuery` results with `enabled: false` by default — lazy, opt-in per call. +- `useShipping({ items, postalCode })` is a `useQuery` **keyed by `{ postalCode, items }`** with a 5 min `staleTime`. Because shipping options are not user-personalized, this gives you client-side cache + dedupe for free — the caching the server-side loader can't do yet (see [issue #373](https://github.com/decocms/blocks/issues/373)). `enabled` turns on automatically when a postal code + items are present. +- `useAddToCart` returns a standard `useMutation` with `onMutate` optimistic bump + `onError` rollback + `onSuccess` reconciliation (badge, or `FULL_KEY` cache when `projection: "minicart"`). +- Requires `QueryClientProvider` in the tree. + +Import this adapter only if you have `@tanstack/react-query` in your site — it is declared as an optional peer dependency in `@decocms/apps-vtex`. + +--- + +## Wiring in TanStack Start + +The four v2 actions (`getOrCreateCartV2`, `addItemsToCartV2`, `updateCartItemsV2`, `addCouponToCartV2`) are already declared in `packages/apps-vtex/src/invoke.ts`. After `bun link` / installing the package, run the invoke generator to emit the site-local `createServerFn` bindings: + +```bash +npm run generate:invoke +# or, if using the unified orchestrator: +npm run generate +``` + +The generated handler automatically calls `forwardResponseCookies()`, so the VTEX `checkout.vtex.com` and `CheckoutOrderFormOwnership` cookies reach the browser — the cart stays linked to the right OrderForm. + +## Wiring in Next.js + +Loaders and actions are called through `handleInvoke` (mounted at `app/deco/[[...deco]]/route.ts`). No extra generator step: `invoke.vtex.loaders.cart.*` and `invoke.vtex.actions.*V2` resolve via the manifest registered in `setupApps`. Cookie forwarding is handled by `vtexFetchWithCookies` inside each action. + +--- + +## Migrating gradually from the legacy cart + +Cart v2 is **additive** — the legacy `useCart` / `createUseCart` keep working untouched. But the two systems hold **independent state**: `createCart` is its own module-singleton and does not share the badge count, cart cookie read timing, or listeners with `createUseCart`. During the transition, keep **one source of truth for the badge** — don't let a v1 badge and a v2 badge run side by side, or they will diverge (v1 creates a cart on mount and counts eagerly; v2 is lazy). + +Recommended order, component by component: + +1. **Badge + add-to-cart together** → `useCartSummary` + `useAddToCart`. Migrate these as a pair: the badge's source of truth must be the same singleton that `add()` reconciles into. This is also where you get the biggest win — the eager on-mount cart creation disappears. +2. **Drawer** → `useCart({ include: { full: open } })`. Replace the legacy drawer's `fetchCart`/`getOrCreateCart` reads. `updateQuantity` / `removeItem` / `addCoupon` return the projected `Minicart`. +3. **On-demand extras** → `useShipping`, `useGifts`, `useAttachments`. These had no legacy equivalent as separate reads; wire them where the drawer previously pulled everything at once. + +Until every add-to-cart entry point is on v2, do not delete the legacy hooks — a mixed page (v1 PDP button + v2 badge) will show a stale count because the two singletons don't notify each other. + +--- + +## Traffic impact summary + +| Scenario (legacy) | VTEX API calls | Payload | +|---|---|---| +| Visitor lands, no add | 1 `getOrCreateCart` on mount | ~40 KB OrderForm | +| Add to cart | 1 `addItems` with 15 sections | ~40 KB OrderForm | +| Open drawer | 1 `getCartFull` with 15 sections | ~40 KB OrderForm | + +| Scenario (Cart v2) | VTEX API calls | Payload to browser | +|---|---|---| +| Visitor lands, no add | **0** | 0 | +| Add to cart (default) | 1 `addItemsToCartV2` with **3 sections** | `{ totalItems, total, items:[slim] }` — ~1 KB | +| Open drawer | 1 `cart/full` with **9 sections** | Full Minicart — ~10 KB | +| Shipping estimate (after caching) | 0 (cache hit) | `{ postalCode, options }` — ~1 KB | + +--- + +## Constraints and footguns + +- **`vtexFetchWithCookies` is mandatory for all cart mutations.** `vtexFetch` / `vtexCachedFetch` must not be used — they do not rotate `checkout.vtex.com` / `CheckoutOrderFormOwnership` cookies, causing the storefront's cart to drift from VTEX's server-side state. +- **Shipping simulation is not cached yet.** `simulateCart` is a POST that rotates cookies — a bespoke cache layer is required. Tracked at [issue #373](https://github.com/decocms/blocks/issues/373). +- **`createCart` is a factory; call it once.** Each call produces an independent module-singleton. Calling it inside a component creates a new singleton per render — always call at module scope. +- **`projection: "none"` + `projection: "minicart"` in the same add**: pick one. `"none"` discards the server response; `"minicart"` uses it to populate the drawer. They cannot be combined. +- The legacy `useCart`, `createUseCart`, `loaders/cart.ts`, and `loaders/minicart.ts` are untouched. Cart v2 is additive — migrate gradually per component. diff --git a/bun.lock b/bun.lock index 80706e63..1783d8de 100644 --- a/bun.lock +++ b/bun.lock @@ -195,15 +195,20 @@ "@decocms/tanstack": "workspace:*", }, "devDependencies": { + "@tanstack/react-query": "^5.96.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "knip": "^5.86.0", "typescript": "^5.9.0", }, "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", }, + "optionalPeers": [ + "@tanstack/react-query", + ], }, "packages/apps-website": { "name": "@decocms/apps-website", diff --git a/packages/apps-commerce/package.json b/packages/apps-commerce/package.json index 930be2c7..2905df8d 100644 --- a/packages/apps-commerce/package.json +++ b/packages/apps-commerce/package.json @@ -11,6 +11,7 @@ "main": "./src/types/commerce.ts", "exports": { "./types": "./src/types/commerce.ts", + "./types/cart": "./src/types/cart.ts", "./app-types": "./src/app-types.ts", "./resolve": "./src/resolve.ts", "./manifest-utils": "./src/manifest-utils.ts", diff --git a/packages/apps-commerce/src/types/cart.ts b/packages/apps-commerce/src/types/cart.ts new file mode 100644 index 00000000..1169f0d3 --- /dev/null +++ b/packages/apps-commerce/src/types/cart.ts @@ -0,0 +1,169 @@ +/** + * Cart v2 — platform-agnostic fragmentation contract. + * + * Two independent dimensions govern every cart operation: + * + * 1. `sections` — WHAT WE ASK THE PLATFORM FOR. Maps to VTEX's + * `expectedOrderFormSections`; other platforms map it to their own + * partial-response mechanism. Controls the upstream payload + compute. + * 2. `projection` — WHAT THE SERVER RETURNS TO THE CLIENT. Independent of + * `sections`: we can ask the platform for the full cart yet project a slim + * `{ totalItems, total }` down the wire, or vice-versa. + * + * The whole point is "default to the minimum, everything opt-in". A mutation + * that only needs to bump a badge should not ship the entire cart to the + * browser, and a page view where the user never clicked "add" should not hit + * the cart API at all. + * + * This module is deliberately platform-neutral — VTEX is the first + * implementation (see `@decocms/apps/vtex/utils/cartProjection`), Shopify/Wake + * reuse the same contract. Only types + presets live here; the actual + * OrderForm→projection mapping is platform-specific. + */ + +import type { MinicartItem } from "./commerce"; + +/** + * A VTEX OrderForm section (the canonical superset). Other platforms map the + * subset they support; unknown strings are allowed so a platform can request a + * section this union doesn't yet name without a type error. + */ +export type CartSection = + | "items" + | "totalizers" + | "clientProfileData" + | "shippingData" + | "paymentData" + | "sellers" + | "messages" + | "marketingData" + | "clientPreferencesData" + | "storePreferencesData" + | "giftRegistryData" + | "ratesAndBenefitsData" + | "openTextField" + | "commercialConditionData" + | "customData" + | (string & {}); + +/** + * Shape of the response the server sends to the client after a cart read or + * mutation. Ordered from cheapest to richest: + * + * - `none` — `{ ok: true }`. The platform payload is discarded server-side. + * Use when the client updates optimistically and needs no confirmation data. + * - `summary` — `{ orderFormId, totalItems, total }`. Enough for a badge. + * - `summary+items` — summary plus the slim line items (the **default** for + * add-to-cart: the platform returns the items regardless, so we forward a + * trimmed view — name, image, price, chosen variant, quantity). + * - `minicart` — the full canonical `Minicart` (drawer view). + * - `raw` — the untouched platform cart (escape hatch for platform-specific + * reads: GTM, pixels, custom integrations). + */ +export type CartProjection = "none" | "summary" | "summary+items" | "minicart" | "raw"; + +/** Minimal, cacheable-shaped cart totals for a badge. All money in major units. */ +export interface CartSummary { + orderFormId: string | null; + /** Sum of line quantities. `0` when there is no cart yet. */ + totalItems: number; + /** Total payable, in major units. */ + total: number; +} + +/** + * A trimmed cart line — the "at least name, image, price, chosen size" the + * user asked for. Structurally a subset of `MinicartItem` so it forwards + * straight to analytics and drawer UIs without re-mapping. + */ +export interface CartItemSlim { + item_id?: string; + item_name?: string; + item_variant?: string; + image: string; + /** Selling price per unit, in major units. */ + price: number; + quantity: number; +} + +/** `projection: "none"` result. */ +export interface CartOk { + ok: true; +} + +/** `projection: "summary+items"` result — the add-to-cart default. */ +export interface CartSummaryWithItems extends CartSummary { + items: CartItemSlim[]; +} + +/** + * Discriminated union of every projection result, so callers can narrow on the + * `projection` they requested. `minicart`/`raw` payload types are supplied by + * the platform binding via the generic params. + */ +export type CartProjectionResult = + | CartOk + | CartSummary + | CartSummaryWithItems + | TMinicart + | TRaw; + +// --------------------------------------------------------------------------- +// Section presets — the "which sections per operation" defaults. +// --------------------------------------------------------------------------- + +/** + * Smallest useful set for a mutation: line items + totals, plus `messages` so + * stock/availability errors surface. This is the add-to-cart default and the + * single biggest lever for shrinking both the VTEX payload and its compute. + */ +export const SECTIONS_MINIMAL: CartSection[] = ["items", "totalizers", "messages"]; + +/** Everything a drawer/minicart renders: totals, coupon, shipping, sellers. */ +export const SECTIONS_DRAWER: CartSection[] = [ + "items", + "totalizers", + "messages", + "marketingData", + "sellers", + "ratesAndBenefitsData", + "storePreferencesData", + "clientPreferencesData", + "shippingData", +]; + +/** The full superset — parity with the legacy hardcoded default. */ +export const SECTIONS_FULL: CartSection[] = [ + "items", + "totalizers", + "clientProfileData", + "shippingData", + "paymentData", + "sellers", + "messages", + "marketingData", + "clientPreferencesData", + "storePreferencesData", + "giftRegistryData", + "ratesAndBenefitsData", + "openTextField", + "commercialConditionData", + "customData", +]; + +/** Default sections per projection — used when a caller passes a projection but no explicit sections. */ +export function defaultSectionsFor(projection: CartProjection): CartSection[] { + switch (projection) { + case "none": + case "summary": + case "summary+items": + return SECTIONS_MINIMAL; + case "minicart": + return SECTIONS_DRAWER; + case "raw": + return SECTIONS_FULL; + } +} + +/** Re-export so consumers can build slim items against the canonical type. */ +export type { MinicartItem }; diff --git a/packages/apps-commerce/src/types/commerce.ts b/packages/apps-commerce/src/types/commerce.ts index fdb83953..7afdfc4f 100644 --- a/packages/apps-commerce/src/types/commerce.ts +++ b/packages/apps-commerce/src/types/commerce.ts @@ -1226,3 +1226,8 @@ export interface Minicart { postalCode?: string; }; } + +// --------------------------------------------------------------------------- +// Cart v2 — platform-agnostic fragmentation contract (sections + projections). +// --------------------------------------------------------------------------- +export * from "./cart"; diff --git a/packages/apps-vtex/package.json b/packages/apps-vtex/package.json index 64b2fe7b..f2174285 100644 --- a/packages/apps-vtex/package.json +++ b/packages/apps-vtex/package.json @@ -54,10 +54,17 @@ "@decocms/tanstack": "workspace:*" }, "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, + "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + } + }, "devDependencies": { + "@tanstack/react-query": "^5.96.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "knip": "^5.86.0", diff --git a/packages/apps-vtex/src/actions/checkout.ts b/packages/apps-vtex/src/actions/checkout.ts index 5aeadbed..2227cefe 100644 --- a/packages/apps-vtex/src/actions/checkout.ts +++ b/packages/apps-vtex/src/actions/checkout.ts @@ -7,47 +7,47 @@ * @see https://developers.vtex.com/docs/api-reference/checkout-api */ +import { + type CartProjection, + type CartSection, + defaultSectionsFor, + SECTIONS_FULL, +} from "@decocms/apps-commerce/types"; import { getVtexConfig, vtexFetchWithCookies } from "../client"; import type { OrderForm } from "../types"; +import { + type ProjectOrderFormOptions, + projectOrderForm, + type VtexCartProjectionResult, +} from "../utils/cartProjection"; -export const DEFAULT_EXPECTED_SECTIONS = [ - "items", - "totalizers", - "clientProfileData", - "shippingData", - "paymentData", - "sellers", - "messages", - "marketingData", - "clientPreferencesData", - "storePreferencesData", - "giftRegistryData", - "ratesAndBenefitsData", - "openTextField", - "commercialConditionData", - "customData", -]; +/** + * Legacy full-section default. Kept as an exported symbol (loaders/cart.ts and + * older callers import it), but sourced from the agnostic `SECTIONS_FULL` + * preset so the "which sections" list lives in exactly one place. + */ +export const DEFAULT_EXPECTED_SECTIONS: string[] = SECTIONS_FULL; function scParam(): string { - const sc = getVtexConfig().salesChannel; - return sc ? `sc=${sc}` : ""; + const sc = getVtexConfig().salesChannel; + return sc ? `sc=${sc}` : ""; } function appendSc(params: URLSearchParams): URLSearchParams { - const sc = getVtexConfig().salesChannel; - if (sc) params.set("sc", sc); - return params; + const sc = getVtexConfig().salesChannel; + if (sc) params.set("sc", sc); + return params; } function forceHttpsOnAssets(orderForm: OrderForm): OrderForm { - if (!orderForm?.items) return orderForm; - return { - ...orderForm, - items: orderForm.items.map((item: any) => ({ - ...item, - imageUrl: item.imageUrl?.replace(/^http:/, "https:"), - })), - }; + if (!orderForm?.items) return orderForm; + return { + ...orderForm, + items: orderForm.items.map((item: any) => ({ + ...item, + imageUrl: item.imageUrl?.replace(/^http:/, "https:"), + })), + }; } // --------------------------------------------------------------------------- @@ -55,107 +55,107 @@ function forceHttpsOnAssets(orderForm: OrderForm): OrderForm { // --------------------------------------------------------------------------- export interface GetOrCreateCartProps { - orderFormId?: string; + orderFormId?: string; } export async function getOrCreateCart(props: GetOrCreateCartProps): Promise { - const { orderFormId } = props; - const sc = scParam(); - - if (orderFormId) { - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}${sc ? `?${sc}` : ""}`, - ); - return forceHttpsOnAssets(result); - } - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm${sc ? `?${sc}` : ""}`, - { - method: "POST", - body: JSON.stringify({ - expectedOrderFormSections: DEFAULT_EXPECTED_SECTIONS, - }), - }, - ); - return forceHttpsOnAssets(result); + const { orderFormId } = props; + const sc = scParam(); + + if (orderFormId) { + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}${sc ? `?${sc}` : ""}`, + ); + return forceHttpsOnAssets(result); + } + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm${sc ? `?${sc}` : ""}`, + { + method: "POST", + body: JSON.stringify({ + expectedOrderFormSections: DEFAULT_EXPECTED_SECTIONS, + }), + }, + ); + return forceHttpsOnAssets(result); } export interface AddItemsToCartProps { - orderFormId: string; - orderItems: Array<{ - id: string; - seller: string; - quantity: number; - index?: number; - price?: number; - }>; - allowedOutdatedData?: string[]; + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + index?: number; + price?: number; + }>; + allowedOutdatedData?: string[]; } export async function addItemsToCart(props: AddItemsToCartProps): Promise { - const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"] } = props; - const params = appendSc(new URLSearchParams()); - for (const d of allowedOutdatedData) params.append("allowedOutdatedData", d); - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items?${params}`, - { method: "POST", body: JSON.stringify({ orderItems }) }, - ); - return forceHttpsOnAssets(result); + const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"] } = props; + const params = appendSc(new URLSearchParams()); + for (const d of allowedOutdatedData) params.append("allowedOutdatedData", d); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items?${params}`, + { method: "POST", body: JSON.stringify({ orderItems }) }, + ); + return forceHttpsOnAssets(result); } export interface UpdateCartItemsProps { - orderFormId: string; - orderItems: Array<{ index: number; quantity: number }>; - allowedOutdatedData?: string[]; - noSplitItem?: boolean; + orderFormId: string; + orderItems: Array<{ index: number; quantity: number }>; + allowedOutdatedData?: string[]; + noSplitItem?: boolean; } export async function updateCartItems(props: UpdateCartItemsProps): Promise { - const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"], noSplitItem } = props; - const params = appendSc(new URLSearchParams()); - for (const d of allowedOutdatedData) { - params.append("allowedOutdatedData", d); - } - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/update?${params}`, - { - method: "POST", - body: JSON.stringify({ - orderItems, - noSplitItem: Boolean(noSplitItem), - }), - }, - ); - return forceHttpsOnAssets(result); + const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"], noSplitItem } = props; + const params = appendSc(new URLSearchParams()); + for (const d of allowedOutdatedData) { + params.append("allowedOutdatedData", d); + } + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/update?${params}`, + { + method: "POST", + body: JSON.stringify({ + orderItems, + noSplitItem: Boolean(noSplitItem), + }), + }, + ); + return forceHttpsOnAssets(result); } export interface RemoveAllItemsProps { - orderFormId: string; + orderFormId: string; } export async function removeAllItems(props: RemoveAllItemsProps): Promise { - const { orderFormId } = props; - const sc = scParam(); - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/removeAll${sc ? `?${sc}` : ""}`, - { method: "POST", body: JSON.stringify({}) }, - ); - return forceHttpsOnAssets(result); + const { orderFormId } = props; + const sc = scParam(); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/removeAll${sc ? `?${sc}` : ""}`, + { method: "POST", body: JSON.stringify({}) }, + ); + return forceHttpsOnAssets(result); } export interface AddCouponToCartProps { - orderFormId: string; - text: string; + orderFormId: string; + text: string; } export async function addCouponToCart(props: AddCouponToCartProps): Promise { - const { orderFormId, text } = props; - const sc = scParam(); - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/coupons${sc ? `?${sc}` : ""}`, - { method: "POST", body: JSON.stringify({ text }) }, - ); - return forceHttpsOnAssets(result); + const { orderFormId, text } = props; + const sc = scParam(); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/coupons${sc ? `?${sc}` : ""}`, + { method: "POST", body: JSON.stringify({ text }) }, + ); + return forceHttpsOnAssets(result); } // --------------------------------------------------------------------------- @@ -163,35 +163,35 @@ export async function addCouponToCart(props: AddCouponToCartProps): Promise(`/api/checkout/pub/orderForms/simulation?${params}`, { - method: "POST", - body: JSON.stringify({ - items, - postalCode, - country: country ?? config.country ?? "BRA", - }), - }); + const { items, postalCode, country, RnbBehavior = 1 } = props; + const config = getVtexConfig(); + const params = appendSc(new URLSearchParams({ RnbBehavior: String(RnbBehavior) })); + // Uses vtexFetchWithCookies so any Set-Cookie VTEX returns on the + // orderForm-scoped simulation reaches the browser via RequestContext. + // Without this, the segment/ownership cookies VTEX may rotate during + // simulation are dropped, and the storefront's local orderFormId + // drifts away from VTEX's checkout.vtex.com server cookie. + return vtexFetchWithCookies(`/api/checkout/pub/orderForms/simulation?${params}`, { + method: "POST", + body: JSON.stringify({ + items, + postalCode, + country: country ?? config.country ?? "BRA", + }), + }); } // --------------------------------------------------------------------------- @@ -199,55 +199,55 @@ export async function simulateCart(props: SimulateCartProps) { // --------------------------------------------------------------------------- export interface AddOfferingProps { - orderFormId: string; - itemIndex: number; - offeringId: string | number; - expectedOrderFormSections?: string[]; + orderFormId: string; + itemIndex: number; + offeringId: string | number; + expectedOrderFormSections?: string[]; } export async function addOffering(props: AddOfferingProps): Promise { - const { - orderFormId, - itemIndex, - offeringId, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/offerings`, - { - method: "POST", - body: JSON.stringify({ - expectedOrderFormSections, - id: offeringId, - info: null, - }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + itemIndex, + offeringId, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/offerings`, + { + method: "POST", + body: JSON.stringify({ + expectedOrderFormSections, + id: offeringId, + info: null, + }), + }, + ); + return forceHttpsOnAssets(result); } export interface RemoveOfferingProps { - orderFormId: string; - itemIndex: number; - offeringId: string | number; - expectedOrderFormSections?: string[]; + orderFormId: string; + itemIndex: number; + offeringId: string | number; + expectedOrderFormSections?: string[]; } export async function removeOffering(props: RemoveOfferingProps): Promise { - const { - orderFormId, - itemIndex, - offeringId, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/offerings/${offeringId}/remove`, - { - method: "POST", - body: JSON.stringify({ expectedOrderFormSections }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + itemIndex, + offeringId, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/offerings/${offeringId}/remove`, + { + method: "POST", + body: JSON.stringify({ expectedOrderFormSections }), + }, + ); + return forceHttpsOnAssets(result); } // --------------------------------------------------------------------------- @@ -255,94 +255,94 @@ export async function removeOffering(props: RemoveOfferingProps): Promise; - expectedOrderFormSections?: string[]; + orderFormId: string; + attachment: string; + body: Record; + expectedOrderFormSections?: string[]; } export async function updateOrderFormAttachment( - props: UpdateOrderFormAttachmentProps, + props: UpdateOrderFormAttachmentProps, ): Promise { - const { - orderFormId, - attachment, - body, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - if (!orderFormId) throw new Error("Order form ID is required"); - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/attachments/${attachment}`, - { - method: "POST", - body: JSON.stringify({ expectedOrderFormSections, ...body }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + attachment, + body, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + if (!orderFormId) throw new Error("Order form ID is required"); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/attachments/${attachment}`, + { + method: "POST", + body: JSON.stringify({ expectedOrderFormSections, ...body }), + }, + ); + return forceHttpsOnAssets(result); } export interface UpdateItemAttachmentProps { - orderFormId: string; - itemIndex: number; - attachment: string; - content: Record; - noSplitItem?: boolean; - expectedOrderFormSections?: string[]; + orderFormId: string; + itemIndex: number; + attachment: string; + content: Record; + noSplitItem?: boolean; + expectedOrderFormSections?: string[]; } export async function updateItemAttachment(props: UpdateItemAttachmentProps): Promise { - const { - orderFormId, - itemIndex, - attachment, - content, - noSplitItem = true, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/attachments/${attachment}`, - { - method: "POST", - body: JSON.stringify({ - content, - noSplitItem, - expectedOrderFormSections, - }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + itemIndex, + attachment, + content, + noSplitItem = true, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/attachments/${attachment}`, + { + method: "POST", + body: JSON.stringify({ + content, + noSplitItem, + expectedOrderFormSections, + }), + }, + ); + return forceHttpsOnAssets(result); } export interface RemoveItemAttachmentProps { - orderFormId: string; - itemIndex: number; - attachment: string; - content: Record; - noSplitItem?: boolean; - expectedOrderFormSections?: string[]; + orderFormId: string; + itemIndex: number; + attachment: string; + content: Record; + noSplitItem?: boolean; + expectedOrderFormSections?: string[]; } export async function removeItemAttachment(props: RemoveItemAttachmentProps): Promise { - const { - orderFormId, - itemIndex, - attachment, - content, - noSplitItem = true, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/attachments/${attachment}`, - { - method: "DELETE", - body: JSON.stringify({ - content, - noSplitItem, - expectedOrderFormSections, - }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + itemIndex, + attachment, + content, + noSplitItem = true, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/attachments/${attachment}`, + { + method: "DELETE", + body: JSON.stringify({ + content, + noSplitItem, + expectedOrderFormSections, + }), + }, + ); + return forceHttpsOnAssets(result); } // --------------------------------------------------------------------------- @@ -350,17 +350,17 @@ export async function removeItemAttachment(props: RemoveItemAttachmentProps): Pr // --------------------------------------------------------------------------- export interface UpdateItemPriceProps { - orderFormId: string; - itemIndex: number; - price: number; + orderFormId: string; + itemIndex: number; + price: number; } export async function updateItemPrice(props: UpdateItemPriceProps): Promise { - const { orderFormId, itemIndex, price } = props; - return vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/price`, - { method: "PUT", body: JSON.stringify({ price }) }, - ); + const { orderFormId, itemIndex, price } = props; + return vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/${itemIndex}/price`, + { method: "PUT", body: JSON.stringify({ price }) }, + ); } // --------------------------------------------------------------------------- @@ -368,31 +368,31 @@ export async function updateItemPrice(props: UpdateItemPriceProps): Promise; - expectedOrderFormSections?: string[]; + orderFormId: string; + giftId: string; + selectedGifts: Array<{ id: string; seller: string; quantity: number }>; + expectedOrderFormSections?: string[]; } export async function updateSelectableGifts(props: UpdateSelectableGiftsProps): Promise { - const { - orderFormId, - giftId, - selectedGifts, - expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, - } = props; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/selectable-gifts/${giftId}`, - { - method: "POST", - body: JSON.stringify({ - expectedOrderFormSections, - selectedGifts, - id: giftId, - }), - }, - ); - return forceHttpsOnAssets(result); + const { + orderFormId, + giftId, + selectedGifts, + expectedOrderFormSections = DEFAULT_EXPECTED_SECTIONS, + } = props; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/selectable-gifts/${giftId}`, + { + method: "POST", + body: JSON.stringify({ + expectedOrderFormSections, + selectedGifts, + id: giftId, + }), + }, + ); + return forceHttpsOnAssets(result); } // --------------------------------------------------------------------------- @@ -400,17 +400,17 @@ export async function updateSelectableGifts(props: UpdateSelectableGiftsProps): // --------------------------------------------------------------------------- export interface GetInstallmentsProps { - orderFormId: string; - paymentSystem: number; + orderFormId: string; + paymentSystem: number; } export async function getInstallments(props: GetInstallmentsProps) { - const { orderFormId, paymentSystem } = props; - const params = new URLSearchParams({ paymentSystem: String(paymentSystem) }); - appendSc(params); - return vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`, - ); + const { orderFormId, paymentSystem } = props; + const params = new URLSearchParams({ paymentSystem: String(paymentSystem) }); + appendSc(params); + return vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/installments?${params}`, + ); } // --------------------------------------------------------------------------- @@ -418,49 +418,49 @@ export async function getInstallments(props: GetInstallmentsProps) { // --------------------------------------------------------------------------- export interface UpdateOrderFormProfileProps { - orderFormId: string; - fields: Record; - ignoreProfileData?: boolean; + orderFormId: string; + fields: Record; + ignoreProfileData?: boolean; } export async function updateOrderFormProfile( - props: UpdateOrderFormProfileProps, + props: UpdateOrderFormProfileProps, ): Promise { - const { orderFormId, fields, ignoreProfileData } = props; - const body = ignoreProfileData ? { ...fields, ignoreProfileData: true } : fields; - const result = await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/profile`, - { method: "PATCH", body: JSON.stringify(body) }, - ); - return forceHttpsOnAssets(result); + const { orderFormId, fields, ignoreProfileData } = props; + const body = ignoreProfileData ? { ...fields, ignoreProfileData: true } : fields; + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/profile`, + { method: "PATCH", body: JSON.stringify(body) }, + ); + return forceHttpsOnAssets(result); } export interface ChangeToAnonymousUserProps { - orderFormId: string; + orderFormId: string; } export async function changeToAnonymousUser(props: ChangeToAnonymousUserProps): Promise { - const { orderFormId } = props; - // This endpoint rotates the orderForm ownership cookies — must use - // vtexFetchWithCookies so the new cookies reach the browser. - return vtexFetchWithCookies(`/api/checkout/changeToAnonymousUser/${orderFormId}`); + const { orderFormId } = props; + // This endpoint rotates the orderForm ownership cookies — must use + // vtexFetchWithCookies so the new cookies reach the browser. + return vtexFetchWithCookies(`/api/checkout/changeToAnonymousUser/${orderFormId}`); } export interface ClearOrderFormMessagesProps { - orderFormId: string; + orderFormId: string; } export async function clearOrderFormMessages( - props: ClearOrderFormMessagesProps, + props: ClearOrderFormMessagesProps, ): Promise { - const { orderFormId } = props; - return vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/messages/clear`, - { - method: "POST", - body: JSON.stringify({}), - }, - ); + const { orderFormId } = props; + return vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/messages/clear`, + { + method: "POST", + body: JSON.stringify({}), + }, + ); } // --------------------------------------------------------------------------- @@ -468,55 +468,192 @@ export async function clearOrderFormMessages( // --------------------------------------------------------------------------- export interface Seller { - id: string; - name: string; + id: string; + name: string; } export interface RegionResult { - id: string; - sellers: Seller[]; + id: string; + sellers: Seller[]; } export interface GetSellersByRegionProps { - postalCode: string; - salesChannel?: string; + postalCode: string; + salesChannel?: string; } export async function getSellersByRegion( - props: GetSellersByRegionProps, + props: GetSellersByRegionProps, ): Promise { - const { postalCode, salesChannel } = props; - const params = new URLSearchParams({ country: "BRA", postalCode }); - const sc = salesChannel ?? getVtexConfig().salesChannel; - if (sc) params.set("sc", sc); - const resp = await vtexFetchWithCookies(`/api/checkout/pub/regions/?${params}`); - return resp[0]?.sellers?.length > 0 ? resp[0] : null; + const { postalCode, salesChannel } = props; + const params = new URLSearchParams({ country: "BRA", postalCode }); + const sc = salesChannel ?? getVtexConfig().salesChannel; + if (sc) params.set("sc", sc); + const resp = await vtexFetchWithCookies(`/api/checkout/pub/regions/?${params}`); + return resp[0]?.sellers?.length > 0 ? resp[0] : null; } export interface SetShippingPostalCodeProps { - orderFormId: string; - postalCode: string; - country?: string; + orderFormId: string; + postalCode: string; + country?: string; } export async function setShippingPostalCode(props: SetShippingPostalCodeProps): Promise { - const { orderFormId, postalCode, country = "BRA" } = props; - try { - // VTEX docs note that /attachments/shippingData can rotate the - // CheckoutOrderFormOwnership cookie. vtexFetchWithCookies ensures - // any such Set-Cookie reaches the browser via RequestContext, - // keeping the storefront and VTEX bound to the same orderForm. - await vtexFetchWithCookies( - `/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`, - { - method: "POST", - body: JSON.stringify({ - selectedAddresses: [{ postalCode, country }], - }), - }, - ); - return true; - } catch { - return false; - } + const { orderFormId, postalCode, country = "BRA" } = props; + try { + // VTEX docs note that /attachments/shippingData can rotate the + // CheckoutOrderFormOwnership cookie. vtexFetchWithCookies ensures + // any such Set-Cookie reaches the browser via RequestContext, + // keeping the storefront and VTEX bound to the same orderForm. + await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/attachments/shippingData`, + { + method: "POST", + body: JSON.stringify({ + selectedAddresses: [{ postalCode, country }], + }), + }, + ); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Cart v2 — granular sections + server-side projection +// +// The v1 CRUD above always returns the raw OrderForm with all 15 sections. +// The v2 variants let the caller pick (a) which `sections` VTEX computes and +// (b) which `projection` reaches the client — defaulting to the minimum +// (`SECTIONS_MINIMAL` + `"summary+items"`). They are additive: v1 stays for +// back-compat, v2 is opt-in via the new loaders/hooks. +// +// @see `@decocms/apps-commerce/types/cart` for the contract. +// --------------------------------------------------------------------------- + +/** Shared fragmentation knobs for every v2 cart operation. */ +export interface CartV2Options { + /** Sections to request from VTEX. Defaults to the projection's preset. */ + sections?: CartSection[]; + /** Client-facing shape. Default: `"summary+items"`. */ + projection?: CartProjection; + /** Storefront overrides forwarded when `projection === "minicart"`. */ + minicartOptions?: ProjectOrderFormOptions; +} + +function resolveFragmentation(opts: CartV2Options): { + sections: string[]; + projection: CartProjection; +} { + const projection = opts.projection ?? "summary+items"; + const sections = opts.sections ?? defaultSectionsFor(projection); + return { sections, projection }; +} + +export interface GetOrCreateCartV2Props extends CartV2Options { + orderFormId?: string; +} + +/** + * v2 of {@link getOrCreateCart}. Fetches (or creates, when no id) the cart with + * only the requested sections and returns the requested projection. + */ +export async function getOrCreateCartV2( + props: GetOrCreateCartV2Props, +): Promise { + const { orderFormId } = props; + const { sections, projection } = resolveFragmentation(props); + const sc = scParam(); + const body = JSON.stringify({ expectedOrderFormSections: sections }); + + const result = orderFormId + ? await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}${sc ? `?${sc}` : ""}`, + { method: "POST", body }, + ) + : await vtexFetchWithCookies(`/api/checkout/pub/orderForm${sc ? `?${sc}` : ""}`, { + method: "POST", + body, + }); + return projectOrderForm(forceHttpsOnAssets(result), projection, props.minicartOptions); +} + +export interface AddItemsToCartV2Props extends CartV2Options { + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + index?: number; + price?: number; + }>; + allowedOutdatedData?: string[]; +} + +/** v2 of {@link addItemsToCart} — fragmented request + projected response. */ +export async function addItemsToCartV2( + props: AddItemsToCartV2Props, +): Promise { + const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"] } = props; + const { sections, projection } = resolveFragmentation(props); + const params = appendSc(new URLSearchParams()); + for (const d of allowedOutdatedData) params.append("allowedOutdatedData", d); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items?${params}`, + { + method: "POST", + body: JSON.stringify({ orderItems, expectedOrderFormSections: sections }), + }, + ); + return projectOrderForm(forceHttpsOnAssets(result), projection, props.minicartOptions); +} + +export interface UpdateCartItemsV2Props extends CartV2Options { + orderFormId: string; + orderItems: Array<{ index: number; quantity: number }>; + allowedOutdatedData?: string[]; + noSplitItem?: boolean; +} + +/** v2 of {@link updateCartItems} — used for quantity change and item removal (`quantity: 0`). */ +export async function updateCartItemsV2( + props: UpdateCartItemsV2Props, +): Promise { + const { orderFormId, orderItems, allowedOutdatedData = ["paymentData"], noSplitItem } = props; + const { sections, projection } = resolveFragmentation(props); + const params = appendSc(new URLSearchParams()); + for (const d of allowedOutdatedData) params.append("allowedOutdatedData", d); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/items/update?${params}`, + { + method: "POST", + body: JSON.stringify({ + orderItems, + noSplitItem: Boolean(noSplitItem), + expectedOrderFormSections: sections, + }), + }, + ); + return projectOrderForm(forceHttpsOnAssets(result), projection, props.minicartOptions); +} + +export interface AddCouponToCartV2Props extends CartV2Options { + orderFormId: string; + text: string; +} + +/** v2 of {@link addCouponToCart} — a coupon typically needs the drawer projection. */ +export async function addCouponToCartV2( + props: AddCouponToCartV2Props, +): Promise { + const { orderFormId, text } = props; + const { sections, projection } = resolveFragmentation(props); + const sc = scParam(); + const result = await vtexFetchWithCookies( + `/api/checkout/pub/orderForm/${orderFormId}/coupons${sc ? `?${sc}` : ""}`, + { method: "POST", body: JSON.stringify({ text, expectedOrderFormSections: sections }) }, + ); + return projectOrderForm(forceHttpsOnAssets(result), projection, props.minicartOptions); } diff --git a/packages/apps-vtex/src/hooks/__tests__/createCart.render.test.ts b/packages/apps-vtex/src/hooks/__tests__/createCart.render.test.ts new file mode 100644 index 00000000..8a58606b --- /dev/null +++ b/packages/apps-vtex/src/hooks/__tests__/createCart.render.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +/** + * Render-based tests for the `createCart` factory (Cart v2). + * + * The factory-shape tests in `createCart.test.ts` run in node and never render. + * These exercise the actual hook behaviour that needs a React renderer: + * `useAddToCart().add()` returning the server's projected payload (so a caller + * can drive a toast without a second fetch) and the optimistic badge bump. + * + * No `@testing-library/react` in the tree — we hand-roll a tiny render harness + * on `react-dom/client` (jsdom env, declared in the docblock above). + */ + +import type { CartSummaryWithItems } from "@decocms/apps-commerce/types"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { type CreateCartInvoke, createCart } from "../createCart"; + +// React's act() requires this flag when driving updates manually. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function renderHook(useHook: () => T) { + const ref = { current: undefined as unknown as T }; + function Probe() { + ref.current = useHook(); + return null; + } + const container = document.createElement("div"); + const root = createRoot(container); + act(() => { + root.render(createElement(Probe)); + }); + return { ref, unmount: () => act(() => root.unmount()) }; +} + +/** Fake invoke whose addItemsToCartV2 returns a `summary+items` projection. */ +function makeInvoke(): CreateCartInvoke { + const projected: CartSummaryWithItems = { + orderFormId: "of-1", + totalItems: 1, + total: 100, + items: [ + { + item_id: "sku-1", + item_name: "Camiseta", + item_variant: "P Azul", + image: "https://img.example/x.jpg", + price: 100, + quantity: 1, + }, + ], + }; + return { + vtex: { + actions: { + getOrCreateCartV2: async () => ({ orderFormId: "of-1", totalItems: 0, total: 0 }), + addItemsToCartV2: async () => projected as never, + updateCartItemsV2: async () => ({ ok: true }) as never, + addCouponToCartV2: async () => ({ ok: true }) as never, + }, + loaders: { + cart: { + summary: async () => ({ orderFormId: "of-1", totalItems: 0, total: 0 }), + full: async () => ({ original: null }) as never, + shipping: async () => ({ postalCode: "00000-000", options: [] }), + gifts: async () => ({ orderFormId: "of-1", selectableGifts: [], ratesAndBenefits: null }), + attachments: async () => ({ + orderFormId: "of-1", + itemIndex: 0, + attachments: [], + attachmentOfferings: [], + }), + }, + }, + }, + }; +} + +describe("createCart — useAddToCart.add()", () => { + it("returns the projected payload for a toast without a second fetch", async () => { + const cart = createCart({ invoke: makeInvoke() }); + const { ref, unmount } = renderHook(() => cart.useAddToCart()); + + let result: unknown; + await act(async () => { + result = await ref.current.add({ id: "sku-1", seller: "1" }); + }); + + expect(result).toMatchObject({ + totalItems: 1, + total: 100, + items: [{ item_name: "Camiseta", item_variant: "P Azul", price: 100 }], + }); + unmount(); + }); + + it("reconciles the summary badge from the projected response", async () => { + const cart = createCart({ invoke: makeInvoke() }); + const add = renderHook(() => cart.useAddToCart()); + const badge = renderHook(() => cart.useCartSummary()); + + expect(badge.ref.current.totalItems).toBe(0); + + await act(async () => { + await add.ref.current.add({ id: "sku-1", seller: "1" }); + }); + + // Reconciled from the server projection (1), not just the optimistic bump. + expect(badge.ref.current.totalItems).toBe(1); + add.unmount(); + badge.unmount(); + }); +}); diff --git a/packages/apps-vtex/src/hooks/__tests__/createCart.test.ts b/packages/apps-vtex/src/hooks/__tests__/createCart.test.ts new file mode 100644 index 00000000..30f02a61 --- /dev/null +++ b/packages/apps-vtex/src/hooks/__tests__/createCart.test.ts @@ -0,0 +1,92 @@ +/** + * Tests for the `createCart` factory (Cart v2). + * + * Like `createUseCart`, hook render semantics need a React renderer that + * apps-start does not pull in, so we cover the factory's shape and instance + * isolation. The optimistic + reconciliation behaviour is exercised by the + * site-level smoke test. + */ + +import { describe, expect, it, vi } from "vitest"; +import { type CreateCartInvoke, createCart } from "../createCart"; + +function makeInvoke(): CreateCartInvoke { + const summary = async () => ({ orderFormId: "of-1", totalItems: 0, total: 0 }); + const anyNoop = async () => ({ ok: true }) as never; + return { + vtex: { + actions: { + getOrCreateCartV2: async () => ({ orderFormId: "of-1", totalItems: 0, total: 0 }), + addItemsToCartV2: anyNoop, + updateCartItemsV2: anyNoop, + addCouponToCartV2: anyNoop, + }, + loaders: { + cart: { + summary, + full: async () => ({ + original: null, + storefront: { + items: [], + total: 0, + subtotal: 0, + discounts: 0, + locale: "pt-BR", + currency: "BRL", + freeShippingTarget: 0, + checkoutHref: "/checkout", + }, + }), + shipping: async () => ({ postalCode: "00000-000", options: [] }), + gifts: async () => ({ orderFormId: "of-1", selectableGifts: [], ratesAndBenefits: null }), + attachments: async () => ({ + orderFormId: "of-1", + itemIndex: 0, + attachments: [], + attachmentOfferings: [], + }), + }, + }, + }, + }; +} + +describe("createCart — factory shape", () => { + it("returns the full granular hook set", () => { + const cart = createCart({ invoke: makeInvoke() }); + expect(typeof cart.useCart).toBe("function"); + expect(typeof cart.useCartSummary).toBe("function"); + expect(typeof cart.useAddToCart).toBe("function"); + expect(typeof cart.useShipping).toBe("function"); + expect(typeof cart.useGifts).toBe("function"); + expect(typeof cart.useAttachments).toBe("function"); + expect(typeof cart.resetCart).toBe("function"); + }); + + it("two factory calls produce independent hook identities", () => { + const a = createCart({ invoke: makeInvoke() }); + const b = createCart({ invoke: makeInvoke() }); + expect(a.useCart).not.toBe(b.useCart); + expect(a.useAddToCart).not.toBe(b.useAddToCart); + expect(a.resetCart).not.toBe(b.resetCart); + }); + + it("accepts custom cookie name / max-age without throwing", () => { + expect(() => + createCart({ + invoke: makeInvoke(), + orderFormCookieName: "custom_of", + orderFormCookieMaxAge: 3600, + }), + ).not.toThrow(); + }); + + it("does not call any invoke method at construction time (lazy)", () => { + const invoke = makeInvoke(); + const getOrCreate = vi.spyOn(invoke.vtex.actions, "getOrCreateCartV2"); + const summary = vi.spyOn(invoke.vtex.loaders.cart, "summary"); + createCart({ invoke }); + expect(getOrCreate).not.toHaveBeenCalled(); + expect(summary).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/apps-vtex/src/hooks/cartQuery.ts b/packages/apps-vtex/src/hooks/cartQuery.ts new file mode 100644 index 00000000..7975a6aa --- /dev/null +++ b/packages/apps-vtex/src/hooks/cartQuery.ts @@ -0,0 +1,204 @@ +/** + * Cart v2 — optional TanStack Query adapter. + * + * The base factory (`createCart`) is intentionally QueryClient-free so it runs + * anywhere. Sites already invested in TanStack Query can use this adapter + * instead to get cache integration, `staleTime`, devtools, and cross-component + * query sharing — while keeping the exact same invoke injection and the same + * server-side fragmentation/projection contract. + * + * Importing this module pulls in `@tanstack/react-query` (a peer dependency). + * If your site does not use react-query, import `createCart` instead. + * + * @example + * ```ts + * import { createCartQuery } from "@decocms/apps/vtex/hooks/cartQuery"; + * import { invoke } from "~/server/invoke"; + * export const { useCartSummary, useCartFull, useAddToCart } = createCartQuery({ invoke }); + * ``` + */ + +import type { + CartProjection, + CartSection, + CartSummary, + Minicart, +} from "@decocms/apps-commerce/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { CartItemAttachments } from "../loaders/cart/attachments"; +import type { CartGifts } from "../loaders/cart/gifts"; +import type { CartShipping } from "../loaders/cart/shipping"; +import type { OrderForm } from "../types"; +import type { CreateCartInvoke } from "./createCart"; + +export interface CreateCartQueryOptions { + invoke: CreateCartInvoke; + orderFormCookieName?: string; +} + +const SUMMARY_KEY = ["vtex", "cart", "summary"] as const; +const FULL_KEY = ["vtex", "cart", "full"] as const; +const SHIPPING_KEY = ["vtex", "cart", "shipping"] as const; +const GIFTS_KEY = ["vtex", "cart", "gifts"] as const; +const ATTACHMENTS_KEY = ["vtex", "cart", "attachments"] as const; + +/** TanStack Query flavour of the Cart v2 hooks. */ +export function createCartQuery(opts: CreateCartQueryOptions) { + const { invoke } = opts; + const COOKIE_NAME = opts.orderFormCookieName ?? "checkout.vtex.com__orderFormId"; + + function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function getOrderFormIdFromCookie(): string | null { + if (typeof document === "undefined") return null; + const m = document.cookie.match(new RegExp(`${escapeRegex(COOKIE_NAME)}=([^;]*)`)); + return m ? decodeURIComponent(m[1]) : null; + } + function setOrderFormIdCookie(id: string) { + if (typeof document === "undefined") return; + // biome-ignore lint/suspicious/noDocumentCookie: the orderFormId cookie is intentionally client-readable (VTEX standard). + document.cookie = `${COOKIE_NAME}=${encodeURIComponent(id)}; path=/; max-age=${7 * 24 * 3600}; SameSite=Lax`; + } + async function ensureOrderFormId(current: string | null): Promise { + const existing = current ?? getOrderFormIdFromCookie(); + if (existing) return existing; + const created = (await invoke.vtex.actions.getOrCreateCartV2({ + data: { projection: "summary" }, + })) as CartSummary; + if (created.orderFormId) setOrderFormIdCookie(created.orderFormId); + return created.orderFormId ?? ""; + } + + /** + * Badge query. Lazy: `enabled` defaults to false so it only runs when the + * caller opts in (e.g. after the cart cookie exists) — no cart is created on + * mount. + */ + function useCartSummary(options?: { enabled?: boolean; staleTime?: number }) { + return useQuery({ + queryKey: SUMMARY_KEY, + queryFn: () => invoke.vtex.loaders.cart.summary({ data: {} }), + enabled: options?.enabled ?? false, + staleTime: options?.staleTime ?? 30_000, + }); + } + + /** Full drawer query. `enabled` gated so it fires when the drawer opens. */ + function useCartFull(options?: { + enabled?: boolean; + staleTime?: number; + freeShippingTarget?: number; + locale?: string; + checkoutHref?: string; + enableCoupon?: boolean; + }) { + return useQuery>({ + queryKey: FULL_KEY, + queryFn: () => + invoke.vtex.loaders.cart.full({ + data: { + freeShippingTarget: options?.freeShippingTarget, + locale: options?.locale, + checkoutHref: options?.checkoutHref, + enableCoupon: options?.enableCoupon, + }, + }), + enabled: options?.enabled ?? false, + staleTime: options?.staleTime ?? 30_000, + }); + } + + function useAddToCart(hookOpts?: { projection?: CartProjection; sections?: CartSection[] }) { + const queryClient = useQueryClient(); + const projection: CartProjection = hookOpts?.projection ?? "summary+items"; + + return useMutation({ + mutationFn: async (params: { id: string; seller: string; quantity?: number }) => { + const current = queryClient.getQueryData(SUMMARY_KEY)?.orderFormId ?? null; + const orderFormId = await ensureOrderFormId(current); + return invoke.vtex.actions.addItemsToCartV2({ + data: { + orderFormId, + orderItems: [{ id: params.id, seller: params.seller, quantity: params.quantity ?? 1 }], + projection, + sections: hookOpts?.sections, + }, + }); + }, + // Optimistic badge bump. + onMutate: async (params) => { + await queryClient.cancelQueries({ queryKey: SUMMARY_KEY }); + const prev = queryClient.getQueryData(SUMMARY_KEY); + const qty = params.quantity ?? 1; + queryClient.setQueryData(SUMMARY_KEY, (s) => ({ + orderFormId: s?.orderFormId ?? null, + total: s?.total ?? 0, + totalItems: (s?.totalItems ?? 0) + qty, + })); + return { prev }; + }, + onError: (_err, _params, ctx) => { + if (ctx?.prev) queryClient.setQueryData(SUMMARY_KEY, ctx.prev); + }, + onSuccess: (result) => { + if (projection === "minicart") { + queryClient.setQueryData(FULL_KEY, result); + } else if (projection !== "none") { + const r = result as Partial; + if (r && typeof r.totalItems === "number") { + queryClient.setQueryData(SUMMARY_KEY, { + orderFormId: r.orderFormId ?? null, + totalItems: r.totalItems, + total: r.total ?? 0, + }); + } + } + }, + }); + } + + /** + * Shipping estimate query. Keyed by `{ postalCode, items }` — which is NOT + * user-personalized — so react-query's cache/dedupe gives you the caching + * the server-side loader can't do yet (see the loader's caching note). + * `enabled` defaults on when a postal code is present. + */ + function useShipping( + params: { + items: Array<{ id: string | number; quantity: number; seller: string }>; + postalCode: string; + country?: string; + }, + options?: { enabled?: boolean; staleTime?: number }, + ) { + return useQuery({ + queryKey: [...SHIPPING_KEY, params.postalCode, params.items], + queryFn: () => invoke.vtex.loaders.cart.shipping({ data: params }), + enabled: options?.enabled ?? Boolean(params.postalCode && params.items.length), + staleTime: options?.staleTime ?? 5 * 60_000, + }); + } + + /** Selectable-gifts / promotions query. `enabled` gated (default false). */ + function useGifts(options?: { enabled?: boolean; staleTime?: number }) { + return useQuery({ + queryKey: GIFTS_KEY, + queryFn: () => invoke.vtex.loaders.cart.gifts({ data: {} }), + enabled: options?.enabled ?? false, + staleTime: options?.staleTime ?? 30_000, + }); + } + + /** Single-line attachments query. `enabled` gated (default false). */ + function useAttachments(itemIndex: number, options?: { enabled?: boolean; staleTime?: number }) { + return useQuery({ + queryKey: [...ATTACHMENTS_KEY, itemIndex], + queryFn: () => invoke.vtex.loaders.cart.attachments({ data: { itemIndex } }), + enabled: options?.enabled ?? false, + staleTime: options?.staleTime ?? 30_000, + }); + } + + return { useCartSummary, useCartFull, useAddToCart, useShipping, useGifts, useAttachments }; +} diff --git a/packages/apps-vtex/src/hooks/createCart.ts b/packages/apps-vtex/src/hooks/createCart.ts new file mode 100644 index 00000000..2fa9d7ca --- /dev/null +++ b/packages/apps-vtex/src/hooks/createCart.ts @@ -0,0 +1,427 @@ +/** + * Cart v2 factory — modular, granular, framework-agnostic client hooks. + * + * Built on the same module-singleton + listener pattern as `createUseCart` + * (no `@tanstack/react-query` required, works identically under Next.js and + * TanStack Start), but redesigned around the Cart v2 goals: + * + * - **Lazy**: nothing hits VTEX on mount. No OrderForm is created until the + * first add-to-cart. The badge shows 0 (or the reconciled count) until then. + * - **Granular**: separate hooks per concern (`useCartSummary` for the badge, + * `useCart` for the drawer, `useShipping`, `useGifts`) so a component pulls + * only what it renders. + * - **Minimal by default + optimistic**: `useAddToCart` bumps the local count + * immediately and reconciles from the server's projected response. The + * optimistic + reconciliation logic lives here so the storefront never + * reimplements it. + * + * Injection: pass your site's `invoke` (the generated server-function proxy). + * The `CreateCartInvoke` interface declares only the v2 methods this factory + * calls — a structural subset, same as `CreateUseCartInvoke`. + * + * @example + * ```ts + * // src/hooks/cart.ts + * import { createCart } from "@decocms/apps/vtex/hooks/createCart"; + * import { invoke } from "~/server/invoke"; + * export const { useCart, useCartSummary, useAddToCart } = createCart({ invoke }); + * ``` + */ + +import type { + CartProjection, + CartSection, + CartSummary, + CartSummaryWithItems, + Minicart, +} from "@decocms/apps-commerce/types"; +import { useEffect, useState } from "react"; +import type { CartItemAttachments } from "../loaders/cart/attachments"; +import type { CartGifts } from "../loaders/cart/gifts"; +import type { CartShipping } from "../loaders/cart/shipping"; +import type { OrderForm } from "../types"; +import type { VtexCartProjectionResult } from "../utils/cartProjection"; + +/** Structural subset of the invoke proxy this factory needs. */ +export interface CreateCartInvoke { + vtex: { + actions: { + getOrCreateCartV2: (args: { + data: { orderFormId?: string; projection?: CartProjection; sections?: CartSection[] }; + }) => Promise; + addItemsToCartV2: (args: { + data: { + orderFormId: string; + orderItems: Array<{ id: string; seller: string; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise; + updateCartItemsV2: (args: { + data: { + orderFormId: string; + orderItems: Array<{ index: number; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise; + addCouponToCartV2: (args: { + data: { + orderFormId: string; + text: string; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise; + }; + loaders: { + cart: { + summary: (args: { data?: { orderFormId?: string } }) => Promise; + full: (args: { + data?: { + orderFormId?: string; + freeShippingTarget?: number; + locale?: string; + checkoutHref?: string; + enableCoupon?: boolean; + }; + }) => Promise>; + shipping: (args: { + data: { + items: Array<{ id: string | number; quantity: number; seller: string }>; + postalCode: string; + country?: string; + }; + }) => Promise; + gifts: (args: { data?: { orderFormId?: string } }) => Promise; + attachments: (args: { + data: { orderFormId?: string; itemIndex: number }; + }) => Promise; + }; + }; + }; +} + +export interface CreateCartOptions { + invoke: CreateCartInvoke; + /** Override the orderFormId cookie name. Default: VTEX standard. */ + orderFormCookieName?: string; + /** Override the cookie max-age in seconds. Default: 7 days. */ + orderFormCookieMaxAge?: number; +} + +const EMPTY_SUMMARY: CartSummary = { orderFormId: null, totalItems: 0, total: 0 }; + +export interface AddToCartOptions { + /** What the server should return. Default: `"summary+items"`. */ + projection?: CartProjection; + sections?: CartSection[]; +} + +export interface UseCartInclude { + /** Fetch the full minicart (drawer). Default: false. */ + full?: boolean; +} + +export interface UseCartV2Options { + include?: UseCartInclude; + freeShippingTarget?: number; + locale?: string; + checkoutHref?: string; + enableCoupon?: boolean; +} + +/** Build a per-site set of Cart v2 hooks. */ +export function createCart(opts: CreateCartOptions) { + const { invoke } = opts; + const COOKIE_NAME = opts.orderFormCookieName ?? "checkout.vtex.com__orderFormId"; + const COOKIE_MAX_AGE = opts.orderFormCookieMaxAge ?? 7 * 24 * 3600; + + // --- module-singleton state -------------------------------------------- + let _summary: CartSummary = EMPTY_SUMMARY; + let _minicart: Minicart | null = null; + let _loading = false; + const _listeners = new Set<() => void>(); + + function notify() { + for (const fn of _listeners) fn(); + } + function setSummary(s: CartSummary) { + _summary = s; + notify(); + } + function setMinicart(m: Minicart | null) { + _minicart = m; + if (m) { + // Keep the badge in sync whenever the full cart is loaded. + _summary = { + orderFormId: m.original?.orderFormId ?? _summary.orderFormId, + totalItems: m.storefront.items.reduce((n, i) => n + (i.quantity ?? 0), 0), + total: m.storefront.total, + }; + } + notify(); + } + function setLoading(v: boolean) { + _loading = v; + notify(); + } + + // --- cookie helpers ---------------------------------------------------- + function escapeRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function getOrderFormIdFromCookie(): string | null { + if (typeof document === "undefined") return null; + const re = new RegExp(`${escapeRegex(COOKIE_NAME)}=([^;]*)`); + const match = document.cookie.match(re); + return match ? decodeURIComponent(match[1]) : null; + } + function setOrderFormIdCookie(id: string) { + if (typeof document === "undefined") return; + // biome-ignore lint/suspicious/noDocumentCookie: the orderFormId cookie is intentionally client-readable (VTEX standard, mirrored by createUseCart). + document.cookie = `${COOKIE_NAME}=${encodeURIComponent(id)}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`; + } + + /** + * Ensure an OrderForm exists. Only called from mutations — never on mount — + * so a browsing-only visitor provisions no cart. If the cookie has an id we + * trust it; otherwise we create one (projection "summary" — we only need the + * id + count back). + */ + async function ensureOrderFormId(): Promise { + const existing = _summary.orderFormId ?? getOrderFormIdFromCookie(); + if (existing) { + _summary = { ..._summary, orderFormId: existing }; + return existing; + } + const created = (await invoke.vtex.actions.getOrCreateCartV2({ + data: { projection: "summary" }, + })) as CartSummary; + if (created.orderFormId) setOrderFormIdCookie(created.orderFormId); + setSummary(created); + return created.orderFormId ?? ""; + } + + /** Reconcile local state from a projected mutation response. */ + function reconcile(result: unknown, projection: CartProjection) { + if (projection === "none") return; // optimistic-only; nothing to reconcile + if (projection === "minicart") { + setMinicart(result as Minicart); + return; + } + // "summary" | "summary+items" | "raw" all expose the totals we need. + const r = result as Partial & { value?: number }; + if (r && typeof r.totalItems === "number") { + setSummary({ + orderFormId: r.orderFormId ?? _summary.orderFormId, + totalItems: r.totalItems, + total: r.total ?? _summary.total, + }); + } + } + + // --- shared React subscription ---------------------------------------- + function useCartState() { + const [, forceRender] = useState(0); + useEffect(() => { + const listener = () => forceRender((n) => n + 1); + _listeners.add(listener); + return () => { + _listeners.delete(listener); + }; + }, []); + } + + // --- hooks ------------------------------------------------------------- + + /** Badge hook. Reads the local summary; never triggers a VTEX call by itself. */ + function useCartSummary() { + useCartState(); + return { summary: _summary, totalItems: _summary.totalItems, loading: _loading }; + } + + /** + * Add-to-cart with built-in optimistic count + reconciliation. + * `projection: "none"` → pure 200, optimistic-only (no server data used). + */ + function useAddToCart(hookOpts: AddToCartOptions = {}) { + useCartState(); + const projection: CartProjection = hookOpts.projection ?? "summary+items"; + + async function add(params: { + id: string; + seller: string; + quantity?: number; + }): Promise { + const qty = params.quantity ?? 1; + // Optimistic: bump the badge immediately. + setSummary({ ..._summary, totalItems: _summary.totalItems + qty }); + setLoading(true); + try { + const orderFormId = await ensureOrderFormId(); + const result = await invoke.vtex.actions.addItemsToCartV2({ + data: { + orderFormId, + orderItems: [{ id: params.id, seller: params.seller, quantity: qty }], + projection, + sections: hookOpts.sections, + }, + }); + reconcile(result, projection); + // Return the projected payload so callers can drive a toast / analytics + // without a second fetch (e.g. `summary+items` → name/image/price/variant). + return result as VtexCartProjectionResult; + } catch (err) { + // Roll back the optimistic bump on failure. + setSummary({ ..._summary, totalItems: Math.max(0, _summary.totalItems - qty) }); + console.error("[cart] addToCart failed:", err); + throw err; + } finally { + setLoading(false); + } + } + + return { add, loading: _loading }; + } + + /** + * Composed cart hook for the drawer. Fetches ONLY what `include` enables — + * default is nothing beyond the already-local summary. Call `openDrawer()` + * (or pass `include.full`) to load the full minicart on demand. + */ + function useCart(cartOpts: UseCartV2Options = {}) { + useCartState(); + const wantFull = cartOpts.include?.full === true; + + // biome-ignore lint/correctness/useExhaustiveDependencies: the drawer loads once when enabled; storefront options are stable per-mount and must not re-trigger the fetch. + useEffect(() => { + if (!wantFull) return; + let cancelled = false; + setLoading(true); + invoke.vtex.loaders.cart + .full({ + data: { + freeShippingTarget: cartOpts.freeShippingTarget, + locale: cartOpts.locale, + checkoutHref: cartOpts.checkoutHref, + enableCoupon: cartOpts.enableCoupon, + }, + }) + .then((m) => { + if (!cancelled) setMinicart(m); + }) + .catch((err) => console.error("[cart] load full failed:", err)) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [wantFull]); + + async function refresh() { + const m = await invoke.vtex.loaders.cart.full({ + data: { + freeShippingTarget: cartOpts.freeShippingTarget, + locale: cartOpts.locale, + checkoutHref: cartOpts.checkoutHref, + enableCoupon: cartOpts.enableCoupon, + }, + }); + setMinicart(m); + return m; + } + + async function updateQuantity(index: number, quantity: number) { + const orderFormId = await ensureOrderFormId(); + setLoading(true); + try { + const result = await invoke.vtex.actions.updateCartItemsV2({ + data: { orderFormId, orderItems: [{ index, quantity }], projection: "minicart" }, + }); + reconcile(result, "minicart"); + return result as Minicart; + } finally { + setLoading(false); + } + } + + async function removeItem(index: number) { + return updateQuantity(index, 0); + } + + async function addCoupon(text: string) { + const orderFormId = await ensureOrderFormId(); + setLoading(true); + try { + const result = await invoke.vtex.actions.addCouponToCartV2({ + data: { orderFormId, text, projection: "minicart" }, + }); + reconcile(result, "minicart"); + return result as Minicart; + } finally { + setLoading(false); + } + } + + return { + minicart: _minicart, + summary: _summary, + loading: _loading, + refresh, + updateQuantity, + removeItem, + addCoupon, + }; + } + + /** On-demand shipping estimate for the drawer. Not cached (see loader note). */ + function useShipping() { + useCartState(); + async function estimate(params: { + items: Array<{ id: string | number; quantity: number; seller: string }>; + postalCode: string; + country?: string; + }) { + return invoke.vtex.loaders.cart.shipping({ data: params }); + } + return { estimate }; + } + + /** On-demand selectable-gifts / promotions read. */ + function useGifts() { + useCartState(); + async function load() { + return invoke.vtex.loaders.cart.gifts({ data: {} }); + } + return { load }; + } + + /** On-demand attachments/offerings read for a single line (engraving, gift-wrap…). */ + function useAttachments() { + useCartState(); + async function load(itemIndex: number) { + return invoke.vtex.loaders.cart.attachments({ data: { itemIndex } }); + } + return { load }; + } + + /** Reset all module-level state (e.g. after logout / order placed). */ + function resetCart() { + _summary = EMPTY_SUMMARY; + _minicart = null; + _loading = false; + notify(); + } + + return { + useCart, + useCartSummary, + useAddToCart, + useShipping, + useGifts, + useAttachments, + resetCart, + }; +} diff --git a/packages/apps-vtex/src/hooks/index.ts b/packages/apps-vtex/src/hooks/index.ts index 4f237704..24dccfa8 100644 --- a/packages/apps-vtex/src/hooks/index.ts +++ b/packages/apps-vtex/src/hooks/index.ts @@ -1,3 +1,9 @@ +export { + type CreateCartInvoke, + type CreateCartOptions, + createCart, +} from "./createCart"; +export { type CreateCartQueryOptions, createCartQuery } from "./cartQuery"; export { type CreateUseCartInvoke, type CreateUseCartOptions, diff --git a/packages/apps-vtex/src/invoke.ts b/packages/apps-vtex/src/invoke.ts index 91634112..10d92681 100644 --- a/packages/apps-vtex/src/invoke.ts +++ b/packages/apps-vtex/src/invoke.ts @@ -24,32 +24,39 @@ * 1. Add an entry below with the input/output types and the action call, * 2. From a site repo: `npm run generate:invoke`. */ + +import type { CartProjection, CartSection } from "@decocms/apps-commerce/types"; import { createInvokeFn } from "@decocms/tanstack/sdk/createInvoke"; import { - addCouponToCart, - addItemsToCart, - getOrCreateCart, - getSellersByRegion, - type RegionResult, - type SimulationItem, - setShippingPostalCode, - simulateCart, - updateCartItems, - updateOrderFormAttachment, + addCouponToCart, + addCouponToCartV2, + addItemsToCart, + addItemsToCartV2, + getOrCreateCart, + getOrCreateCartV2, + getSellersByRegion, + type RegionResult, + type SimulationItem, + setShippingPostalCode, + simulateCart, + updateCartItems, + updateCartItemsV2, + updateOrderFormAttachment, } from "./actions/checkout"; import { - type CreateDocumentResult, - createDocument, - getDocument, - patchDocument, - searchDocuments, - type UploadAttachmentOpts, - uploadAttachment, + type CreateDocumentResult, + createDocument, + getDocument, + patchDocument, + searchDocuments, + type UploadAttachmentOpts, + uploadAttachment, } from "./actions/masterData"; import { type NotifyMeProps, notifyMe } from "./actions/misc"; import { type SubscribeProps, subscribe } from "./actions/newsletter"; import { createSession, editSession, type SessionData } from "./actions/session"; import type { OrderForm } from "./types"; +import type { VtexCartProjectionResult } from "./utils/cartProjection"; // --------------------------------------------------------------------------- // invoke.vtex.actions — typed server functions callable from client @@ -62,120 +69,177 @@ import type { OrderForm } from "./types"; // --------------------------------------------------------------------------- export const invoke = { - vtex: { - actions: { - // -- Cart (OrderForm CRUD) -------------------------------------------- - - getOrCreateCart: createInvokeFn((data: { orderFormId?: string }) => - getOrCreateCart(data), - ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, - - addItemsToCart: createInvokeFn( - (data: { - orderFormId: string; - orderItems: Array<{ - id: string; - seller: string; - quantity: number; - }>; - }) => addItemsToCart(data), - ) as unknown as (ctx: { - data: { - orderFormId: string; - orderItems: Array<{ - id: string; - seller: string; - quantity: number; - }>; - }; - }) => Promise, - - updateCartItems: createInvokeFn( - (data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }) => - updateCartItems(data), - ) as unknown as (ctx: { - data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }; - }) => Promise, - - addCouponToCart: createInvokeFn((data: { orderFormId: string; text: string }) => - addCouponToCart(data), - ) as unknown as (ctx: { data: { orderFormId: string; text: string } }) => Promise, - - simulateCart: createInvokeFn( - (data: { items: SimulationItem[]; postalCode: string; country?: string }) => - simulateCart(data), - ), - - // -- Shipping / Region ------------------------------------------------ - - getSellersByRegion: createInvokeFn((data: { postalCode: string; salesChannel?: string }) => - getSellersByRegion(data), - ) as unknown as (ctx: { - data: { postalCode: string; salesChannel?: string }; - }) => Promise, - - setShippingPostalCode: createInvokeFn( - (data: { orderFormId: string; postalCode: string; country?: string }) => - setShippingPostalCode(data), - ) as unknown as (ctx: { - data: { orderFormId: string; postalCode: string; country?: string }; - }) => Promise, - - updateOrderFormAttachment: createInvokeFn( - (data: { orderFormId: string; attachment: string; body: Record }) => - updateOrderFormAttachment(data), - ) as unknown as (ctx: { - data: { orderFormId: string; attachment: string; body: Record }; - }) => Promise, - - // -- Session ---------------------------------------------------------- - - createSession: createInvokeFn((data: Record) => createSession({ data })), - - editSession: createInvokeFn((data: { public: Record }) => - editSession(data), - ) as unknown as (ctx: { - data: { public: Record }; - }) => Promise, - - // -- MasterData ------------------------------------------------------- - - createDocument: createInvokeFn((data: { entity: string; data: Record }) => - createDocument(data), - ) as unknown as (ctx: { - data: { entity: string; data: Record }; - }) => Promise, - - getDocument: createInvokeFn((data: { entity: string; documentId: string }) => - getDocument(data), - ), - - patchDocument: createInvokeFn( - (data: { entity: string; documentId: string; data: Record }) => - patchDocument(data), - ) as unknown as (ctx: { - data: { entity: string; documentId: string; data: Record }; - }) => Promise, - - searchDocuments: createInvokeFn((data: { entity: string; filter: string }) => - searchDocuments(data), - ), - - uploadAttachment: createInvokeFn((data: UploadAttachmentOpts) => - uploadAttachment(data), - ) as unknown as (ctx: { data: UploadAttachmentOpts }) => Promise<{ ok: true }>, - - // -- Newsletter ------------------------------------------------------- - - subscribe: createInvokeFn((data: SubscribeProps) => subscribe(data)) as unknown as (ctx: { - data: SubscribeProps; - }) => Promise, - - // -- Misc ------------------------------------------------------------- - - notifyMe: createInvokeFn((data: NotifyMeProps) => notifyMe(data)) as unknown as (ctx: { - data: NotifyMeProps; - }) => Promise, - }, - }, + vtex: { + actions: { + // -- Cart (OrderForm CRUD) -------------------------------------------- + + getOrCreateCart: createInvokeFn((data: { orderFormId?: string }) => + getOrCreateCart(data), + ) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise, + + addItemsToCart: createInvokeFn( + (data: { + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + }>; + }) => addItemsToCart(data), + ) as unknown as (ctx: { + data: { + orderFormId: string; + orderItems: Array<{ + id: string; + seller: string; + quantity: number; + }>; + }; + }) => Promise, + + updateCartItems: createInvokeFn( + (data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }) => + updateCartItems(data), + ) as unknown as (ctx: { + data: { orderFormId: string; orderItems: Array<{ index: number; quantity: number }> }; + }) => Promise, + + addCouponToCart: createInvokeFn((data: { orderFormId: string; text: string }) => + addCouponToCart(data), + ) as unknown as (ctx: { data: { orderFormId: string; text: string } }) => Promise, + + // -- Cart v2 (fragmented sections + projected response) ---------------- + + getOrCreateCartV2: createInvokeFn( + (data: { orderFormId?: string; projection?: CartProjection; sections?: CartSection[] }) => + getOrCreateCartV2(data), + ) as unknown as (ctx: { + data: { orderFormId?: string; projection?: CartProjection; sections?: CartSection[] }; + }) => Promise, + + addItemsToCartV2: createInvokeFn( + (data: { + orderFormId: string; + orderItems: Array<{ id: string; seller: string; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }) => addItemsToCartV2(data), + ) as unknown as (ctx: { + data: { + orderFormId: string; + orderItems: Array<{ id: string; seller: string; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise, + + updateCartItemsV2: createInvokeFn( + (data: { + orderFormId: string; + orderItems: Array<{ index: number; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }) => updateCartItemsV2(data), + ) as unknown as (ctx: { + data: { + orderFormId: string; + orderItems: Array<{ index: number; quantity: number }>; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise, + + addCouponToCartV2: createInvokeFn( + (data: { + orderFormId: string; + text: string; + projection?: CartProjection; + sections?: CartSection[]; + }) => addCouponToCartV2(data), + ) as unknown as (ctx: { + data: { + orderFormId: string; + text: string; + projection?: CartProjection; + sections?: CartSection[]; + }; + }) => Promise, + + simulateCart: createInvokeFn( + (data: { items: SimulationItem[]; postalCode: string; country?: string }) => + simulateCart(data), + ), + + // -- Shipping / Region ------------------------------------------------ + + getSellersByRegion: createInvokeFn((data: { postalCode: string; salesChannel?: string }) => + getSellersByRegion(data), + ) as unknown as (ctx: { + data: { postalCode: string; salesChannel?: string }; + }) => Promise, + + setShippingPostalCode: createInvokeFn( + (data: { orderFormId: string; postalCode: string; country?: string }) => + setShippingPostalCode(data), + ) as unknown as (ctx: { + data: { orderFormId: string; postalCode: string; country?: string }; + }) => Promise, + + updateOrderFormAttachment: createInvokeFn( + (data: { orderFormId: string; attachment: string; body: Record }) => + updateOrderFormAttachment(data), + ) as unknown as (ctx: { + data: { orderFormId: string; attachment: string; body: Record }; + }) => Promise, + + // -- Session ---------------------------------------------------------- + + createSession: createInvokeFn((data: Record) => createSession({ data })), + + editSession: createInvokeFn((data: { public: Record }) => + editSession(data), + ) as unknown as (ctx: { + data: { public: Record }; + }) => Promise, + + // -- MasterData ------------------------------------------------------- + + createDocument: createInvokeFn((data: { entity: string; data: Record }) => + createDocument(data), + ) as unknown as (ctx: { + data: { entity: string; data: Record }; + }) => Promise, + + getDocument: createInvokeFn((data: { entity: string; documentId: string }) => + getDocument(data), + ), + + patchDocument: createInvokeFn( + (data: { entity: string; documentId: string; data: Record }) => + patchDocument(data), + ) as unknown as (ctx: { + data: { entity: string; documentId: string; data: Record }; + }) => Promise, + + searchDocuments: createInvokeFn((data: { entity: string; filter: string }) => + searchDocuments(data), + ), + + uploadAttachment: createInvokeFn((data: UploadAttachmentOpts) => + uploadAttachment(data), + ) as unknown as (ctx: { data: UploadAttachmentOpts }) => Promise<{ ok: true }>, + + // -- Newsletter ------------------------------------------------------- + + subscribe: createInvokeFn((data: SubscribeProps) => subscribe(data)) as unknown as (ctx: { + data: SubscribeProps; + }) => Promise, + + // -- Misc ------------------------------------------------------------- + + notifyMe: createInvokeFn((data: NotifyMeProps) => notifyMe(data)) as unknown as (ctx: { + data: NotifyMeProps; + }) => Promise, + }, + }, } as const; diff --git a/packages/apps-vtex/src/loaders/cart/attachments.ts b/packages/apps-vtex/src/loaders/cart/attachments.ts new file mode 100644 index 00000000..062d7a9f --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/attachments.ts @@ -0,0 +1,53 @@ +/** + * `vtex/loaders/cart/attachments` — one item's attachments + offered slots. + * + * For a customization UI (engraving, gift wrap, ...) that only needs a single + * line's attachment state. Fetches just `items` and returns that item's + * `attachments` (applied) and `attachmentOfferings` (available slots). + */ + +import { getOrCreateCartV2 } from "../../actions/checkout"; +import type { OrderForm, OrderFormItem } from "../../types"; +import { resolveOrderFormId } from "./orderFormId"; + +export interface CartItemAttachmentsProps { + orderFormId?: string; + /** Index of the line whose attachments to read. */ + itemIndex: number; +} + +export interface CartItemAttachments { + orderFormId: string | null; + itemIndex: number; + attachments: NonNullable; + attachmentOfferings: NonNullable; +} + +export default async function cartItemAttachments( + props: CartItemAttachmentsProps, +): Promise { + const orderFormId = resolveOrderFormId(props.orderFormId); + const empty: CartItemAttachments = { + orderFormId: null, + itemIndex: props.itemIndex, + attachments: [], + attachmentOfferings: [], + }; + if (!orderFormId) return empty; + + const orderForm = (await getOrCreateCartV2({ + orderFormId, + projection: "raw", + sections: ["items"], + })) as OrderForm; + + const item = orderForm.items?.[props.itemIndex]; + if (!item) return { ...empty, orderFormId: orderForm.orderFormId ?? null }; + + return { + orderFormId: orderForm.orderFormId ?? null, + itemIndex: props.itemIndex, + attachments: item.attachments ?? [], + attachmentOfferings: item.attachmentOfferings ?? [], + }; +} diff --git a/packages/apps-vtex/src/loaders/cart/full.ts b/packages/apps-vtex/src/loaders/cart/full.ts new file mode 100644 index 00000000..88e65ffe --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/full.ts @@ -0,0 +1,67 @@ +/** + * `vtex/loaders/cart/full` — the drawer/minicart loader. + * + * Returns the full canonical `Minicart`, but requests only `SECTIONS_DRAWER` + * from VTEX (not the legacy 15-section superset). Call it when the user opens + * the cart drawer — not on every page view. + * + * Like the summary loader, a first-time visitor gets an empty `Minicart` shell + * with no VTEX call. + * + * Registered in the app manifest as `vtex/loaders/cart/full` + * (`invoke.vtex.loaders.cart.full`). + */ + +import type { Minicart } from "@decocms/apps-commerce/types"; +import { SECTIONS_DRAWER } from "@decocms/apps-commerce/types"; +import { getOrCreateCartV2 } from "../../actions/checkout"; +import type { OrderForm } from "../../types"; +import { resolveOrderFormId } from "./orderFormId"; + +export interface CartFullProps { + orderFormId?: string; + /** Free-shipping threshold in major units. `0` disables the progress bar. */ + freeShippingTarget?: number; + /** Override the OrderForm's locale (BCP-47, e.g. `"pt-BR"`). */ + locale?: string; + /** Where the checkout button sends the user. Default: `/checkout`. */ + checkoutHref?: string; + /** Whether the UI should expose the coupon input. Default: `true`. */ + enableCoupon?: boolean; +} + +function emptyMinicart(props: CartFullProps): Minicart { + return { + original: null, + storefront: { + items: [], + subtotal: 0, + discounts: 0, + total: 0, + locale: props.locale ?? "pt-BR", + currency: "BRL", + enableCoupon: props.enableCoupon ?? true, + freeShippingTarget: props.freeShippingTarget ?? 0, + checkoutHref: props.checkoutHref ?? "/checkout", + }, + }; +} + +export default async function cartFull( + props: CartFullProps = {}, +): Promise> { + const orderFormId = resolveOrderFormId(props.orderFormId); + if (!orderFormId) return emptyMinicart(props); + + return (await getOrCreateCartV2({ + orderFormId, + projection: "minicart", + sections: SECTIONS_DRAWER, + minicartOptions: { + freeShippingTarget: props.freeShippingTarget, + locale: props.locale, + checkoutHref: props.checkoutHref, + enableCoupon: props.enableCoupon, + }, + })) as Minicart; +} diff --git a/packages/apps-vtex/src/loaders/cart/gifts.ts b/packages/apps-vtex/src/loaders/cart/gifts.ts new file mode 100644 index 00000000..e3b63398 --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/gifts.ts @@ -0,0 +1,43 @@ +/** + * `vtex/loaders/cart/gifts` — selectable gifts + promotion teasers only. + * + * A focused read: fetches just the `ratesAndBenefitsData` (+ `items` so gift + * eligibility resolves) and returns the gift-relevant slice, so a "you earned a + * gift" UI does not have to load the whole cart. Empty for visitors with no + * cart yet (no VTEX call). + */ + +import { getOrCreateCartV2 } from "../../actions/checkout"; +import type { OrderForm } from "../../types"; +import { resolveOrderFormId } from "./orderFormId"; + +export interface CartGiftsProps { + orderFormId?: string; +} + +export interface CartGifts { + orderFormId: string | null; + /** VTEX `selectableGifts` — gifts the shopper can choose. */ + selectableGifts: unknown[]; + /** VTEX `ratesAndBenefitsData` — active promotions / teasers. */ + ratesAndBenefits: OrderForm["ratesAndBenefitsData"] | null; +} + +const EMPTY: CartGifts = { orderFormId: null, selectableGifts: [], ratesAndBenefits: null }; + +export default async function cartGifts(props: CartGiftsProps = {}): Promise { + const orderFormId = resolveOrderFormId(props.orderFormId); + if (!orderFormId) return EMPTY; + + const orderForm = (await getOrCreateCartV2({ + orderFormId, + projection: "raw", + sections: ["items", "ratesAndBenefitsData", "messages"], + })) as OrderForm; + + return { + orderFormId: orderForm.orderFormId ?? null, + selectableGifts: orderForm.selectableGifts ?? [], + ratesAndBenefits: orderForm.ratesAndBenefitsData ?? null, + }; +} diff --git a/packages/apps-vtex/src/loaders/cart/orderFormId.ts b/packages/apps-vtex/src/loaders/cart/orderFormId.ts new file mode 100644 index 00000000..16ef781b --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/orderFormId.ts @@ -0,0 +1,31 @@ +/** + * Read the current visitor's `orderFormId` from the request cookie. + * + * Shared by every Cart v2 loader. Returns `undefined` for first-time visitors + * (no cart yet) — loaders MUST treat that as "empty, do not create" so a page + * view never provisions a zero-item OrderForm on VTEX. + */ + +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; + +export const ORDER_FORM_COOKIE = "checkout.vtex.com__orderFormId"; + +/** Extract `orderFormId` from the inbound request cookie header, if present. */ +export function readOrderFormIdFromRequest(cookieName = ORDER_FORM_COOKIE): string | undefined { + const ctx = RequestContext.current; + const cookieHeader = ctx?.request.headers.get("cookie"); + if (!cookieHeader) return undefined; + const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); + return match?.[1] ? decodeURIComponent(match[1]) : undefined; +} + +/** + * Resolve the orderFormId a loader should use: an explicit prop wins, else the + * cookie. `undefined` means "no cart exists yet". + */ +export function resolveOrderFormId( + explicit?: string, + cookieName = ORDER_FORM_COOKIE, +): string | undefined { + return explicit ?? readOrderFormIdFromRequest(cookieName); +} diff --git a/packages/apps-vtex/src/loaders/cart/shipping.ts b/packages/apps-vtex/src/loaders/cart/shipping.ts new file mode 100644 index 00000000..942e9fa8 --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/shipping.ts @@ -0,0 +1,73 @@ +/** + * `vtex/loaders/cart/shipping` — shipping options for the drawer. + * + * Runs a VTEX cart simulation for the given items + postal code and returns a + * normalized, de-duplicated list of shipping SLAs (price in major units). This + * is separate from the cart mutation path so the drawer can show delivery + * estimates without re-fetching the whole OrderForm. + * + * NOTE on caching: shipping options for a fixed {items, postalCode, sc} are not + * user-personalized, so they are cache-friendly in principle. But the + * simulation is a POST and `vtexCachedFetch` only caches GET, and `simulateCart` + * intentionally uses `vtexFetchWithCookies` because the endpoint can rotate + * segment/ownership cookies. Caching therefore needs a bespoke `fetchWithCache` + * key that excludes cookies — deferred to a follow-up rather than risk + * cookie-drift here. + */ + +import { type SimulationItem, simulateCart } from "../../actions/checkout"; + +const CENTS_PER_MAJOR = 100; + +export interface CartShippingProps { + items: SimulationItem[]; + postalCode: string; + country?: string; +} + +export interface ShippingOption { + id: string; + name: string; + /** Price in major units. */ + price: number; + shippingEstimate: string; + deliveryChannel?: string; +} + +export interface CartShipping { + postalCode: string; + options: ShippingOption[]; +} + +interface SimSla { + id: string; + name: string; + price: number; + shippingEstimate: string; + deliveryChannel?: string; +} + +export default async function cartShipping(props: CartShippingProps): Promise { + const { items, postalCode, country } = props; + if (!items?.length || !postalCode) return { postalCode, options: [] }; + + const sim = await simulateCart({ items, postalCode, country }); + const logisticsInfo: Array<{ slas?: SimSla[] }> = sim?.logisticsInfo ?? []; + + // Collapse SLAs across all items into one unique-by-id option list. + const byId = new Map(); + for (const info of logisticsInfo) { + for (const sla of info.slas ?? []) { + if (byId.has(sla.id)) continue; + byId.set(sla.id, { + id: sla.id, + name: sla.name, + price: (sla.price ?? 0) / CENTS_PER_MAJOR, + shippingEstimate: sla.shippingEstimate, + deliveryChannel: sla.deliveryChannel, + }); + } + } + + return { postalCode, options: [...byId.values()] }; +} diff --git a/packages/apps-vtex/src/loaders/cart/summary.ts b/packages/apps-vtex/src/loaders/cart/summary.ts new file mode 100644 index 00000000..9d982e22 --- /dev/null +++ b/packages/apps-vtex/src/loaders/cart/summary.ts @@ -0,0 +1,36 @@ +/** + * `vtex/loaders/cart/summary` — the badge loader. + * + * Returns the smallest useful cart shape: `{ orderFormId, totalItems, total }`. + * For a first-time visitor (no orderFormId cookie) it returns an empty summary + * **without touching VTEX** — this is the "don't create a cart on page load" + * guarantee. Only visitors who already have a cart pay a checkout API call, and + * that call requests just `items`+`totalizers` (via `SECTIONS_MINIMAL`). + * + * Registered in the app manifest as `vtex/loaders/cart/summary`, so it is + * invoke-callable from the client (`invoke.vtex.loaders.cart.summary`) in both + * Next.js and TanStack Start. + */ + +import type { CartSummary } from "@decocms/apps-commerce/types"; +import { SECTIONS_MINIMAL } from "@decocms/apps-commerce/types"; +import { getOrCreateCartV2 } from "../../actions/checkout"; +import { resolveOrderFormId } from "./orderFormId"; + +export interface CartSummaryProps { + /** Override the orderFormId; defaults to the request cookie. */ + orderFormId?: string; +} + +const EMPTY_SUMMARY: CartSummary = { orderFormId: null, totalItems: 0, total: 0 }; + +export default async function cartSummary(props: CartSummaryProps = {}): Promise { + const orderFormId = resolveOrderFormId(props.orderFormId); + if (!orderFormId) return EMPTY_SUMMARY; + + return (await getOrCreateCartV2({ + orderFormId, + projection: "summary", + sections: SECTIONS_MINIMAL, + })) as CartSummary; +} diff --git a/packages/apps-vtex/src/manifest.gen.ts b/packages/apps-vtex/src/manifest.gen.ts index 33732be9..188a9d47 100644 --- a/packages/apps-vtex/src/manifest.gen.ts +++ b/packages/apps-vtex/src/manifest.gen.ts @@ -17,6 +17,11 @@ import * as loaders_address from "./loaders/address"; import * as loaders_autocomplete from "./loaders/autocomplete"; import * as loaders_brands from "./loaders/brands"; import * as loaders_cart from "./loaders/cart"; +import * as loaders_cart_attachments from "./loaders/cart/attachments"; +import * as loaders_cart_full from "./loaders/cart/full"; +import * as loaders_cart_gifts from "./loaders/cart/gifts"; +import * as loaders_cart_shipping from "./loaders/cart/shipping"; +import * as loaders_cart_summary from "./loaders/cart/summary"; import * as loaders_catalog from "./loaders/catalog"; import * as loaders_collections from "./loaders/collections"; import * as loaders_legacy from "./loaders/legacy"; @@ -35,44 +40,49 @@ import * as loaders_wishlistProducts from "./loaders/wishlistProducts"; import * as loaders_workflow from "./loaders/workflow"; const manifest = { - name: "vtex", - loaders: { - "vtex/loaders/address": loaders_address, - "vtex/loaders/autocomplete": loaders_autocomplete, - "vtex/loaders/brands": loaders_brands, - "vtex/loaders/cart": loaders_cart, - "vtex/loaders/catalog": loaders_catalog, - "vtex/loaders/collections": loaders_collections, - "vtex/loaders/legacy": loaders_legacy, - "vtex/loaders/logistics": loaders_logistics, - "vtex/loaders/navbar": loaders_navbar, - "vtex/loaders/orders": loaders_orders, - "vtex/loaders/pageType": loaders_pageType, - "vtex/loaders/payment": loaders_payment, - "vtex/loaders/profile": loaders_profile, - "vtex/loaders/promotion": loaders_promotion, - "vtex/loaders/search": loaders_search, - "vtex/loaders/session": loaders_session, - "vtex/loaders/user": loaders_user, - "vtex/loaders/wishlist": loaders_wishlist, - "vtex/loaders/wishlistProducts": loaders_wishlistProducts, - "vtex/loaders/workflow": loaders_workflow, - }, - actions: { - "vtex/actions/address": actions_address, - "vtex/actions/analytics/sendEvent": actions_analytics_sendEvent, - "vtex/actions/auth": actions_auth, - "vtex/actions/checkout": actions_checkout, - "vtex/actions/masterData": actions_masterData, - "vtex/actions/misc": actions_misc, - "vtex/actions/newsletter": actions_newsletter, - "vtex/actions/orders": actions_orders, - "vtex/actions/profile": actions_profile, - "vtex/actions/session": actions_session, - "vtex/actions/trigger": actions_trigger, - "vtex/actions/wishlist": actions_wishlist, - }, - sections: {}, + name: "vtex", + loaders: { + "vtex/loaders/address": loaders_address, + "vtex/loaders/autocomplete": loaders_autocomplete, + "vtex/loaders/brands": loaders_brands, + "vtex/loaders/cart": loaders_cart, + "vtex/loaders/cart/summary": loaders_cart_summary, + "vtex/loaders/cart/full": loaders_cart_full, + "vtex/loaders/cart/shipping": loaders_cart_shipping, + "vtex/loaders/cart/gifts": loaders_cart_gifts, + "vtex/loaders/cart/attachments": loaders_cart_attachments, + "vtex/loaders/catalog": loaders_catalog, + "vtex/loaders/collections": loaders_collections, + "vtex/loaders/legacy": loaders_legacy, + "vtex/loaders/logistics": loaders_logistics, + "vtex/loaders/navbar": loaders_navbar, + "vtex/loaders/orders": loaders_orders, + "vtex/loaders/pageType": loaders_pageType, + "vtex/loaders/payment": loaders_payment, + "vtex/loaders/profile": loaders_profile, + "vtex/loaders/promotion": loaders_promotion, + "vtex/loaders/search": loaders_search, + "vtex/loaders/session": loaders_session, + "vtex/loaders/user": loaders_user, + "vtex/loaders/wishlist": loaders_wishlist, + "vtex/loaders/wishlistProducts": loaders_wishlistProducts, + "vtex/loaders/workflow": loaders_workflow, + }, + actions: { + "vtex/actions/address": actions_address, + "vtex/actions/analytics/sendEvent": actions_analytics_sendEvent, + "vtex/actions/auth": actions_auth, + "vtex/actions/checkout": actions_checkout, + "vtex/actions/masterData": actions_masterData, + "vtex/actions/misc": actions_misc, + "vtex/actions/newsletter": actions_newsletter, + "vtex/actions/orders": actions_orders, + "vtex/actions/profile": actions_profile, + "vtex/actions/session": actions_session, + "vtex/actions/trigger": actions_trigger, + "vtex/actions/wishlist": actions_wishlist, + }, + sections: {}, } as const; export type Manifest = typeof manifest; diff --git a/packages/apps-vtex/src/utils/__tests__/cartProjection.test.ts b/packages/apps-vtex/src/utils/__tests__/cartProjection.test.ts new file mode 100644 index 00000000..1b067b13 --- /dev/null +++ b/packages/apps-vtex/src/utils/__tests__/cartProjection.test.ts @@ -0,0 +1,118 @@ +/** + * Tests for `projectOrderForm` — the server-side fragmentation of a VTEX + * OrderForm into the requested `CartProjection`. Pure function, no I/O. + */ + +import type { Minicart } from "@decocms/apps-commerce/types"; +import { describe, expect, it } from "vitest"; +import type { OrderForm } from "../../types"; +import { projectOrderForm } from "../cartProjection"; + +function makeOrderForm(overrides: Partial = {}): OrderForm { + return { + orderFormId: "of-123", + salesChannel: "1", + loggedIn: false, + isCheckedIn: false, + allowManualPrice: false, + canEditData: true, + ignoreProfileData: false, + value: 15000, // R$150.00 in cents + messages: [], + items: [ + { + id: "sku-1", + productId: "prod-1", + name: "Camiseta", + skuName: "Camiseta P Azul", + imageUrl: "http://img.example/x.jpg", + detailUrl: "/camiseta/p", + price: 10000, + listPrice: 12000, + sellingPrice: 10000, + quantity: 1, + seller: "1", + uniqueId: "u1", + }, + { + id: "sku-2", + productId: "prod-2", + name: "Boné", + skuName: "Boné U Preto", + imageUrl: "https://img.example/y.jpg", + detailUrl: "/bone/u", + price: 5000, + listPrice: 5000, + sellingPrice: 5000, + quantity: 2, + seller: "1", + uniqueId: "u2", + }, + ], + totalizers: [ + { id: "Items", name: "Items", value: 20000 }, + { id: "Discounts", name: "Discounts", value: -5000 }, + ], + shippingData: null, + clientProfileData: null, + paymentData: null, + marketingData: null, + ...overrides, + } as OrderForm; +} + +describe("projectOrderForm", () => { + it("none → { ok: true } and discards the payload", () => { + expect(projectOrderForm(makeOrderForm(), "none")).toEqual({ ok: true }); + }); + + it("summary → orderFormId, summed quantities, total in major units", () => { + const r = projectOrderForm(makeOrderForm(), "summary") as { + orderFormId: string; + totalItems: number; + total: number; + }; + expect(r.orderFormId).toBe("of-123"); + expect(r.totalItems).toBe(3); // 1 + 2 + expect(r.total).toBe(150); // 15000 cents → 150 + }); + + it("summary+items → summary plus slim, https-normalized items", () => { + const r = projectOrderForm(makeOrderForm(), "summary+items") as { + totalItems: number; + items: Array<{ item_name: string; image: string; price: number; quantity: number }>; + }; + expect(r.totalItems).toBe(3); + expect(r.items).toHaveLength(2); + expect(r.items[0]).toMatchObject({ + item_name: "Camiseta", + item_variant: "Camiseta P Azul", + image: "https://img.example/x.jpg", // http → https + price: 100, // 10000 cents + quantity: 1, + }); + // slim projection must NOT leak the full VTEX item fields + expect(r.items[0]).not.toHaveProperty("uniqueId"); + }); + + it("minicart → canonical Minicart with major-unit totals", () => { + const r = projectOrderForm(makeOrderForm(), "minicart") as Minicart; + expect(r.storefront.items).toHaveLength(2); + expect(r.storefront.total).toBe(150); + expect(r.original.orderFormId).toBe("of-123"); + }); + + it("raw → the untouched OrderForm", () => { + const of = makeOrderForm(); + expect(projectOrderForm(of, "raw")).toBe(of); + }); + + it("summary handles an empty cart without NaN", () => { + const r = projectOrderForm( + makeOrderForm({ items: [], value: 0, totalizers: [] }), + "summary", + ) as { totalItems: number; total: number }; + expect(r.totalItems).toBe(0); + expect(r.total).toBe(0); + }); +}); diff --git a/packages/apps-vtex/src/utils/cartProjection.ts b/packages/apps-vtex/src/utils/cartProjection.ts new file mode 100644 index 00000000..a368bdb0 --- /dev/null +++ b/packages/apps-vtex/src/utils/cartProjection.ts @@ -0,0 +1,96 @@ +/** + * Project a VTEX OrderForm down to a requested `CartProjection`. + * + * The VTEX checkout API always returns the whole OrderForm from mutation + * endpoints (bounded only by `expectedOrderFormSections`) — there is no + * delta/patch response. So "return the minimum" is enforced **server-side**: + * we take whatever VTEX gave us and shape it into `none` / `summary` / + * `summary+items` / `minicart` / `raw` before it reaches the browser. + * + * Pure function — no I/O, fully unit-testable. All monetary values in the + * projected output are in major units (VTEX is cents). + * + * @see `@decocms/apps-commerce/types/cart` for the agnostic contract. + */ + +import type { + CartItemSlim, + CartOk, + CartProjection, + CartSummary, + CartSummaryWithItems, + Minicart, +} from "@decocms/apps-commerce/types"; +import type { OrderForm, OrderFormItem } from "../types"; +import { type VtexOrderFormToMinicartOptions, vtexOrderFormToMinicart } from "./minicart"; + +const CENTS_PER_MAJOR = 100; + +function fromCents(cents: number | undefined | null): number { + if (cents == null || !Number.isFinite(cents)) return 0; + return cents / CENTS_PER_MAJOR; +} + +/** Sum of line quantities. */ +function countItems(orderForm: OrderForm): number { + return (orderForm.items ?? []).reduce((sum, i) => sum + (i.quantity ?? 0), 0); +} + +function toSummary(orderForm: OrderForm): CartSummary { + return { + orderFormId: orderForm.orderFormId ?? null, + totalItems: countItems(orderForm), + total: fromCents(orderForm.value), + }; +} + +/** Map a VTEX line to the slim, analytics-compatible `CartItemSlim`. */ +function toSlimItem(item: OrderFormItem): CartItemSlim { + return { + item_id: item.id, + item_name: item.name ?? item.skuName ?? "", + item_variant: item.skuName, + image: item.imageUrl?.replace(/^http:/, "https:") ?? "", + price: fromCents(item.sellingPrice ?? item.price), + quantity: item.quantity, + }; +} + +export type ProjectOrderFormOptions = VtexOrderFormToMinicartOptions; + +/** The projected result type, with VTEX-concrete `minicart`/`raw` payloads. */ +export type VtexCartProjectionResult = + | CartOk + | CartSummary + | CartSummaryWithItems + | Minicart + | OrderForm; + +/** + * Shape a VTEX OrderForm into the requested projection. + * + * @param orderForm - Raw OrderForm from a VTEX checkout call. + * @param projection - Desired client-facing shape. + * @param opts - Storefront overrides forwarded to the `minicart` projection. + */ +export function projectOrderForm( + orderForm: OrderForm, + projection: CartProjection, + opts: ProjectOrderFormOptions = {}, +): VtexCartProjectionResult { + switch (projection) { + case "none": + return { ok: true }; + case "summary": + return toSummary(orderForm); + case "summary+items": + return { + ...toSummary(orderForm), + items: (orderForm.items ?? []).map(toSlimItem), + }; + case "minicart": + return vtexOrderFormToMinicart(orderForm, opts); + case "raw": + return orderForm; + } +}