create-cartbase 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1211 +1,1290 @@
1
- # Components
2
-
3
- The component layer of `@cartbase/storefront`: what each family ships, the SDK
4
- calls it requires, the admin settings that change its behavior, and its mount
5
- rules. Every family is production-proven — ported from live commerce
6
- storefronts and rewired to Cartbase data seams.
7
-
8
- This page has no executable curls — the endpoints these components consume
9
- are executably documented in their own domain files
10
- ([integrations.md](integrations.md), [consent.md](consent.md), etc.); this
11
- page is the component-side contract that binds to them.
12
-
13
- Import discipline: always import from the subpath
14
- (`@cartbase/storefront/tracking/meta-pixel`, `@cartbase/storefront/lib/money`) —
15
- tree-shaking and Next.js RSC boundary detection both work better than via
16
- barrels. Styling resolves against STOREFRONT theme tokens (`bg-card`,
17
- `text-foreground`, …, shadcn-standard names) — `@import
18
- "@cartbase/storefront/theme"` in the app's CSS supplies both the token names
19
- and a filled default set of values, so components render designed out of the
20
- box. No component hardcodes a colour; retheming means overriding values, never
21
- renaming tokens.
22
-
23
- ---
24
-
25
- ## Language: the library is English, a language is a pack
26
-
27
- No component holds copy. Every string comes from a label pack, and the
28
- library's own defaults are **international English** (`products/labels`,
29
- `checkout/labels`, and so on, beside each family's type).
30
-
31
- A language is one object covering all six families, imported by its own
32
- subpath so a store bundles only what it ships:
33
-
34
- ```tsx
35
- import { es } from "@cartbase/storefront/locales/es"
36
- import { StorefrontLocaleProvider } from "@cartbase/storefront/locales"
37
-
38
- <StorefrontLocaleProvider locale={es}>{children}</StorefrontLocaleProvider>
39
- ```
40
-
41
- Shipping: `locales/en`, `locales/es`, `locales/bg`. Each is typed
42
- `StorefrontLocale`, so a label added to the library breaks an incomplete
43
- pack at compile time instead of falling back to English mid-checkout.
44
-
45
- The provider mounts the three pure label contexts (products, checkout,
46
- order). It deliberately does NOT mount the cart drawer, whose provider
47
- carries cart state, and two families read labels from a prop. Hand those
48
- their pack:
49
-
50
- ```tsx
51
- const locale = useStorefrontLocale()
52
- <CartDrawerProvider cart={cart} labels={locale.cartDrawer}>
53
- <StoreTemplate labels={locale.store} />
54
- <ReviewWidget labels={locale.reviews} />
55
- ```
56
-
57
- **Failure copy is copy.** What a shopper reads when an address, a payment, a
58
- gift card or a discount code fails lives in the checkout pack too
59
- (`addressErrors`, `paymentErrors`, `giftCardErrors`, `promotionErrors`).
60
- The modules that recognize each failure keep their English patterns,
61
- because those match the API's wire format and are never shown to anyone.
62
-
63
- Overriding one word is a partial: every provider merges over the defaults.
64
-
65
- ---
66
-
67
- ## Family: tracking (`@cartbase/storefront/tracking/*`)
68
- Meta Pixel + GA4 + Rybbit + Consent Mode v2. The Cartbase split of duties:
69
- **this package fires client events and writes attribution; server-side CAPI /
70
- GA4 Measurement Protocol sending is Cartbase-backend-owned** (the
71
- `order.placed` forwarder). The whole family is safe to render
72
- unconditionally every component renders nothing when its id prop is absent,
73
- every helper no-ops outside the browser.
74
-
75
- ### `<ConsentInit />` — `tracking/consent-init`
76
-
77
- - **Purpose** the synchronous Consent Mode v2 "default" snippet: reads the
78
- `_1c_consent` cookie and sets gtag's consent default (denied when no stored
79
- choice) before any Google tag loads.
80
- - **SDK calls** — none. It must NEVER wait on a fetch: the per-store config
81
- drives the banner, not this default (async default = first-hit consent
82
- race).
83
- - **Mount rules** — FIRST child of `<body>` in the root layout, before any
84
- tag component. Plain inline `<script>` by design (not next/script).
85
- - **Settings** — none (static; the shared cookie name is baked in).
86
-
87
- ### `<ConsentBanner copy layout privacyHref rejectOnFirstLayer />` `tracking/consent-banner`
88
-
89
- - **Purpose** the built-in two-layer CMP UI; writes the `_1c_consent`
90
- cookie and applies the live `gtag('consent','update')` + `fbq('consent')`.
91
- - **SDK calls** `GET /api/store/consent` (via `@cartbase/storefront/api/consent`)
92
- for the store's config; pass `copy = pickConsentCopy(consent.copy, locale)`,
93
- plus `layout` / `privacy_href` / `reject_on_first_layer` from the payload.
94
- - **Mount rules** mount ONLY when `shouldRenderBanner(consent)` (i.e.
95
- `enabled && mode === "builtin"`). In `mode: "external"` render nothing —
96
- the merchant's CMP must write the same `_1c_consent` cookie or call
97
- `setConsent()`; all gating keeps working off that one seam. z-index is
98
- `z-[70]` (above the cart drawer's `z-[60]`).
99
- - **Settings** the consent card config (admin Settings → Consent):
100
- `enabled`, `mode`, `layout` (`modal` blocks + scroll-locks;
101
- `banner-bottom` is non-blocking and must not trap focus), `privacy_href`,
102
- `reject_on_first_layer`, per-locale `copy`.
103
- - Also ships `<ConsentSettingsLink>` (footer link that re-opens the settings
104
- layer"withdraw as easily as given") and the pure `<ConsentBannerCard>`.
105
-
106
- ### `<MetaPixel pixelId />` — `tracking/meta-pixel`
107
-
108
- - **Purpose** injects fbevents.js, pushes the consent state from
109
- `_1c_consent` BEFORE `fbq('init')` (pre-init revoke = Pixel queues events
110
- until grant), fires the initial PageView.
111
- - **SDK calls** `getTrackingConfig(client)` (`tracking/get-tracking-config`,
112
- wraps `GET /api/store/integrations` `tracking.facebookPixel.pixelId`).
113
- - **Mount rules** root layout, after `<ConsentInit>`. Renders nothing when
114
- `pixelId` is falsy. When `tracking.consent_required` is true the consent
115
- gate above is mandatory (it is wired in by construction — the snippet
116
- always reads the cookie).
117
- - **Settings** — admin Integrations hub `facebook_capi` row (`enabled` +
118
- `credentials.pixel_id`); consent card `enabled` `consent_required`.
119
- - Companion: `updatePixelAdvancedMatching(visitor)` call from checkout /
120
- signup as PII becomes known; hashes em/ph/fn/ln/ct/st/zp/country
121
- (SHA-256, Meta normalization) and re-inits the Pixel so every subsequent
122
- event carries Advanced Matching. Also persists raw values for
123
- `getKnownVisitor()`.
124
-
125
- ### `<GA4 measurementId />` `tracking/ga4`
126
-
127
- - **Purpose** loads gtag.js + `gtag('config')`. gtag then owns the `_ga` /
128
- `_ga_<MEASUREMENT_ID>` cookies that `getTrackingAttribution()` later reads.
129
- - **SDK calls** — `getTrackingConfig(client)` → `tracking.ga4.measurementId`.
130
- - **Mount rules** — root layout, after `<ConsentInit>` (the consent default
131
- governs whether gtag writes cookies or sends cookieless pings). Renders
132
- nothing when falsy. SPA route changes are NOT auto-tracked fire
133
- `page_view` from a route-change effect if per-route views are wanted.
134
- - **Settings** Integrations hub `ga4` row (`credentials.measurement_id`);
135
- consent card.
136
-
137
- ### `<Rybbit siteId baseUrl? />` `tracking/rybbit`
138
-
139
- - **Purpose**the platform's self-hosted, cookieless analytics tracker;
140
- auto-tracks pageviews incl. SPA route changes.
141
- - **SDK calls** NONE. Rybbit is deliberately absent from the store
142
- `tracking` block: platform-provisioned, the host app passes props from
143
- platform env. Outside the consent system by design (cookieless).
144
- - **Mount rules** — root layout; renders nothing when `siteId` falsy.
145
- - **Settings**none merchant-facing.
146
-
147
- ### Client events — `tracking/events` (use this one)
148
-
149
- ONE call per commerce moment, every vendor at once. Reach for these rather
150
- than the per-vendor helpers below, because the failure they prevent is the
151
- common one and it is invisible: a store adds a vendor, updates three of the
152
- four call sites, and an ad account optimises on partial data for a month
153
- before anyone notices.
154
-
155
- | Step | Call |
156
- |---|---|
157
- | Product view | `trackProductView({ line, currency, value })` |
158
- | Add to cart | `trackCartAdd({ line, currency, value })` |
159
- | Checkout start | `trackCheckoutStart({ lines, currency, value, coupon? })` |
160
- | Order confirmed | `trackOrderPurchase(order, trackingConfig)` |
161
-
162
- A `line` is `{ productId, variantId?, title, quantity, price }`. Pass the
163
- PRODUCT id: Meta's content_ids, TikTok's content_id and the catalogue
164
- feed's `<g:id>` must be the same value or the event matches no catalogue
165
- entry, which costs dynamic ads on both platforms. GA4 is keyed by variant
166
- instead, on purpose, and these helpers do that split for you.
167
-
168
- `trackOrderPurchase` takes the tracking config as its second argument
169
- because Google Ads only fires when the store configured BOTH the account id
170
- and the purchase label. Pass `order.metadata.customer_type` through as
171
- `customerType` when present: it is computed once server-side, so the
172
- browser and the server tell Google and TikTok the same thing.
173
-
174
- ### Per-vendor helpers — `tracking/fbq`, `tracking/gtag`, `tracking/ttq`, `tracking/rybbit-events`, `tracking/google-ads`
175
-
176
- Call on the matching funnel step; all are no-op-safe (SSR, blocked, absent
177
- tag). The Rybbit helpers additionally queue-buffer through the script-load
178
- race (10s cap, v2.2.6 production fix).
179
-
180
- | Step | Meta (`fbq.ts`) | TikTok (`ttq.ts`) | GA4 (`gtag.ts`) | Rybbit (`rybbit-events.ts`) |
181
- |---|---|---|---|---|
182
- | Product view | `trackViewContent` | `trackTikTokViewContent` | `trackGAViewItem` | `trackRybbitViewItem` |
183
- | Add to cart | `trackAddToCart` | `trackTikTokAddToCart` | `trackGAAddToCart` | `trackRybbitAddToCart` |
184
- | Checkout start | `trackInitiateCheckout` | `trackTikTokInitiateCheckout` | `trackGABeginCheckout` | `trackRybbitBeginCheckout` |
185
- | Order confirmed | `trackPurchase(data, order.display_id)` | `trackTikTokPurchase({…, displayId})` | `trackGAPurchase({transaction_id: String(order.display_id), …})` | `trackRybbitPurchase` |
186
- | Newsletter/popup signup | `trackLead` | | | — |
187
-
188
- Google Ads has one event, the purchase conversion:
189
- `trackGoogleAdsPurchase({ sendTo, value, currency, transactionId, newCustomer? })`,
190
- with `sendTo` from `googleAdsPurchaseSendTo(config)` (null when the label
191
- is not configured skip the conversion rather than fire a malformed
192
- `send_to`, which Google accepts and silently drops).
193
-
194
- **THE Purchase dedupe contract** (must never drift; mirrored server-side in
195
- `src/lib/tracking/constants.ts`):
196
-
197
- - Meta: browser Pixel `eventID` = **`"purchase_" + order.display_id`** — the
198
- exact id the backend `order.placed` CAPI Purchase uses. `trackPurchase()`
199
- builds it from the caller's `display_id` so the format cannot drift. Meta
200
- dedupes by event_name + event_id (~3 days).
201
- - TikTok: browser pixel `event_id` = **`"tt_purchase_" + order.display_id`**,
202
- the same id the backend Events API Purchase sends. The `tt_` prefix keeps
203
- it from colliding with Meta's on a page carrying both pixels. TikTok
204
- dedupes on event_source_id + event + event_id and discards duplicates for
205
- 48 hours; the backend ALSO carries an idempotency flag, because 48 hours
206
- does not cover an order email opened on day four.
207
- - GA4: `transaction_id = String(order.display_id)` on both browser event and
208
- backend Measurement Protocol GA4 dedupes by transaction_id natively.
209
- - Google Ads: `transaction_id` again, the same value. Never send it empty:
210
- an empty transaction_id dedupes nothing, so every re-open of the
211
- confirmation page counts another conversion.
212
- - All other events get `<eventname>_<unixSeconds>_<6hex>` ids
213
- (`generateEventId`).
214
-
215
- `setEnhancedConversions(input)` (gtag.ts) — Google Ads Enhanced Conversions
216
- for Web: hashes email/phone/names client-side and `gtag('set','user_data',…)`.
217
- Call alongside `updatePixelAdvancedMatching` at checkout. The phone is
218
- normalised to E.164 WITH the plus, which is NOT the digits-only string Meta
219
- wants: two vendors, two normalisers, and merging them re-creates a defect
220
- that fails silently and costs match rate with nothing in any log.
221
-
222
- ### Attribution `tracking/attribution`, `tracking/get-tracking-attribution`, `tracking/use-engagement-time`
223
-
224
- - Browser side (call from a top-level client component, e.g. `TrackInit`):
225
- `captureUtmsFromUrl()` (first-touch `_1c_utm_first` 365d, last-touch
226
- `_1c_utm_last` 90d), `getOrCreateFbp()` / `getOrCreateFbc()` (defensive
227
- `_fbp`/`_fbc` writes so the Pixel-load race never ships empty match keys —
228
- production fix), `getOrCreateAnonId()` (`_1c_anon`, the guest
229
- `external_id`), `initEngagementTime()`.
230
- - Server side, at checkout completion:
231
- `getTrackingAttribution(clientHints?, opts?)` reads the cookies + headers
232
- and returns the `TrackingAttribution` keys to write into `cart.metadata`
233
- (CONSENT-GATED — only when the visitor's choice allows). Cart completion
234
- copies them to `order.metadata`; the backend forwarder inherits
235
- fbp/fbc/anon-id/ga signals from there. Pass
236
- `{ engagementTimeMsec: getEngagementTimeMsec() }` as clientHints, and
237
- either `opts.ga4MeasurementId` or `opts.client` so the `_ga_<id>` session
238
- cookie can be located.
239
- - **Settings** Integrations hub rows (which signals exist), consent card
240
- (whether capture happens).
241
-
242
- ---
243
-
244
- ## Family: primitives (`@cartbase/storefront/primitives/*`)
245
- Generic shadcn-style building blocks. No SDK calls, no admin settings —
246
- pure UI over the theme tokens.
247
-
248
- - `primitives/field` `<Field label … />`: floating-label input (checkout
249
- house style) with the `pulse` attention cue (soft blue 2-cycle pulse,
250
- distinct from focus/error production UX fix).
251
- - `primitives/select-field` `<SelectField label>…options…</SelectField>`:
252
- floating-label native select (native mobile keyboards).
253
- - `primitives/ui/*` shadcn-standard `button` (cva variants incl.
254
- `default/destructive/outline/secondary/ghost/link`), `input`, `label`,
255
- `select`, `dialog`, `sheet`, `tabs`, `accordion`, `collapsible`,
256
- `popover`. Radix-backed, ported verbatim; import per file.
257
- - Mount rules: all are `"use client"`. Requires the Tailwind preset tokens.
258
-
259
- ---
260
-
261
- ## Family: lib (`@cartbase/storefront/lib/*`)
262
- Pure helpers — no React except `price`, no fetches. The SDK never
263
- invents server truths: prices/totals arrive computed from the API
264
- (`variant.calculated_price`, `cart.total`); these helpers only select and
265
- format.
266
-
267
- - `lib/utils` — `cn()` (clsx + tailwind-merge).
268
- - `lib/money` — `convertToLocale({amount, currency_code,})` Intl currency
269
- formatting (amounts are decimal EUR major units per the store contract);
270
- `noDivisionCurrencies`.
271
- - `lib/price` — `<Price amount currencyCode className />`: the amount in its
272
- own currency, formatted through `convertToLocale`. Every price in the
273
- library renders through it. It shows ONE currency; a store that wants a
274
- second one displayed adds it in its own locale layer rather than in
275
- every price.
276
- - `lib/cart-helpers` `isProductLine` / `isFeeLine` / `productTotal` /
277
- `productItemCount` / `findFeeLine` + `COD_FEE_METADATA_KEY`. THE single
278
- source of truth for hiding the backend-injected COD-fee line in cart
279
- surfaces while checkout/order totals show it as its own row. Affected by:
280
- COD settings (whether a fee line ever appears).
281
- - `lib/get-product-price` — `getProductPrice({product, variantId})` /
282
- `getPricesForVariant` formatted `VariantPrice` (cheapest + selected)
283
- from server-computed `calculated_price`. Reads Cartbase's flat
284
- `price_list_type` with a legacy nested shape as fallback. Affected by:
285
- price lists / B2B pricing context (what `calculated_price` contains),
286
- currency + region query context.
287
- - `lib/get-percentage-diff` — sale badge math.
288
- - `lib/product` — `isSimpleProduct` (skip the option selector for
289
- one-option-one-value products).
290
- - `lib/sort-products` — client-side re-sort of a fetched page
291
- (`price_asc|price_desc|created_at`); the API's `order` param stays the
292
- authority for paginated listings.
293
- - `lib/payment-constants` `isStripeLike` / `isPaypal` +
294
- `paymentInfoMap`. Two tender shapes since the pp_* kill: a processor
295
- `provider_id` (`pp_stripe` exact, code truth
296
- `src/lib/stripe/providers.ts`, plus legacy Medusa-era prefixes as
297
- fallbacks) or a merchant METHOD rendered by its snapshot NAME — a
298
- method never has a provider id to bucket. Affected by: connected
299
- processors + enabled methods + checkout rules (which entries ever
300
- reach the client).
301
- - `lib/store-api-error` — `storeApiError(err)`: display-boundary normalizer
302
- (capitalized message + terminal period).
303
- Catch `StoreApiError` directly instead when branching on `status`/`code`.
304
- - `lib/hooks/use-intersection`, `lib/hooks/use-toggle-state` viewport +
305
- toggle micro-hooks (client).
306
-
307
- ---
308
-
309
- ## Family: checkout (`@cartbase/storefront/checkout/*`)
310
- The full checkout page family, production-proven (deferred-
311
- intent architecture no payment session exists until Buy click) onto the
312
- Cartbase orchestration endpoints. The flow every component serves
313
- (executable ground truth: [checkout.md](checkout.md)):
314
-
315
- ```
316
- listShippingOptions(cart_id) + listPaymentProviders(cart_id) (render pickers)
317
- Buy click prepareCheckout (ONE atomic, compensated call)
318
- pp_stripe: stripe.confirmPayment(client_secret) | merchant method: skip
319
- completeCart navigate to the confirmed page
320
- ```
321
-
322
- Amounts are EUR major units and SERVER truth — components render totals,
323
- never compute money; the only client-side arithmetic is the *optimistic
324
- display* overlay (shipping/COD-fee prediction) that the server value
325
- replaces at prepare. All user-facing copy flows from `CheckoutProvider`
326
- labels (English by default; a language is a pack from `locales/*`); errors surface
327
- code-first through the error-copy maps, never raw API strings.
328
-
329
- ### `<CheckoutProvider labels orderConfirmedPath />` — `checkout/context`
330
-
331
- - **Purpose**supplies labels + the order-confirmed path template
332
- (`{id}`/`{country}` substitution) to every checkout primitive.
333
- - **SDK calls** — none.
334
- - **Mount rules** — wrap the checkout page (or any custom composition).
335
- Default path template `"/{country}/order/{id}/confirmed"`; single-country
336
- stores pass `"/order/{id}/confirmed"`.
337
- - **A NESTED PROVIDER INHERITS (0.12.0)** — mounting a second one, which is
338
- what you do when you set `orderConfirmedPath` on a page inside a
339
- `StorefrontLocaleProvider`, keeps every label from the provider above and
340
- changes only the keys you name. Before 0.12.0 it merged over the ENGLISH
341
- defaults, so a provider mounted for the path alone silently reset the
342
- whole checkout to English on an otherwise translated store. The same now
343
- holds for `ProductLabelsProvider` and `OrderLabelsProvider`.
344
- - **Settings** — none (labels come from the store's locale pack).
345
-
346
- ### `useCheckoutOrchestration(options)` `checkout/use-checkout-orchestration`
347
-
348
- - **Purpose** — THE hardened checkout state machine: address form +
349
- debounced autosave, shipping/payment selection (client-state-only
350
- pre-Buy), carrier metadata, optimistic totals, the atomic Buy click,
351
- 3DS-return handling, completed-cart detection. Every production race
352
- guard from production is preserved.
353
- - **SDK calls** — `carts.updateCart` (address autosave + tracking metadata),
354
- `customers.updateMe` (best-effort profile sync incl. Cartbase's first-class
355
- `company_name`/`company_eik`), `checkout.calculateShippingOption`
356
- (calculated-rate forward-compat), `checkout.prepareCheckout`,
357
- `checkout.syncPaymentAmount` (exposed + fired on payment-tab switch with
358
- the new `provider_id`), `checkout.refreshPaymentIfTerminal` (exposed
359
- call REACTIVELY from Elements `loaderror` only), `carts.completeCart`.
360
- - **Props contract** `{client, cart, customer, availableShippingMethods,
361
- availablePaymentMethods, countryCode?, countries?, paymentMethodFilter?,
362
- orderConfirmedPath?, onOrderPlaced?,
363
- resolveTrackingMetadata?, logError?}`. `countries` is OPTIONAL and is an
364
- override: omit it and the hook calls `GET /api/store/countries` itself, so
365
- the address form offers the countries the store's Markets declare, or the
366
- whole catalogue when it declares none (0.11.0before that, omitting it
367
- produced a ONE-country read-only field). A failure to load the list never
368
- blocks a checkout: it falls back to `countryCode` and reports through
369
- `logError` as `country_list_failed`. Fee prediction is never
370
- hardcoded: each method LISTING entry carries its own
371
- `fee_amount`/`fee_label`. `logError` replaces the legacy log writers
372
- (all production log points preserved). Returns the full orchestration
373
- surface (`performBuyClick`, `optimisticTotal(Cents)`, `deliveryReady`, ).
374
- - **Cartbase specifics** zero-remainder gift path: `prepareCheckout`
375
- returning `client_secret:null` + `provider_id:null` +
376
- `payment_method_id:null` SKIPS Stripe and completes on the gift session
377
- ([gift-cards.md](gift-cards.md)). Processors are `pp_stripe` exactly;
378
- merchant methods list as `{payment_method_id, name, kind, instructions,
379
- fee_amount, fee_label}` entries the COD-kind method wins the offline
380
- tab, else the first manual method. The charged fee reads the cart-level
381
- `payment_method_fee_total` decoration.
382
- - **Settings** — checkout rules (filter the listings + complete guard),
383
- the methods' own fee configuration (Payments settings), Stripe
384
- credentials, gift cards, `accounts_mode`.
385
-
386
- ### `<CheckoutClient />``checkout/checkout-client`
387
-
388
- - **Purpose** the assembled single-page checkout layout over the hook +
389
- every component below. Stores wanting a custom layout compose the same
390
- hook + primitives instead.
391
- - **SDK calls** — everything the hook + summary widgets call; the host
392
- fetches `listShippingOptions`/`listPaymentProviders` (+ customer, +
393
- `getIntegrationsConfig().cod`) and passes them down.
394
- - **Props contract** — hook options + `showGiftCards?`,
395
- `logoByFulfillmentOptionId?`, Stripe `appearance`/`fonts`, `onCartChange?`
396
- (receives every decorated cart from summary mutations; the layout also
397
- re-runs `syncPaymentAmount` on those).
398
- - **Mount rules** `"use client"`; inside `CheckoutProvider`; redirect
399
- server-side when `cart.completed_at` is set (the hook also flags
400
- `cartIsCompleted`).
401
-
402
- ### `<PaymentWrapper cart amount />` + Stripe scope `checkout/payment-wrapper`, `checkout/stripe-wrapper`
403
-
404
- - **Purpose** deferred-intent Stripe context: `PaymentWrapper` publishes
405
- `{stripePromise, amount, currency, appearance, fonts}`;
406
- `StripeElementsScope` mounts `<Elements mode:"payment">` where needed
407
- (`passthrough` renders children scope-less on COD-only stores);
408
- `StripeContext` boolean = "Stripe.js ready".
409
- - **SDK calls** none (env: `NEXT_PUBLIC_STRIPE_KEY`; legacy env names
410
- kept as fallbacks).
411
- - **Mount rules** — `PaymentWrapper` wraps the page ONCE with
412
- `amount={optimisticTotalCents}` (cents at this Stripe boundary only);
413
- `StripeElementsScope` lives INSIDE the payment section so a session
414
- rotation never tears down the form/tracking tree.
415
- - **Settings** — Stripe integration (whether `pp_stripe` is ever listed).
416
-
417
- ### `<CheckoutAddressForm />` (+ `<AddressSelect />`, `<CompanyDetails />`) — `checkout/address-form`, `checkout/address-select`, `checkout/company-details`
418
-
419
- - **Purpose** email + delivery address (floating-label `Field`s, 3s-idle
420
- pulse cue), saved-address picker, collapsible BG company-invoice fields
421
- (name/VAT/MOL/address → `cart.metadata` + customer profile).
422
- - **SDK calls** none directly; the hook autosaves via `updateCart` /
423
- `updateMe`. Saved addresses come from `customers.getMe()` →
424
- `addressesInRegion`.
425
- - **Props contract** everything from the hook (`formData`,
426
- `handleFormChange`, `handleFieldBlur`, `regionCountries`, `addressInput`,
427
- `addressError`, `pulseFields`); `hideCountry?` for single-country stores.
428
- The country field is a NATIVE select carrying the chosen country's flag,
429
- and it keeps `autocomplete="country"` on purpose: a custom listbox would
430
- draw a flag per row but lose browser autofill and the phone's own picker,
431
- and a checkout does not trade autofill for decoration. A list of exactly
432
- one country still renders as a read-only name, which is right for a store
433
- that truly sells to one country and is no longer the default for everyone
434
- else (0.11.0).
435
- - **Settings** — Admin Settings Markets Regions, read through
436
- `GET /api/store/countries`.
437
-
438
- ### `<CheckoutShippingMethodList />` — `checkout/shipping-method-list`
439
-
440
- - **Purpose** radio list of shipping options with inline carrier-picker
441
- expansion, free-shipping label, optional per-carrier logos, optional
442
- read-only price preview pre-address (`previewWhenAddressNotReady`).
443
- - **SDK calls** renders `checkout.listShippingOptions(client, {cart_id})`
444
- rows AS SERVED (rule-filtering + `checkout_method_order` are server-side).
445
- - **Props contract** — hook state + `econt?`/`boxnow?` picker configs
446
- (detection by the STABLE `shipping_option.data.id` `"econt-office"` /
447
- `"boxnow-locker"` — never display names; `boxnow.client` carries the SDK
448
- transport) + `logoByFulfillmentOptionId?`.
449
- - **Settings** checkout rules (`target_type=shipping_option`), method
450
- ordering, carrier integrations (which options exist at all).
451
-
452
- ### `<EcontOfficeSelector />` / `<BoxNowLockerSelector />` — `checkout/econt-office-selector`, `checkout/boxnow-locker-selector`
453
-
454
- - **Purpose** — Bulgarian office/locker pickers: nearest-3 by haversine
455
- distance (Nominatim geocode of the typed address), city-locked search
456
- with Cyrillic↔Latin normalization, selected pill + change.
457
- - **SDK calls** — Econt: none (public Econt Nomenclatures endpoint,
458
- page-level cache). BoxNow: `integrations.listBoxNowLockers(client)`
459
- ([integrations.md](integrations.md)); 503/502/network all render one
460
- "temporarily unavailable" state discover availability via
461
- `carriers.boxnow.lockers_url`, don't probe.
462
- - **Props contract** `{userCity, userAddress, selectedOffice|Locker,
463
- onSelect}` (+ `client` for BoxNow). The CHOSEN office/locker is client
464
- state; the hook writes it into `carrier_metadata` exactly once at
465
- prepare (previous carrier keys are server-side swept
466
- `_prepared_carrier_keys`).
467
- - **Settings** the carrier integrations (enabled/lockers).
468
-
469
- ### `<CheckoutPaymentMethodList />` + `<PaymentButton />` `checkout/payment-method-list`, `checkout/payment-button`
470
-
471
- - **Purpose** — pay-online vs cash-on-delivery radio rail. The online tab
472
- hosts Stripe `<PaymentElement layout:"accordion">` (every
473
- Dashboard-enabled method, no per-method code; deliberately NO
474
- `fields.billingDetails.address` overridethe strict-completeness
475
- IntegrationError fix). `PaymentButton` = the Buy button: re-entry-guarded
476
- click `performBuyClick`, cycling processing narration, translated
477
- inline errors, Price total.
478
- - **SDK calls** — renders `checkout.listPaymentProviders` results via the
479
- hook's `hasCard`/`hasCod`; the click path runs the hook's calls.
480
- - **Props contract**hook state + `buyButtonNotReady(Reason?)`,
481
- `gatePaymentUntilDelivery?` (false = always-visible payment section),
482
- `beforePaymentButton?` slot, `total` (pass `optimisticTotal`),
483
- `logError?`.
484
- - **Settings** COD integration (`labels.codNote` + fee timing), Stripe
485
- credentials, checkout rules (`target_type=payment_method`) hidden
486
- methods are also re-enforced at complete (`checkout_method_hidden`).
487
-
488
- ### `<OrderSummary />` (+ `CheckoutLineItem`, `<LineItemCard />`) — `checkout/order-summary`, `checkout/line-item-card`
489
-
490
- - **Purpose** — items (flat rows with qty pill), promo + gift-card
491
- widgets, totals breakdown (subtotal / shipping / COD fee / discount /
492
- VAT / total + gift-card tender rows UNDER the unchanged total), secure
493
- badge. `LineItemCard` is the standalone card variant.
494
- - **SDK calls** — `carts.updateLineItem` (quantity); child widgets below.
495
- Totals are rendered STRAIGHT from the cart decoration: `item_total`,
496
- `shipping_total`, `payment_method_fee_total`/`payment_method_fee_label`, `discount_total`,
497
- `tax_total`, `total`, `gift_card_total`, `gift_card_remainder`.
498
- - **Props contract** — `{client, cart, optimisticShippingCost,
499
- onOptimisticShippingClear?, optimisticCodFee?, onOptimisticCodFeeClear?,
500
- methodFeeLabel?, showGiftCards?, onCartChange?}`. Optimistic values clear
501
- automatically once the server cart catches up.
502
- - **Settings** COD integration (fee row), gift cards, promotions.
503
-
504
- ### `<DiscountSection />` — `checkout/discount-section`
505
-
506
- - **Purpose** collapsible promo-code input + applied-promotion list
507
- (percentage or fixed amount display).
508
- - **SDK calls** — `POST /api/store/carts/:id/promotions {promo_codes}`
509
- via the client transport (additive apply; the carts SDK module ships no
510
- wrapper for this route yet see [carts.md](carts.md) for the cart
511
- shape; `cart.promotions` arrives as the `{promotion:{…}}` pivot embed,
512
- unwrapped here).
513
- - **Props contract** `{client, cart, onCartChange?}`. Errors code-first
514
- via `promotion-error-copy` (`promotion_not_found`/`promotion_inactive`/…
515
- + the no-email campaign-budget heuristic).
516
- - **Settings** — promotions admin (codes, status, application method).
517
-
518
- ### `<GiftCardSection />` — `checkout/gift-card-section`
519
-
520
- - **Purpose** gift-card code input + applied-cards chips (masked
521
- `••••last4`, per-card live coverage, remove). Cartbase-new — no legacy
522
- equivalent.
523
- - **SDK calls** — `giftCards.applyGiftCard` / `giftCards.removeGiftCard`
524
- ([gift-cards.md](gift-cards.md)). Renders `cart.gift_cards[]` /
525
- `gift_card_total` / `gift_card_remainder` — server truth, no client
526
- math; totals never move (tender, not discount).
527
- - **Props contract** `{client, cart, onCartChange?}`. Error copy honors
528
- the anti-oracle contract: ONE generic message for `invalid_gift_card`
529
- (never branch on reasons the API hides), `rate_limited` for burned
530
- windows. After apply/remove the host must `syncPaymentAmount` (the
531
- CheckoutClient wiring does).
532
- - **Settings** — gift-cards admin (issue/disable, expiry, balances).
533
-
534
- ### Error-copy maps — `checkout/payment-error-copy`, `checkout/address-error-copy`, `checkout/promotion-error-copy`
535
-
536
- - **Purpose** — every raw failure actionable Bulgarian copy, CODE-FIRST
537
- against the Cartbase error envelope (`StoreApiError.code`), then the
538
- production substring layers (Stripe.js browser errors carry no Cartbase
539
- code), then a clean per-context generic. `PAYMENT_ERROR_CODE_COPY`
540
- covers EVERY documented code of complete/prepare/sync/refresh
541
- `checkout_method_hidden`, `account_required`,
542
- `gift_card_insufficient_balance`, the 402 payment family, …
543
- (unit-gated by `tests/unit/storefront-checkout.test.ts`).
544
- - **SDK calls** — none (pure).
545
- - **Mount rules**call `translatePaymentError(err, "card"|"cod")` /
546
- `translateAddressError(err)` / `translatePromotionError(err,
547
- {hasEmail})` / `translateGiftCardError(err)` at the display boundary;
548
- never show `err.message` raw.
549
-
550
- Also in the family barrel: `compareAddresses` (saved-address match
551
- detection) and the `geocode` helpers (`normalizeForMatch`,
552
- `distanceMeters`, `formatDistance`, `cleanAddress`, `geocodeAddress`)
553
- extra modules beyond the per-file exports, imported from
554
- `@cartbase/storefront/checkout`.
555
-
556
- ---
557
-
558
- ## Family: cart-drawer (`@cartbase/storefront/cart-drawer/*`)
559
- Sliding cart UI, production-proven layout. All
560
- components are `"use client"`. Money is EUR decimal major units everywhere
561
- (cart totals are SERVER truth from the decorated cart — components render,
562
- never compute; the only client arithmetic is the optimistic
563
- `unit_price × quantity` preview that the next server snapshot replaces).
564
- Domain doc for every call: [carts.md](carts.md); gift-card tender:
565
- [gift-cards.md](gift-cards.md); cross-sell sources: [products.md](products.md)
566
- + [search.md](search.md).
567
-
568
- ### `<CartDrawerProvider client cart onCartChange … />` — `cart-drawer/context`
569
-
570
- - **Purpose** the family's root: open/close state, the optimistic cart
571
- snapshot (React 19 `useOptimistic`), labels + hrefs, and the SDK-wired
572
- mutations every child uses. Auto-opens when the product-line item count
573
- rises **from a nonzero base** (the guard that keeps the late-arriving
574
- initial cart snapshot from opening the drawer on page load — so the very
575
- FIRST add does not auto-open; open explicitly via `useCartDrawer().open`
576
- / the header `CartButtonClient`, or wire `ProductActions`' `openCart`
577
- seam in a client composition). Locks body scroll while open, closes on
578
- Escape.
579
- - **SDK calls** `@cartbase/storefront/api/carts`: `createCart` (first
580
- `addItem` with no cart `region_id` falls back to the store default),
581
- `retrieveCart` (mount in `client`+`cartId` mode, and `refresh()`),
582
- `addLineItem`, `updateLineItem` (body `{quantity}` ONLY Cartbase accepts
583
- no metadata on update; quantity 0 deletes), `deleteLineItem`. Every
584
- mutation returns the FULL decorated cart, which becomes the confirmed
585
- snapshot no page refetch needed.
586
- - **Props contract** — `cart?: Cart|null` (server-fetched snapshot; prop
587
- updates win), `client?: StorefrontClient` (enables `addItem`/
588
- `updateQuantity`/`removeItem`/`refresh`), `cartId?` (retrieve-on-mount
589
- when no snapshot), `onCartChange?(cart)` (fires on every confirmed
590
- change INCLUDING first-add cart creation persist `cart.id` here),
591
- `onOptimisticError?(failure)` (the ops funnel for every failed
592
- optimistic mutation wire to store logging; replaces the source's
593
- `logEvent` backend call), `labels?: Partial<CartDrawerLabels>`,
594
- `hrefs?: {checkout, browse, productPrefix}`.
595
- - **Hook** `useCartDrawer()` → `{isOpen, open, close, toggle, cart,
596
- addItem(variantId, qty?, display?), updateQuantity(lineId, qty),
597
- removeItem(lineId), refresh, applyOptimistic, dispatchOptimistic,
598
- labels, hrefs}`. `applyOptimistic(action, serverAction)` stays for
599
- store-owned server actions (PDP add via RSC).
600
- - **Settings**store default region + enabled currencies (cart create),
601
- B2B price lists via attached customer, gift-card product flags
602
- (`is_giftcard` lines are non-discountable), automatic promotions,
603
- COD-fee integration (injects the fee line the drawer hides).
604
- - **Mount rules** — wrap the root layout; ONE provider per app. i18n via
605
- `labels` (`cart-drawer/labels` holds the English defaults; pass
606
- `locale.cartDrawer` from a pack in `locales/*` for another language).
607
-
608
- ### `<CartDrawer sidebar children />` — `cart-drawer/cart-drawer`
609
-
610
- - **Purpose** — the slide-out shell: overlay + right panel (`z-[60]`/
611
- `z-[61]` the consent banner sits above at `z-[70]`), optional desktop
612
- left sidebar slot for cross-sell. No SDK calls.
613
- - **Mount rules** — render once, inside the provider; put drawer body
614
- components in `children`. `panelClassName` composes onto the panel.
615
-
616
- ### `<CartDrawerHeader />` — `cart-drawer/header`
617
-
618
- - **Purpose** title + item-count badge + close button. Counts PRODUCT
619
- lines via `productItemCount` (`lib/cart-helpers`) so a backend-injected
620
- COD-fee line never inflates the count. No SDK calls (context cart).
621
-
622
- ### `<CartPromoBanner message variant />` `cart-drawer/promo-banner`
623
-
624
- - **Purpose** top strip message (`info|success|warning`). Pure props.
625
-
626
- ### `<CartTieredProgress tiers currencyCode />` `cart-drawer/tiered-progress`
627
-
628
- - **Purpose** — progress bar to the next shipping/discount tier with
629
- checkpoint markers. Reads `cart.total` (tax-inclusive server truth).
630
- - **Props contract** — `tiers: CartTier[]` sorted ascending;
631
- `threshold` in EUR major units (50 = €50). Pure math exported as
632
- `computeTierProgress(amount, tiers)` (unit-tested).
633
-
634
- ### `<CartItem item currencyCode>{upsell?}</CartItem>` `cart-drawer/item`
635
-
636
- - **Purpose** one line row: thumbnail, title link, variant, quantity
637
- stepper, per-line price with strikethrough (server `total` <
638
- `original_total`), remove button (optimistic, via the provider's
639
- `removeItem` → `DELETE line-items/:id`).
640
- - **Props contract** — `item: CartLineItem` (the SDK's decorated line
641
- per-line totals are server-computed), `currencyCode`, `children` =
642
- per-item upsell slot.
643
- - Subcomponents: `item/quantity` (`<CartItemQuantity lineId quantity
644
- maxQuantity? />` stepper floor 1, calls `updateQuantity` with
645
- `{quantity}`), `item/variant` (`<CartItemVariant variantTitle
646
- options? />`Cartbase lines carry the flat `variant_title` string, not
647
- an embedded variant object), `item/upsell` (`<CartItemUpsell products
648
- onAdd />` feed from `listRelatedProducts`, `variantId` included so
649
- `onAdd` can call `addItem`).
650
-
651
- ### `<CartFreeGift />`, `<CartGiftWrap />`, `<CartNotes … />`, `<CartRewardsPoints … />`
652
-
653
- - Merchandising slots (`cart-drawer/free-gift`, `gift-wrap`, `notes`,
654
- `rewards-points`) — pure props + labels; prices EUR major units. The
655
- store owns the effects: `CartGiftWrap.onToggle` → add/remove the store's
656
- gift-wrap variant via `addItem`/`removeItem`; `CartNotes.onSave`
657
- `updateCart(client, cartId, {metadata: {...cart.metadata, gift_note}})`
658
- the update route REPLACES metadata wholesale (no merge), so always
659
- spread the current `cart.metadata`; cart metadata is copied onto the
660
- order at complete. No admin settings — configured per store in code.
661
-
662
- ### `<CartCrossSellSidebar / CartCrossSellCarousel products onAdd label? />` `cart-drawer/cross-sell-*`
663
-
664
- - **Purpose** — desktop sidebar card list / horizontal strip of
665
- recommendations.
666
- - **SDK calls** feed `products` from `api/products.listProducts`
667
- (curated collection/tag) or `api/search.listRelatedProducts` (anchor
668
- complementsmanual admin picks first, deterministic fallback fills;
669
- see [search.md](search.md)). Map responses with `toCrossSellProduct`
670
- (exported from `cart-drawer/cross-sell-sidebar`; picks the first variant
671
- with a server-computed `calculated_price`, returns null unpriced — pass
672
- `currency_code` on the listing call). Wire `onAdd(productId, variantId)`
673
- `addItem(variantId)`.
674
- - **Settings** — Admin → Product → Related (manual picks); price lists
675
- (what `calculated_price` contains); publishable-key channel scope.
676
-
677
- ### `<CartSummaryBreakdown />` `cart-drawer/summary-breakdown`
678
-
679
- - **Purpose** full totals breakdown rendered EXACTLY from the decorated
680
- cart: `subtotal`, `discount_total` (>0, negated for display),
681
- `shipping_total` (once a shipping method is set; 0 renders FREE; before
682
- that "calculated at checkout"), `tax_total` (>0), `payment_method_fee_total` (>0,
683
- labeled by the server's `payment_method_fee_label`), `total`, then one row per
684
- applied gift card (masked `last4`, negated; a depleted card stays listed
685
- at 0) and `gift_card_remainder` — what the remainder provider charges.
686
- Row selection is the pure `selectSummaryRows(cart)` (unit-tested).
687
- - **SDK calls** — none directly (context cart; every cart read re-derives
688
- gift-card tender from the live ledger).
689
- - **Settings** — COD integration (fee + label), promotions, gift cards.
690
-
691
- ### `<CartStickyFooter />` `cart-drawer/sticky-footer`
692
-
693
- - **Purpose** — pre-checkout subtotal + checkout CTA. Deliberately shows
694
- `productTotal(cart.items)` (product lines only) NOT `cart.total`,
695
- which carries shipping/tax/COD checkout-context state that must not
696
- leak into the shopping drawer. Navigates to `hrefs.checkout`.
697
-
698
- ### `<CartPaymentBadges methods? badges? />`, `<CartContinueShopping />`, `<CartEmpty />`
699
-
700
- - `payment-badges` — inline SVG payment logos (visa/mastercard/googlepay/
701
- applepay/amex), overridable per store. `continue-shopping` close link.
702
- `empty` — empty state with `hrefs.browse` CTA. Pure props + labels.
703
-
704
- ### `<CartDrawerTemplate config />` `cart-drawer/template`
705
-
706
- - **Purpose** the optional default assembly; every
707
- feature opt-in via `CartDrawerConfig`. Stores wanting a different layout
708
- compose the primitives themselves inside `<CartDrawer>`.
709
- - **Props contract** — `config`: `promoBanner`, `shippingTiers`
710
- (EUR thresholds), `freeGift` (`minCartTotal` EUR), `giftWrap`,
711
- `notes`, `rewards` (`pointsPerCurrency` = points per 1 EUR —
712
- major-unit port change from the source's per-cent rate), `crossSell`
713
- (`products` or async `loader` fires once when the drawer first has
714
- items; build it from the SDK + `toCrossSellProduct`). Cross-sell adds
715
- are wired to the provider's `addItem` automatically.
716
- - **Mount rules** — renders product lines only (`isProductLine`) — a
717
- fee-only cart renders as empty rather than a fake product row.
718
-
719
- ---
720
-
721
- ## Family: products (`@cartbase/storefront/products/*`)
722
- The PDP + product-card family, production-proven. All prices
723
- render the SERVER-computed `variant.calculated_price` via
724
- `lib/get-product-price` (Cartbase's flat `price_list_type` wire shape) no
725
- component computes money. Product data is the canonical `StoreProduct`
726
- (`@cartbase/storefront/api/products`) every discovery endpoint serves.
727
-
728
- **Labels / i18n** — `products/labels` (`ProductLabels` + English defaults),
729
- and a pack from `locales/*` for any other language,
730
- `products/context` (`<ProductLabelsProvider labels>` + `useProductLabels()`;
731
- components read copy only through the context or explicit `labels` props).
732
-
733
- ### `<ProductLabelsProvider labels />` `products/context`
734
-
735
- - **SDK calls**none. **Settings** none.
736
- - **Mount rules** client component; wrap the product page (or app) once;
737
- partial `labels` merge over English defaults.
738
-
739
- ### `<Thumbnail thumbnail images size isFeatured />` `products/thumbnail`
740
-
741
- - **Purpose** — the product image tile used by cards; `ImageOff` fallback
742
- when no image exists.
743
- - **SDK calls** — none (props: `product.thumbnail` / `product.images`).
744
- - **Props** `size: "small"|"medium"|"large"|"full"|"square"` (aspect +
745
- width), `isFeatured` (11/14 aspect), `className`.
746
- - **Mount rules** — server-safe; uses `next/image` (host must allow the
747
- media domain in `next.config` images).
748
-
749
- ### `<PreviewPrice price />` — `products/preview-price`
750
-
751
- - **Purpose** card price line; strikethrough original + accent price when
752
- `price_type === "sale"`.
753
- - **SDK calls** none; takes a `VariantPrice` from
754
- `lib/get-product-price` `getProductPrice(...).cheapestPrice`.
755
- - **Settings** price lists (whether a `sale` type ever appears).
756
-
757
- ### `<ProductPrice product variant? />``products/product-price`
758
-
759
- - **Purpose** PDP price panel: "From <cheapest>" until a variant is
760
- selected, then the variant price; sale shows original + percentage off.
761
- - **SDK calls** none directly; the product must have been fetched WITH a
762
- pricing context (`currency_code`/`region_id`) or it renders the loading
763
- shimmer (no `calculated_price` no price, by design).
764
- - **Settings** price lists / B2B groups (via the Bearer JWT on the fetch),
765
- region/currency context.
766
- - **Mount rules** — client; reads labels from context.
767
-
768
- ### `<OptionSelect option current updateOption title disabled />` `products/option-select`
769
-
770
- - **Purpose** — one option row of value buttons (`product.options[]`, which
771
- carries `values[]`).
772
- - **SDK calls** — none. **Mount rules** — client; controlled by the parent.
773
-
774
- ### Pure: `products/variant-matching` (extra module, Cartbase addition)
775
-
776
- `optionsAsKeymap` / `optionsMatch` / `findMatchingVariant` — the
777
- option-choice variant resolution extracted from product-actions, reading
778
- Cartbase's option-value LINK shape (`variant.options[].value.{option_id,value}`)
779
- with a legacy flat-row fallback. Unit-tested
780
- (tests/unit/storefront-catalog.test.ts).
781
-
782
- ### `<ImageGallery images />` `products/image-gallery`
783
-
784
- - **Purpose** — stacked PDP gallery (rank order as served); first three
785
- images `priority`.
786
- - **SDK calls** — none (props: `product.images`). Server-safe.
787
-
788
- ### `<ProductActions product addToCart disabled? onAddToCart? openCart? />` — `products/product-actions`
789
-
790
- - **Purpose** THE add-to-cart panel: option selection variant
791
- resolution, URL `v_id` sync, price, stock gate, add button, mobile bar.
792
- - **SDK calls** — none itself; the injected `addToCart({variantId,
793
- quantity})` seam is the host's cart orchestration (typically `api/carts`
794
- `addLineItem` + the host's cart-id cookie). `openCart` replaces the
795
- legacy cart-drawer context import (no hard cross-family dependency).
796
- - **Stock contract**Cartbase's store surface exposes NO
797
- `inventory_quantity`; managed-inventory variants are optimistically in
798
- stock and the SERVER enforces at add (400 `insufficient_inventory`, which
799
- flips the button to the out-of-stock state). `!manage_inventory` and
800
- `allow_backorder` are always addable.
801
- - **Settings** inventory (manage/backorder flags, kit components at add),
802
- price lists, promotions (server re-applies on add).
803
- - **Mount rules** client; needs `ProductLabelsProvider` for non-English.
804
- Fire the tracking trio (`trackAddToCart`/GA4/Rybbit) from `onAddToCart`.
805
-
806
- ### `<MobileActions />` `products/mobile-actions`
807
-
808
- - **Purpose** the `lg:hidden` sticky bottom bar + options bottom sheet
809
- (z-[75], above the cart drawer's z-[60]) shown when the desktop actions
810
- scroll out of view.
811
- - **SDK calls** none; pure props from `ProductActions` (which mounts it —
812
- rarely used directly).
813
-
814
- ### `<ProductTabs product />` — `products/product-tabs`
815
-
816
- - **Purpose** accordion: product information (material, origin, type,
817
- weight, dimensions) + static shipping/returns copy from labels.
818
- - **SDK calls** — none. **Settings** — none (copy via labels).
819
-
820
- ### `<ProductInfo product />` — `products/product-info`
821
-
822
- - **Purpose** collection link (`/collections/<handle>`), title,
823
- description. **SDK calls** — none. Server-safe.
824
-
825
- ### `<ProductPreview product isFeatured? />` `products/product-preview`
826
-
827
- - **Purpose** THE product card (links `/products/<handle>`): thumbnail +
828
- title + cheapest price. Reused by store grids, search results, related
829
- strip; every template accepts `renderProduct` to swap it for a custom
830
- card.
831
- - **SDK calls** none; expects a `StoreProduct` fetched with pricing
832
- context for the price line. Server-safe.
833
-
834
- ### `<RelatedProducts client product pricingContext? limit? labels? renderProduct? />` — `products/related-products`
835
-
836
- - **Purpose** the "You might also like" strip.
837
- - **SDK calls** `api/search` `listRelatedProducts(product.id)` — manual
838
- admin picks first, deterministic fallback fills to `limit`
839
- (`auto_filled`); anchor never appears. Renders nothing on empty/404.
840
- - **Settings** — Admin → Product → Related (manual picks), price lists.
841
- - **Mount rules** — async server component; render inside `<Suspense>`.
842
-
843
- ### `<ProductActionsWrapper client id pricingContext? addToCart … />` — `products/product-actions-wrapper`
844
-
845
- - **Purpose** re-fetches the product with the LIVE pricing context (and
846
- the client's Bearer JWT → group-aware B2B prices) and mounts
847
- `ProductActions`; the PDP shell stays cacheable.
848
- - **SDK calls** — `api/products` `retrieveProduct(idOrHandle,
849
- pricingContext)`; 404 renders nothing.
850
- - **Mount rules** — async server component inside `<Suspense>` (fallback:
851
- disabled `<ProductActions>`).
852
-
853
- ### `<ProductTemplate client product pricingContext? addToCart onAddToCart? openCart? />` `products/product-template`
854
-
855
- - **Purpose** — the full PDP: sticky info column (`ProductInfo` +
856
- `ProductTabs`), gallery, sticky actions column (suspended
857
- `ProductActionsWrapper`), related strip.
858
- - **SDK calls** via children (retrieveProduct, listRelatedProducts). The
859
- page fetches the product by handle (`retrieveProduct`) and passes it in.
860
- - **Settings** union of children's.
861
- - **Mount rules** server component; wrap the page in
862
- `ProductLabelsProvider` for i18n; `addToCart`/`openCart` seams as on
863
- `ProductActions`.
864
-
865
- ## Family: store (`@cartbase/storefront/store/*`)
866
- The listing family: paginated grids, sort, collection/category/search
867
- templates. Sorting discipline: the API's `order` param is the authority for
868
- paginated listings; client-side re-sort (`lib/sort-products`) exists ONLY
869
- for the price sorts on the plain products listing (price is not a products
870
- column) over the legacy 100-item window.
871
-
872
- **Labels / i18n** `store/labels` (`StoreLabels` + defaults + the pure
873
- `sortOptionLabelKeys` map completeness unit-tested) and `locale.store`
874
- from a pack. Templates take `labels?: Partial<StoreLabels>` props
875
- (no context in this family, matching the production original).
876
-
877
- ### `<Pagination page totalPages />` — `store/pagination`
878
-
879
- - **Purpose** — windowed page-number pagination; writes the `page` query
880
- param and pushes the route (server re-renders with the new offset).
881
- - **SDK calls** — none. **Mount rules** — client.
882
-
883
- ### `<SortSelect sortBy? labels? />` — `store/sort-select`
884
-
885
- - **Purpose** — sort sidebar; writes the `sortBy` query param
886
- (`created_at` | `price_asc` | `price_desc`, rendered from
887
- `sortOptionLabelKeys`).
888
- - **Props** — `sortBy` OPTIONAL (port adaptation): on collection pages no
889
- selection = the collection's admin `default_sort`, nothing highlighted.
890
- - **SDK calls** — none. **Mount rules** client.
891
-
892
- ### `<PaginatedProducts client page sortBy? collectionId? categoryId? productsIds? pricingContext? renderProduct? />` — `store/paginated-products`
893
-
894
- - **Purpose** — the 12-per-page product grid + pagination over the plain
895
- products listing.
896
- - **SDK calls** — `api/products` `listProducts`: `created_at` →
897
- server `order:"-created_at"` + real offset pagination; `price_asc`/
898
- `price_desc` → 100-item window fetch, `lib/sort-products` re-sort, slice
899
- (the proven production approach see module JSDoc for the >100 caveat).
900
- - **Props note** — `collectionId` filters by PRIMARY collection
901
- (`products.collection_id`); the membership join lives in
902
- `CollectionTemplate`.
903
- - **Settings** — price lists (pricing context), sales-channel/publishable-
904
- key scope on the client.
905
- - **Mount rules** — async server component; render inside `<Suspense>`
906
- with `<SkeletonProductGrid />`.
907
-
908
- ### `<SkeletonProductGrid numberOfProducts? />` — `store/skeleton-product-grid`
909
-
910
- - **Purpose** — pulse skeleton for any product grid. Server-safe, no calls.
911
-
912
- ### `<StoreTemplate client sortBy? page? pricingContext? labels? renderProduct? />` `store/store-template`
913
-
914
- - **Purpose** — the `/store` all-products page: sort sidebar + heading +
915
- suspended `PaginatedProducts`.
916
- - **SDK calls** — via `PaginatedProducts`. Pass the page's `sortBy`/`page`
917
- query params straight in.
918
-
919
- ### `<CollectionTemplate client collection sortBy? page? pricingContext? labels? renderProduct? />` — `store/collection-template`
920
-
921
- - **Purpose** — collection page over the MEMBERSHIP listing (multi-
922
- collection products appear in every collection).
923
- - **SDK calls** `api/collections` `listCollectionProducts(collection.id)`
924
- the admin `default_sort` is honored SERVER-side when `sortBy` is unset
925
- (no client re-sort, port adaptation); a shopper override maps
926
- `created_at→newest`, `price_asc`, `price_desc` to the `order` param
927
- (price sorting server-side here, unlike the plain listing). Fetch the
928
- collection itself via `listCollections({handle})` in the page.
929
- - **Settings** — collection `default_sort` + manual order, smart-collection
930
- conditions, channel links (scoped-away collection 404s), price lists.
931
- - **Mount rules** server component; grid suspends internally.
932
-
933
- ### `<CategoryTemplate client category sortBy? page? pricingContext? labels? renderProduct? />` — `store/category-template`
934
-
935
- - **Purpose** — category page: breadcrumbs (ancestor chain), description,
936
- child-category links, `PaginatedProducts` filtered by `category_id`.
937
- - **SDK calls** via `PaginatedProducts`. Fetch the category in the page
938
- with `retrieveCategory(id, {include_ancestors_tree: true,
939
- include_descendants_tree: true})` without the flags the breadcrumb and
940
- children sections don't render.
941
- - **Settings** category tree (active/internal flags are server-filtered).
942
-
943
- ### `<SearchTemplate client searchParams basePath? pricingContext? limit? labels? renderProduct? />` — `store/search-template`
944
-
945
- - **Purpose** the search results page in the
946
- store-template idiom GET query box, facet sidebar from the response
947
- `facets[]`, result grid (reuses the product card), pagination. Fully
948
- URL-state driven: works server-rendered with zero own client JS.
949
- - **SDK calls** — `api/search` `searchProducts` (only when `q` present).
950
- Facet buckets toggle by rewriting the wire-named query params
951
- (`collection_id`/`type_id`/`tag_id` CSV, `price_min`/`price_max`,
952
- `availability`, `option.<Title>` CSV) via the pure `store/search-params`
953
- helpers (round-trip unit-tested); every toggle resets `page`.
954
- - **Settings** — Search & discovery: synonyms, pins/boosts, facet config
955
- (order/enabled, price bucket strategy `auto`/`fixed`); price facet
956
- currency follows the pricing context.
957
- - **Mount rules** — async server component; pass the route's raw
958
- `searchParams`; `basePath` defaults to `/search`.
959
-
960
- ### Pure: `store/search-params` (extra module, Cartbase addition)
961
-
962
- `parseSearchParams` / `buildSearchQueryString` / `fromQueryString` /
963
- `toggleFacetSelection` / `isFacetSelected` / `clearFilters` /
964
- `toSearchQuery` the search page's URL-state machine, exported for custom
965
- search UIs (chips, drawers) that want the same URL contract.
966
-
967
- ---
968
-
969
- ## Family: order (`@cartbase/storefront/order/*`)
970
- Order confirmation + account order views, production-proven. Some
971
- platforms ship ONE `StoreOrder` object carrying computed line
972
- totals, order totals, shipping methods and payments; Cartbase splits those
973
- across surfaces, so the family takes them as separate props the Cartbase
974
- `StoreOrderDetail` (`api/orders`) carries items as version-pivot rows
975
- (`{quantity, line_item}`), fulfillments with tracking labels, and both
976
- addresses, while MONEY comes from the decorated cart (`api/carts` `Cart`)
977
- or the order summary snapshot. Components render server truths; the only
978
- arithmetic is the two documented production subtractions in the totals
979
- selector.
980
-
981
- Labels: `OrderLabelsProvider`/`useOrderLabels` (`order/context`) +
982
- `defaultOrderLabels` (`order/labels`, English) + `locale.order` from a
983
- pack in `locales/*` for any other language. Every
984
- component also takes a `labels` prop pick.
985
-
986
- ### `<OrderCompletedTemplate order totals items? shippingMethod? paymentProviderId? cardLast4? />` `order/order-completed-template`
987
-
988
- - **Purpose** — the full confirmation page: hero header, fulfillment
989
- timeline, items+totals card, contact/delivery/payment/help cards,
990
- continue-shopping CTA.
991
- - **SDK calls** none itself; feed it: `order` (`retrieveOrder` /
992
- `retrieveOrderByDisplayId` detail, or the `completeCart()` response),
993
- `totals` (the decorated cart from checkout, or
994
- `orderTotalsFromSummary(completeCart().order.summary)`), optional
995
- normalized `items` (prefer `displayItemFromCartLine` right after
996
- checkout keeps server-computed per-line discounts), `shippingMethod` /
997
- `paymentProviderId` / `cardLast4` from checkout state.
998
- - **Mount rules** order-confirmation route (server component OK; only
999
- the timeline child is client). The Purchase tracking trio (fbq/gtag/
1000
- rybbit, deduped by `order.display_id`) is APP-OWNED: fire it from the
1001
- confirmation route exactly once per order per the tracking family's
1002
- dedupe contract the template deliberately does NOT fire it.
1003
- - **Settings** — COD settings (fee row presence + `payment_method_fee_label`),
1004
- checkout rules (which provider ids appear), store locales (labels pack).
1005
-
1006
- ### `<OrderConfirmationHeader order />` — `order/order-confirmation-header`
1007
-
1008
- - **Purpose** check hero + "Order #display_id" + localized date chips.
1009
- - **Props** — structural `OrderHeaderData` (`display_id`, `email`,
1010
- `created_at`) — order detail and `completeCart().order` both satisfy it.
1011
- `locale` drives `toLocaleDateString`.
1012
-
1013
- ### `<OrderItemsList items currencyCode />` + `<OrderItem item currencyCode />` — `order/order-items-list`, `order/order-item`
1014
-
1015
- - **Purpose** the purchased lines (thumb, title, variant caption, qty,
1016
- line total with discount strike-through when the source had it).
1017
- - **Data seam** — normalized `OrderDisplayItem[]` via the pure converters
1018
- `displayItemFromOrderItem(pivot)` (order path: `unit_price × quantity`)
1019
- / `displayItemFromCartLine(line)` (cart path: server `total` /
1020
- `original_total`). Legacy COD-fee line items
1021
- (`metadata.is_cod_fee=true`) are hidden here and surfaced in
1022
- `OrderTotals` instead (`lib/cart-helpers.isProductLine`); Cartbase-native
1023
- COD fees are never line items, so on pure Cartbase data the filter is a
1024
- no-op safety net. Newest-first sort by `createdAt` when present.
1025
-
1026
- ### `<OrderTotals totals currencyCode items? methodFeeLabel? />` `order/order-totals`
1027
-
1028
- - **Purpose** the money breakdown: Subtotal / Shipping (FREE badge at
1029
- 0) / COD fee / Discount (negated) / Tax / Total, all via `Price`.
1030
- - **Data seam** — `OrderTotalsSource` (the decorated cart satisfies it:
1031
- `item_subtotal`, `shipping_subtotal`, `discount_total`, `tax_total`,
1032
- `total`, `payment_method_fee_total`, `payment_method_fee_label`); the summary snapshot adapts
1033
- via `orderTotalsFromSummary`. Row policy is the pure, unit-tested
1034
- `selectOrderTotalsRows`: native `payment_method_fee_total` wins over a legacy fee
1035
- LINE; a legacy fee line's net is subtracted from the visible subtotal
1036
- (v2.3.1 production fix); COD label preference `methodFeeLabel` prop
1037
- server `payment_method_fee_label` fee-line title `labels.paymentMethodFee`.
1038
- - **Settings** — COD settings (`payment_method_fee_total`/`payment_method_fee_label`),
1039
- promotions (discount row).
1040
-
1041
- ### `<OrderAddressCard order />` — `order/order-address-card`
1042
-
1043
- - Contact info card: shipping-address name + phone + order email.
1044
- Structural `OrderContactData` the order detail satisfies it.
1045
-
1046
- ### `<OrderDeliveryCard order shippingMethod? currencyCode />` — `order/order-delivery-card`
1047
-
1048
- - **Purpose** pickup point (Econt office metadata / stable
1049
- fulfillment-option ids `econt-office`, `boxnow-locker` id beats name
1050
- parsing, production fix) or shipping address, the method row with
1051
- price/FREE, and — Cartbase addition — the fulfillment tracking labels.
1052
- - **Data seam** — `order.metadata` + `order.shipping_address` +
1053
- `order.fulfillments` from the detail; `shippingMethod` (structural,
1054
- `CartShippingMethod` fits) from checkout state since the Cartbase order
1055
- read has no shipping-method embed. Tracking rows come from the pure,
1056
- unit-tested `pickTrackingLabels(order.fulfillments)` skips canceled
1057
- fulfillments, drops number-less labels, dedupes re-prints.
1058
- - **Settings** carrier integrations (whether labels/urls exist),
1059
- shipping options (ids/names).
1060
-
1061
- ### `<OrderPaymentCard providerId cardLast4? />` — `order/order-payment-card`
1062
-
1063
- - Payment method card; `resolvePaymentTitle(providerId, titles,
1064
- methodName?)` — the method's merchant NAME wins verbatim when present
1065
- (the snapshot from session data); otherwise the processor id buckets
1066
- via `lib/payment-constants` (`pp_stripe` = card) through the locale
1067
- pack, falling back to `paymentInfoMap` then the raw id. Payment
1068
- internals never cross the Cartbase store surface — both arrive via
1069
- props from checkout state.
1070
-
1071
- ### `<OrderTimeline fulfillmentStatus? />` `order/order-timeline`
1072
-
1073
- - Placed Processing Shipped Delivered progress (client, animated).
1074
- Cartbase has no `fulfillment_status` column on the store surface
1075
- derive it with the pure, unit-tested
1076
- `deriveFulfillmentStatus(order.fulfillments)` (packed_at/shipped_at/
1077
- delivered_at ladder, `partially_*` when only some active fulfillments
1078
- reached a stage, canceled ignored).
1079
-
1080
- ### `<OrderHelpSection contactHref? returnsHref? />` — `order/order-help-section`
1081
-
1082
- - "Need help?" links card (contact + returns). No SDK calls, no settings.
1083
-
1084
- ---
1085
-
1086
- ## Family: common (`@cartbase/storefront/common/*`)
1087
- Shared storefront chrome, production-proven.
1088
-
1089
- ### `<LocalizedLink href />` `common/localized-link`
1090
-
1091
- - `next/link` that persists the URL locale/country segment when the route
1092
- has one (`[countryCode]` param by default, `paramName` overridable);
1093
- plain link on cookie-locale apps (the Cartbase default). Client.
1094
-
1095
- ### `<CartButton client cartId? />` + `<CartButtonClient cart />` — `common/cart-button`, `common/cart-button-client`
1096
-
1097
- - **Purpose** header cart button: badge count + opens the cart drawer.
1098
- - **SDK calls** server wrapper: `api/carts.retrieveCart(client, cartId)`
1099
- (the app owns the cart-id cookie); fetch failure degrades to an empty
1100
- button. Client half takes the decorated `Cart`; badge count =
1101
- `productItemCount(cart.items)` (fee-line-aware, consistent with the
1102
- drawer).
1103
- - **Mount rules** `CartButtonClient` must sit inside the cart-drawer
1104
- family's `<CartDrawerProvider>` (it calls `useCartDrawer().open`).
1105
-
1106
- ### `<DeleteButton client cartId id onDeleted? />` — `common/delete-button`
1107
-
1108
- - Cart-line remove with spinner. Calls
1109
- `api/carts.deleteLineItem(client, cartId, id)` (idempotent) and hands
1110
- the refreshed `{cart}` to `onDeleted`; spinner resets on failure.
1111
-
1112
- ### `<CountrySelect regions value? onChange />` — `common/country-select`
1113
-
1114
- - **Purpose** — the market picker for a header or footer. It lists REGIONS
1115
- (`api/regions.listRegions`), valued by region id; persistence is app-owned
1116
- via `onChange` (usually `carts.updateCart(client, cartId, {region_id})` +
1117
- a cookie). The name is a leftover from the ported source and is wrong for
1118
- what it does: it selects a market, not a country. Use
1119
- `listCountries` + `CountryFlag` when you want a country.
1120
- - **Settings** Admin Settings Markets → Regions: which rows exist.
1121
-
1122
- ### `<CountryFlag code title? className? />` `common/country-flag`
1123
-
1124
- - **Purpose** a country's flag from its ISO-3166 alpha-2 code, at the size
1125
- of the text beside it. An unknown or empty code renders a neutral box that
1126
- holds the same space, so a field does not jump when a shopper picks their
1127
- country.
1128
- - **Why a sprite and not an emoji** — Windows ships no flag glyphs, so an
1129
- emoji flag reads as the two letters "BG" there. This draws `flag-icons`
1130
- SVG under a CSS class, which renders the same on every platform, and it is
1131
- the same sprite the Cartbase admin uses, so one country treatment runs
1132
- across the admin and the storefront.
1133
- - **Accessibility** — decorative and `aria-hidden`: a flag never carries
1134
- meaning the name beside it does not. Pass `title` when it stands alone.
1135
-
1136
- ### `<LanguageSelect locales currentLocale onChange labels? />` — `common/language-select`
1137
-
1138
- - **Purpose** locale switcher. Feed `locales` from
1139
- `api/regions.listLocales(client)` (bare codes, store default first —
1140
- SDK wins over the source's `{code,name}` objects); display names via
1141
- `localeDisplayName()` (`Intl.DisplayNames` autonym) with per-store
1142
- `labels` overrides. `onChange` persists (cookie read by
1143
- `StorefrontClient.getLocale`) inside the preserved `useTransition`
1144
- pending-disable UX.
1145
- - **Settings** Settings Store → locales (per-store `store_locales`).
1146
-
1147
- ### `<Skeleton className? />` + `<SkeletonProductPreview />` `common/skeleton`
1148
-
1149
- - Loading placeholders (pure UI over theme tokens).
1150
-
1151
- ---
1152
-
1153
- ## Family: reviews-ui (`@cartbase/storefront/reviews-ui`)
1154
- Verified-purchase review components, ported from a production storefront,
1155
- over `api/reviews`. ONE barrel export seam: everything imports from
1156
- `@cartbase/storefront/reviews-ui`. Endpoint truth:
1157
- [reviews.md](reviews.md). Labels: `defaultReviewsUiLabels` +
1158
- `locale.reviews` from a pack in `locales/*` (English by default,
1159
- parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots —
1160
- resolve via `formatLabel`).
1161
-
1162
- ### `<ReviewWidget client productId initialData? />` — the PDP section
1163
-
1164
- - **Purpose** — aggregate header (score badge + 5→1 distribution bars +
1165
- sort select) + masonry card list + load-more + lightbox. Renders null
1166
- at zero reviews (production rule); derives avg/distribution from the
1167
- loaded page if the aggregate is missing (never a misleading "0.0").
1168
- - **SDK calls** — bootstrap: `getWidget(client, productId)` (ONE call:
1169
- aggregate + first page per the store's display options; edge-cached
1170
- 60s) — server-fetch it and pass `initialData` (recommended), else the
1171
- widget fetches on mount. Sort changes / load-more: `listReviews`
1172
- (`sortParamsFor` maps the UI keys to the API `(sort, order)` tuple).
1173
- - **Mount rules** client component, PDP below the fold; `id="reviews"`
1174
- anchor built in. Verified badge is unconditional (every review is
1175
- token-minted — a system tautology, not a flag).
1176
- - **Settings** — Settings → Reviews display options: `widget_layout`
1177
- (masonry|list), `widget_page_size`, `widget_photo_first` (all arrive
1178
- via `getWidget().options`); moderation decides visibility.
1179
- - Pieces exported for custom layouts: `<StarRow>`, `<StarBadge>`,
1180
- `<RatingDistribution>` (`star-badge`), `<ReviewList>` +
1181
- `<ReviewLightbox>` (presentational cards + overlay),
1182
- `reviewDisplayName` (first name + surname initial — shared with any
1183
- app JSON-LD so UI and structured data can't drift, production fix),
1184
- `formatReviewDate`.
1185
-
1186
- ### `<ReviewWizard client token validation rewardPct? supportEmail? />` — the token page
1187
-
1188
- - **Purpose** — everything behind `<review_link_base>/<token>`: invalid/
1189
- expired panels, the terminal already-submitted panel, and the two-step
1190
- form (rate photo done) with the reward-code reveal.
1191
- - **SDK calls** — `validateToken(client, token)` SERVER-SIDE in the page
1192
- (pass the result as `validation` never flash a form on a dead
1193
- token; mark the route noindex), then client-side: `submitReview`
1194
- (step 1 consumes the token, rating locked in even if the customer
1195
- bails), `createUploadUrl` signed R2 PUT `attachReviewPhoto`
1196
- (step 2 mints the single-use reward code; `code: null` on 200 =
1197
- media saved, mint failed"write to us" note, never an error).
1198
- - **Step resolution** — the pure, unit-tested
1199
- `resolveWizardEntry(validation)`; THE RESUME RULE: consumed token +
1200
- review row + `reward_code` null → resume at photo; `reward_code` set →
1201
- done showing the code; consumed with no review row terminal panel.
1202
- Submit errors map by status via `submitErrorKeyFor` (429/409/410).
1203
- - **Mount rules** `ReviewWizard` is the full page body (client);
1204
- `ReviewWizardForm` and `<ReviewPhotoUpload>` (drag-drop, per-file slot
1205
- caps ≤6 images/≤1 video, 8/50 MB, 60s video, blob-preview swap +
1206
- revoke) are exported for custom pages.
1207
- - **Settings** Settings Reviews: `reward_enabled` /
1208
- `reward_percentage` (the store surface does not expose the percentage
1209
- pass `rewardPct`, default 10), `moderation_mode` (`hold` lands the
1210
- review pending; the thanks copy stays true either way), request-scanner
1211
- settings decide when tokens are minted at all.
1
+ # Components
2
+
3
+ The component layer of `@cartbase/storefront`: what each family ships, the SDK
4
+ calls it requires, the admin settings that change its behavior, and its mount
5
+ rules. Every family is production-proven — ported from live commerce
6
+ storefronts and rewired to Cartbase data seams.
7
+
8
+ This page has no executable curls — the endpoints these components consume
9
+ are executably documented in their own domain files
10
+ ([integrations.md](integrations.md), [consent.md](consent.md), etc.); this
11
+ page is the component-side contract that binds to them.
12
+
13
+ Import discipline: always import from the subpath
14
+ (`@cartbase/storefront/tracking/meta-pixel`, `@cartbase/storefront/lib/money`) —
15
+ tree-shaking and Next.js RSC boundary detection both work better than via
16
+ barrels. Styling resolves against STOREFRONT theme tokens (`bg-card`,
17
+ `text-foreground`, …, shadcn-standard names) — `@import
18
+ "@cartbase/storefront/theme"` in the app's CSS supplies both the token names
19
+ and a filled default set of values, so components render designed out of the
20
+ box. No component hardcodes a colour; retheming means overriding values, never
21
+ renaming tokens.
22
+
23
+ ---
24
+
25
+ ## Language: the library is English, a language is a pack
26
+
27
+ No component holds copy. Every string comes from a label pack, and the
28
+ library's own defaults are **international English** (`products/labels`,
29
+ `checkout/labels`, and so on, beside each family's type).
30
+
31
+ A language is one object covering all six families, imported by its own
32
+ subpath so a store bundles only what it ships:
33
+
34
+ ```tsx
35
+ import { es } from "@cartbase/storefront/locales/es"
36
+ import { StorefrontLocaleProvider } from "@cartbase/storefront/locales"
37
+
38
+ <StorefrontLocaleProvider locale={es}>{children}</StorefrontLocaleProvider>
39
+ ```
40
+
41
+ Shipping: `locales/en`, `locales/es`, `locales/bg`. Each is typed
42
+ `StorefrontLocale`, so a label added to the library breaks an incomplete
43
+ pack at compile time instead of falling back to English mid-checkout.
44
+
45
+ The provider mounts the three pure label contexts (products, checkout,
46
+ order), and the cart drawer and the reviews family read the language from
47
+ it directly, so every CLIENT component inside it speaks the pack with no
48
+ further wiring. What it cannot reach is a SERVER component: the store,
49
+ product and order templates render on the server, so each page hands them
50
+ their area of the pack, and that prop is REQUIRED. A page that forgets it
51
+ does not compile; `PropLabelAreas` in `locales` names the areas.
52
+
53
+ ```tsx
54
+ <StoreTemplate labels={STORE_LOCALE.store} />
55
+ <SearchTemplate labels={STORE_LOCALE.store} />
56
+ <CollectionTemplate labels={STORE_LOCALE.store} />
57
+ <CategoryTemplate labels={STORE_LOCALE.store} />
58
+ <ProductTemplate labels={STORE_LOCALE.products} />
59
+ <OrderCompletedTemplate labels={STORE_LOCALE.order} />
60
+ ```
61
+
62
+ `STORE_LOCALE` is the pack the store declared once in its config (`en`, or
63
+ `bg`, or `es`); the scaffold ships it that way. English is not a special
64
+ case: an English store passes `en.store`.
65
+
66
+ **Failure copy is copy.** What a shopper reads when an address, a payment, a
67
+ gift card or a discount code fails lives in the checkout pack too
68
+ (`addressErrors`, `paymentErrors`, `giftCardErrors`, `promotionErrors`).
69
+ The modules that recognize each failure keep their English patterns,
70
+ because those match the API's wire format and are never shown to anyone.
71
+
72
+ Overriding one word is a partial: every provider merges over the defaults.
73
+
74
+ ---
75
+
76
+ ## Family: tracking (`@cartbase/storefront/tracking/*`)
77
+ Meta Pixel + GA4 + Rybbit + Consent Mode v2. The Cartbase split of duties:
78
+ **this package fires client events and writes attribution; server-side CAPI /
79
+ GA4 Measurement Protocol sending is Cartbase-backend-owned** (the
80
+ `order.placed` forwarder). The whole family is safe to render
81
+ unconditionally every component renders nothing when its id prop is absent,
82
+ every helper no-ops outside the browser.
83
+
84
+ ### `<ConsentInit required />` `tracking/consent-init`
85
+
86
+ - **Purpose** — the synchronous Consent Mode v2 "default" snippet: sets
87
+ gtag's consent default before any Google tag loads. `required` is the
88
+ store's consent switch (`consent.enabled` from `GET /api/store/consent`).
89
+ With it true a new visitor starts DENIED until they choose on the banner;
90
+ with it false the store collects no consent and a new visitor starts
91
+ GRANTED. A choice stored in `_1c_consent` wins either way.
92
+ - **SDK calls** — none in the browser. The layout resolves the consent
93
+ config on the server (it fetches it for the banner anyway) and passes the
94
+ switch; the script must NEVER wait on a fetch (async default = first-hit
95
+ consent race).
96
+ - **Mount rules** FIRST child of `<body>` in the root layout, before any
97
+ tag component. Plain inline `<script>` by design (not next/script). The
98
+ prop is REQUIRED: neither default is safe, so a layout must say what the
99
+ store does, and one that does not fails to compile (2026-09-14).
100
+ - **Settings** — the consent card's `enabled` (admin Settings → Consent).
101
+
102
+ ### `<ConsentBanner copy layout privacyHref rejectOnFirstLayer />` — `tracking/consent-banner`
103
+
104
+ - **Purpose** the built-in two-layer CMP UI; writes the `_1c_consent`
105
+ cookie and applies the live `gtag('consent','update')` + `fbq('consent')`.
106
+ - **SDK calls** — `GET /api/store/consent` (via `@cartbase/storefront/api/consent`)
107
+ for the store's config; pass `copy = pickConsentCopy(consent.copy, locale)`,
108
+ plus `layout` / `privacy_href` / `reject_on_first_layer` from the payload.
109
+ - **Mount rules** — mount ONLY when `shouldRenderBanner(consent)` (i.e.
110
+ `enabled && mode === "builtin"`). In `mode: "external"` render nothing —
111
+ the merchant's CMP must write the same `_1c_consent` cookie or call
112
+ `setConsent()`; all gating keeps working off that one seam. z-index is
113
+ `z-[70]` (above the cart drawer's `z-[60]`).
114
+ - **Settings** the consent card config (admin → Settings → Consent):
115
+ `enabled`, `mode`, `layout` (`modal` blocks + scroll-locks;
116
+ `banner-bottom` is non-blocking and must not trap focus), `privacy_href`,
117
+ `reject_on_first_layer`, per-locale `copy`.
118
+ - Also ships `<ConsentSettingsLink>` (footer link that re-opens the settings
119
+ layer "withdraw as easily as given") and the pure `<ConsentBannerCard>`.
120
+
121
+ ### `<MetaPixel pixelId consentRequired />` `tracking/meta-pixel`
122
+
123
+ - **Purpose** — injects fbevents.js, pushes the consent state BEFORE
124
+ `fbq('init')` (pre-init revoke = Pixel queues events until grant), fires
125
+ the initial PageView. The state is the store's default overridden by the
126
+ visitor's stored `_1c_consent` choice: revoked on a store that collects
127
+ consent, granted on a store with the banner off.
128
+ - **SDK calls** `getTrackingConfig(client)` (`tracking/get-tracking-config`,
129
+ wraps `GET /api/store/integrations` → `tracking.facebookPixel.pixelId` and
130
+ `tracking.consent_required`).
131
+ - **Mount rules** root layout, after `<ConsentInit>`; `<StorefrontTags>`
132
+ mounts it with both props from the block. Renders nothing when `pixelId`
133
+ is falsy. `consentRequired` is REQUIRED for the reason `<ConsentInit>`
134
+ gives, and so it is on `<TikTokPixel>` and `<ChatGptPixel>`, the two
135
+ other tags that gate their own SDK (2026-09-14). Google needs no prop: it
136
+ reads the `<ConsentInit>` default.
137
+ - **Settings** admin Integrations hub `facebook_capi` row (`enabled` +
138
+ `credentials.pixel_id`); consent card `enabled` → `consent_required`.
139
+ - Companion: `updatePixelAdvancedMatching(visitor)` call from checkout /
140
+ signup as PII becomes known; hashes em/ph/fn/ln/ct/st/zp/country
141
+ (SHA-256, Meta normalization) and re-inits the Pixel so every subsequent
142
+ event carries Advanced Matching. Also persists raw values for
143
+ `getKnownVisitor()`.
144
+
145
+ ### `<GA4 measurementId />` `tracking/ga4`
146
+
147
+ - **Purpose**loads gtag.js + `gtag('config')`. gtag then owns the `_ga` /
148
+ `_ga_<MEASUREMENT_ID>` cookies that `getTrackingAttribution()` later reads.
149
+ - **SDK calls** `getTrackingConfig(client)` `tracking.ga4.measurementId`.
150
+ - **Mount rules** root layout, after `<ConsentInit>` (the consent default
151
+ governs whether gtag writes cookies or sends cookieless pings). Renders
152
+ nothing when falsy. SPA route changes are NOT auto-tracked fire
153
+ `page_view` from a route-change effect if per-route views are wanted.
154
+ - **Settings** — Integrations hub `ga4` row (`credentials.measurement_id`);
155
+ consent card.
156
+
157
+ ### `<Rybbit siteId baseUrl? />` `tracking/rybbit`
158
+
159
+ - **Purpose** the platform's self-hosted, cookieless analytics tracker;
160
+ auto-tracks pageviews incl. SPA route changes.
161
+ - **SDK calls** — NONE. Rybbit is deliberately absent from the store
162
+ `tracking` block: platform-provisioned, the host app passes props from
163
+ platform env. Outside the consent system by design (cookieless).
164
+ - **Mount rules** root layout; renders nothing when `siteId` falsy.
165
+ - **Settings** none merchant-facing.
166
+
167
+ ### Client events — `tracking/events` (use this one)
168
+
169
+ ONE call per commerce moment, every vendor at once. Reach for these rather
170
+ than the per-vendor helpers below, because the failure they prevent is the
171
+ common one and it is invisible: a store adds a vendor, updates three of the
172
+ four call sites, and an ad account optimises on partial data for a month
173
+ before anyone notices.
174
+
175
+ | Step | Call |
176
+ |---|---|
177
+ | Product view | `trackProductView({ line, currency, value })` |
178
+ | Add to cart | `trackCartAdd({ line, currency, value })` |
179
+ | Checkout start | `trackCheckoutStart({ lines, currency, value, coupon? })` |
180
+ | Order confirmed | `trackOrderPurchase(order, trackingConfig)` |
181
+
182
+ A `line` is `{ productId, variantId?, title, quantity, price }`. Pass the
183
+ PRODUCT id: Meta's content_ids, TikTok's content_id and the catalogue
184
+ feed's `<g:id>` must be the same value or the event matches no catalogue
185
+ entry, which costs dynamic ads on both platforms. GA4 is keyed by variant
186
+ instead, on purpose, and these helpers do that split for you.
187
+
188
+ `trackOrderPurchase` takes the tracking config as its second argument
189
+ because Google Ads only fires when the store configured BOTH the account id
190
+ and the purchase label. Pass `order.metadata.customer_type` through as
191
+ `customerType` when present: it is computed once server-side, so the
192
+ browser and the server tell Google and TikTok the same thing.
193
+
194
+ ### Per-vendor helpers `tracking/fbq`, `tracking/gtag`, `tracking/ttq`, `tracking/rybbit-events`, `tracking/google-ads`
195
+
196
+ Call on the matching funnel step; all are no-op-safe (SSR, blocked, absent
197
+ tag). The Rybbit helpers additionally queue-buffer through the script-load
198
+ race (10s cap, v2.2.6 production fix).
199
+
200
+ | Step | Meta (`fbq.ts`) | TikTok (`ttq.ts`) | GA4 (`gtag.ts`) | Rybbit (`rybbit-events.ts`) |
201
+ |---|---|---|---|---|
202
+ | Product view | `trackViewContent` | `trackTikTokViewContent` | `trackGAViewItem` | `trackRybbitViewItem` |
203
+ | Add to cart | `trackAddToCart` | `trackTikTokAddToCart` | `trackGAAddToCart` | `trackRybbitAddToCart` |
204
+ | Checkout start | `trackInitiateCheckout` | `trackTikTokInitiateCheckout` | `trackGABeginCheckout` | `trackRybbitBeginCheckout` |
205
+ | Order confirmed | `trackPurchase(data, order.display_id)` | `trackTikTokPurchase({…, displayId})` | `trackGAPurchase({transaction_id: String(order.display_id), …})` | `trackRybbitPurchase` |
206
+ | Newsletter/popup signup | `trackLead` | | | — |
207
+
208
+ Google Ads has one event, the purchase conversion:
209
+ `trackGoogleAdsPurchase({ sendTo, value, currency, transactionId, newCustomer? })`,
210
+ with `sendTo` from `googleAdsPurchaseSendTo(config)` (null when the label
211
+ is not configured skip the conversion rather than fire a malformed
212
+ `send_to`, which Google accepts and silently drops).
213
+
214
+ **THE Purchase dedupe contract** (must never drift; mirrored server-side in
215
+ `src/lib/tracking/constants.ts`):
216
+
217
+ - Meta: browser Pixel `eventID` = **`"purchase_" + order.display_id`** the
218
+ exact id the backend `order.placed` CAPI Purchase uses. `trackPurchase()`
219
+ builds it from the caller's `display_id` so the format cannot drift. Meta
220
+ dedupes by event_name + event_id (~3 days).
221
+ - TikTok: browser pixel `event_id` = **`"tt_purchase_" + order.display_id`**,
222
+ the same id the backend Events API Purchase sends. The `tt_` prefix keeps
223
+ it from colliding with Meta's on a page carrying both pixels. TikTok
224
+ dedupes on event_source_id + event + event_id and discards duplicates for
225
+ 48 hours; the backend ALSO carries an idempotency flag, because 48 hours
226
+ does not cover an order email opened on day four.
227
+ - GA4: `transaction_id = String(order.display_id)` on both browser event and
228
+ backend Measurement Protocol GA4 dedupes by transaction_id natively.
229
+ - Google Ads: `transaction_id` again, the same value. Never send it empty:
230
+ an empty transaction_id dedupes nothing, so every re-open of the
231
+ confirmation page counts another conversion.
232
+ - All other events get `<eventname>_<unixSeconds>_<6hex>` ids
233
+ (`generateEventId`).
234
+
235
+ `setEnhancedConversions(input)` (gtag.ts) Google Ads Enhanced Conversions
236
+ for Web: hashes email/phone/names client-side and `gtag('set','user_data',…)`.
237
+ Call alongside `updatePixelAdvancedMatching` at checkout. The phone is
238
+ normalised to E.164 WITH the plus, which is NOT the digits-only string Meta
239
+ wants: two vendors, two normalisers, and merging them re-creates a defect
240
+ that fails silently and costs match rate with nothing in any log.
241
+
242
+ ### Attribution — `tracking/attribution`, `tracking/get-tracking-attribution`, `tracking/use-engagement-time`
243
+
244
+ - Browser side (call from a top-level client component, e.g. `TrackInit`):
245
+ `captureUtmsFromUrl()` (first-touch `_1c_utm_first` 365d, last-touch
246
+ `_1c_utm_last` 90d), `getOrCreateFbp()` / `getOrCreateFbc()` (defensive
247
+ `_fbp`/`_fbc` writes so the Pixel-load race never ships empty match keys —
248
+ production fix), `getOrCreateAnonId()` (`_1c_anon`, the guest
249
+ `external_id`), `initEngagementTime()`.
250
+ - Server side, at checkout completion:
251
+ `getTrackingAttribution(clientHints?, opts?)` reads the cookies + headers
252
+ and returns the `TrackingAttribution` keys to write into `cart.metadata`
253
+ (CONSENT-GATEDonly when the visitor's choice allows). Cart completion
254
+ copies them to `order.metadata`; the backend forwarder inherits
255
+ fbp/fbc/anon-id/ga signals from there. Pass
256
+ `{ engagementTimeMsec: getEngagementTimeMsec() }` as clientHints, and
257
+ either `opts.ga4MeasurementId` or `opts.client` so the `_ga_<id>` session
258
+ cookie can be located.
259
+ - **Settings** — Integrations hub rows (which signals exist), consent card
260
+ (whether capture happens).
261
+
262
+ ---
263
+
264
+ ## Family: primitives (`@cartbase/storefront/primitives/*`)
265
+ Generic shadcn-style building blocks. No SDK calls, no admin settings —
266
+ pure UI over the theme tokens.
267
+
268
+ - `primitives/field` — `<Field label/>`: floating-label input (checkout
269
+ house style) with the `pulse` attention cue (soft blue 2-cycle pulse,
270
+ distinct from focus/error — production UX fix).
271
+ - `primitives/select-field` — `<SelectField label>…options…</SelectField>`:
272
+ floating-label native select (native mobile keyboards).
273
+ - `primitives/ui/*` shadcn-standard `button` (cva variants incl.
274
+ `default/destructive/outline/secondary/ghost/link`), `input`, `label`,
275
+ `select`, `dialog`, `sheet`, `tabs`, `accordion`, `collapsible`,
276
+ `popover`. Radix-backed, ported verbatim; import per file.
277
+ - Mount rules: all are `"use client"`. Requires the Tailwind preset tokens.
278
+
279
+ ---
280
+
281
+ ## Family: lib (`@cartbase/storefront/lib/*`)
282
+ Pure helpers no React except `price`, no fetches. The SDK never
283
+ invents server truths: prices/totals arrive computed from the API
284
+ (`variant.calculated_price`, `cart.total`); these helpers only select and
285
+ format.
286
+
287
+ - `lib/utils` — `cn()` (clsx + tailwind-merge).
288
+ - `lib/money` — `convertToLocale({amount, currency_code, …})` Intl currency
289
+ formatting (amounts are decimal EUR major units per the store contract);
290
+ `noDivisionCurrencies`.
291
+ - `lib/price` `<Price amount currencyCode className />`: the amount in its
292
+ own currency, formatted through `convertToLocale`. Every price in the
293
+ library renders through it. It shows ONE currency; a store that wants a
294
+ second one displayed adds it in its own locale layer rather than in
295
+ every price.
296
+ - `lib/cart-helpers` — `isProductLine` / `isFeeLine` / `productTotal` /
297
+ `productItemCount` / `findFeeLine` + `COD_FEE_METADATA_KEY`. THE single
298
+ source of truth for hiding the backend-injected COD-fee line in cart
299
+ surfaces while checkout/order totals show it as its own row. Affected by:
300
+ COD settings (whether a fee line ever appears).
301
+ - `lib/get-product-price` — `getProductPrice({product, variantId})` /
302
+ `getPricesForVariant` → formatted `VariantPrice` (cheapest + selected)
303
+ from server-computed `calculated_price`. Reads Cartbase's flat
304
+ `price_list_type` with a legacy nested shape as fallback. Affected by:
305
+ price lists / B2B pricing context (what `calculated_price` contains),
306
+ currency + region query context.
307
+ - `lib/get-percentage-diff` — sale badge math.
308
+ - `lib/product` — `isSimpleProduct` (skip the option selector for
309
+ one-option-one-value products).
310
+ - `lib/sort-products` client-side re-sort of a fetched page
311
+ (`price_asc|price_desc|created_at`); the API's `order` param stays the
312
+ authority for paginated listings.
313
+ - `lib/payment-constants` `isStripeLike` / `isPaypal` +
314
+ `paymentInfoMap`. Two tender shapes since the pp_* kill: a processor
315
+ `provider_id` (`pp_stripe` exact, code truth
316
+ `src/lib/stripe/providers.ts`, plus legacy Medusa-era prefixes as
317
+ fallbacks) or a merchant METHOD rendered by its snapshot NAME — a
318
+ method never has a provider id to bucket. Affected by: connected
319
+ processors + enabled methods + checkout rules (which entries ever
320
+ reach the client).
321
+ - `lib/store-api-error` — `storeApiError(err)`: display-boundary normalizer
322
+ (capitalized message + terminal period).
323
+ Catch `StoreApiError` directly instead when branching on `status`/`code`.
324
+ - `lib/hooks/use-intersection`, `lib/hooks/use-toggle-state` viewport +
325
+ toggle micro-hooks (client).
326
+
327
+ ---
328
+
329
+ ## Family: checkout (`@cartbase/storefront/checkout/*`)
330
+ The full checkout page family, production-proven (deferred-
331
+ intent architectureno payment session exists until Buy click) onto the
332
+ Cartbase orchestration endpoints. The flow every component serves
333
+ (executable ground truth: [checkout.md](checkout.md)):
334
+
335
+ ```
336
+ listShippingOptions(cart_id) + listPaymentProviders(cart_id) (render pickers)
337
+ Buy click prepareCheckout (ONE atomic, compensated call)
338
+ pp_stripe: stripe.confirmPayment(client_secret) | merchant method: skip
339
+ completeCart navigate to the confirmed page
340
+ ```
341
+
342
+ Amounts are EUR major units and SERVER truth components render totals,
343
+ never compute money; the only client-side arithmetic is the *optimistic
344
+ display* overlay (shipping/COD-fee prediction) that the server value
345
+ replaces at prepare. All user-facing copy flows from `CheckoutProvider`
346
+ labels (English by default; a language is a pack from `locales/*`); errors surface
347
+ code-first through the error-copy maps, never raw API strings.
348
+
349
+ ### `<CheckoutProvider labels orderConfirmedPath />` — `checkout/context`
350
+
351
+ - **Purpose** supplies labels + the order-confirmed path template
352
+ (`{id}`/`{country}` substitution) to every checkout primitive.
353
+ - **SDK calls** — none.
354
+ - **Mount rules** — wrap the checkout page (or any custom composition).
355
+ Default path template `"/{country}/order/{id}/confirmed"`; single-country
356
+ stores pass `"/order/{id}/confirmed"`.
357
+ - **A NESTED PROVIDER INHERITS (0.12.0)** mounting a second one, which is
358
+ what you do when you set `orderConfirmedPath` on a page inside a
359
+ `StorefrontLocaleProvider`, keeps every label from the provider above and
360
+ changes only the keys you name. Before 0.12.0 it merged over the ENGLISH
361
+ defaults, so a provider mounted for the path alone silently reset the
362
+ whole checkout to English on an otherwise translated store. The same now
363
+ holds for `ProductLabelsProvider` and `OrderLabelsProvider`.
364
+ - **Settings** none (labels come from the store's locale pack).
365
+
366
+ ### `useCheckoutOrchestration(options)``checkout/use-checkout-orchestration`
367
+
368
+ - **Purpose** THE hardened checkout state machine: address form +
369
+ debounced autosave, shipping/payment selection (client-state-only
370
+ pre-Buy), carrier metadata, optimistic totals, the atomic Buy click,
371
+ 3DS-return handling, completed-cart detection. Every production race
372
+ guard from production is preserved.
373
+ - **SDK calls** — `carts.updateCart` (address autosave + tracking metadata),
374
+ `customers.updateMe` (best-effort profile sync incl. Cartbase's first-class
375
+ `company_name`/`company_eik`), `checkout.calculateShippingOption`
376
+ (calculated-rate forward-compat), `checkout.prepareCheckout`,
377
+ `checkout.syncPaymentAmount` (exposed + fired on payment-tab switch with
378
+ the new `provider_id`), `checkout.refreshPaymentIfTerminal` (exposed
379
+ call REACTIVELY from Elements `loaderror` only), `carts.completeCart`.
380
+ - **Props contract** `{client, cart, customer, availableShippingMethods,
381
+ availablePaymentMethods, countryCode?, countries?, paymentMethodFilter?,
382
+ orderConfirmedPath?, onOrderPlaced?,
383
+ resolveTrackingMetadata?, logError?}`. `countries` is OPTIONAL and is an
384
+ override: omit it and the hook calls `GET /api/store/countries` itself, so
385
+ the address form offers the countries the store's Markets declare, or the
386
+ whole catalogue when it declares none (0.11.0 before that, omitting it
387
+ produced a ONE-country read-only field). A failure to load the list never
388
+ blocks a checkout: it falls back to `countryCode` and reports through
389
+ `logError` as `country_list_failed`. Fee prediction is never
390
+ hardcoded: each method LISTING entry carries its own
391
+ `fee_amount`/`fee_label`. `logError` replaces the legacy log writers
392
+ (all production log points preserved). Returns the full orchestration
393
+ surface (`performBuyClick`, `optimisticTotal(Cents)`, `deliveryReady`, …).
394
+ - **Cartbase specifics** — zero-remainder gift path: `prepareCheckout`
395
+ returning `client_secret:null` + `provider_id:null` +
396
+ `payment_method_id:null` SKIPS Stripe and completes on the gift session
397
+ ([gift-cards.md](gift-cards.md)). Processors are `pp_stripe` exactly;
398
+ merchant methods list as `{payment_method_id, name, kind, instructions,
399
+ fee_amount, fee_label}` entries the COD-kind method wins the offline
400
+ tab, else the first manual method. The charged fee reads the cart-level
401
+ `payment_method_fee_total` decoration.
402
+ - **Settings** checkout rules (filter the listings + complete guard),
403
+ the methods' own fee configuration (Payments settings), Stripe
404
+ credentials, gift cards, `accounts_mode`.
405
+
406
+ ### `<CheckoutClient />` `checkout/checkout-client`
407
+
408
+ - **Purpose** the assembled single-page checkout layout over the hook +
409
+ every component below. Stores wanting a custom layout compose the same
410
+ hook + primitives instead.
411
+ - **SDK calls** — everything the hook + summary widgets call; the host
412
+ fetches `listShippingOptions`/`listPaymentProviders` (+ customer, +
413
+ `getIntegrationsConfig().cod`) and passes them down.
414
+ - **Props contract** hook options + `showGiftCards?`,
415
+ `logoByFulfillmentOptionId?`, Stripe `appearance`/`fonts`, `onCartChange?`
416
+ (receives every decorated cart from summary mutations; the layout also
417
+ re-runs `syncPaymentAmount` on those).
418
+ - **Mount rules** — `"use client"`; inside `CheckoutProvider`; redirect
419
+ server-side when `cart.completed_at` is set (the hook also flags
420
+ `cartIsCompleted`).
421
+
422
+ ### `<PaymentWrapper cart amount />` + Stripe scope `checkout/payment-wrapper`, `checkout/stripe-wrapper`
423
+
424
+ - **Purpose** — deferred-intent Stripe context: `PaymentWrapper` publishes
425
+ `{stripePromise, amount, currency, appearance, fonts}`;
426
+ `StripeElementsScope` mounts `<Elements mode:"payment">` where needed
427
+ (`passthrough` renders children scope-less on COD-only stores);
428
+ `StripeContext` boolean = "Stripe.js ready".
429
+ - **SDK calls** none (env: `NEXT_PUBLIC_STRIPE_KEY`; legacy env names
430
+ kept as fallbacks).
431
+ - **Mount rules** `PaymentWrapper` wraps the page ONCE with
432
+ `amount={optimisticTotalCents}` (cents at this Stripe boundary only);
433
+ `StripeElementsScope` lives INSIDE the payment section so a session
434
+ rotation never tears down the form/tracking tree.
435
+ - **Settings** — Stripe integration (whether `pp_stripe` is ever listed).
436
+
437
+ ### `<CheckoutAddressForm />` (+ `<AddressSelect />`, `<CompanyDetails />`) — `checkout/address-form`, `checkout/address-select`, `checkout/company-details`
438
+
439
+ - **Purpose** — email + delivery address (floating-label `Field`s, 3s-idle
440
+ pulse cue), saved-address picker, collapsible BG company-invoice fields
441
+ (name/VAT/MOL/address `cart.metadata` + customer profile).
442
+ - **SDK calls** none directly; the hook autosaves via `updateCart` /
443
+ `updateMe`. Saved addresses come from `customers.getMe()`
444
+ `addressesInRegion`.
445
+ - **Props contract** — everything from the hook (`formData`,
446
+ `handleFormChange`, `handleFieldBlur`, `regionCountries`, `addressInput`,
447
+ `addressError`, `pulseFields`); `hideCountry?` for single-country stores.
448
+ The country field is a NATIVE select carrying the chosen country's flag,
449
+ and it keeps `autocomplete="country"` on purpose: a custom listbox would
450
+ draw a flag per row but lose browser autofill and the phone's own picker,
451
+ and a checkout does not trade autofill for decoration. A list of exactly
452
+ one country still renders as a read-only name, which is right for a store
453
+ that truly sells to one country and is no longer the default for everyone
454
+ else (0.11.0).
455
+ - **Settings** Admin Settings → Markets → Regions, read through
456
+ `GET /api/store/countries`.
457
+
458
+ ### `<CheckoutShippingMethodList />` `checkout/shipping-method-list`
459
+
460
+ - **Purpose**radio list of shipping options with inline carrier-picker
461
+ expansion, free-shipping label, optional per-carrier logos, optional
462
+ read-only price preview pre-address (`previewWhenAddressNotReady`).
463
+ - **SDK calls** — renders `checkout.listShippingOptions(client, {cart_id})`
464
+ rows AS SERVED (rule-filtering + `checkout_method_order` are server-side).
465
+ - **Props contract** hook state + `econt?`/`boxnow?` picker configs
466
+ (detection by the STABLE `shipping_option.data.id` — `"econt-office"` /
467
+ `"boxnow-locker"`never display names; `boxnow.client` carries the SDK
468
+ transport) + `logoByFulfillmentOptionId?`.
469
+ - **Settings** — checkout rules (`target_type=shipping_option`), method
470
+ ordering, carrier integrations (which options exist at all).
471
+
472
+ ### `<EcontOfficeSelector />` / `<BoxNowLockerSelector />` — `checkout/econt-office-selector`, `checkout/boxnow-locker-selector`
473
+
474
+ - **Purpose**Bulgarian office/locker pickers: nearest-3 by haversine
475
+ distance (Nominatim geocode of the typed address), city-locked search
476
+ with Cyrillic↔Latin normalization, selected pill + change.
477
+ - **SDK calls** — Econt: none (public Econt Nomenclatures endpoint,
478
+ page-level cache). BoxNow: `integrations.listBoxNowLockers(client)`
479
+ ([integrations.md](integrations.md)); 503/502/network all render one
480
+ "temporarily unavailable" statediscover availability via
481
+ `carriers.boxnow.lockers_url`, don't probe.
482
+ - **Props contract** `{userCity, userAddress, selectedOffice|Locker,
483
+ onSelect}` (+ `client` for BoxNow). The CHOSEN office/locker is client
484
+ state; the hook writes it into `carrier_metadata` exactly once at
485
+ prepare (previous carrier keys are server-side swept
486
+ `_prepared_carrier_keys`).
487
+ - **Settings** — the carrier integrations (enabled/lockers).
488
+
489
+ ### `<CheckoutPaymentMethodList />` + `<PaymentButton />` — `checkout/payment-method-list`, `checkout/payment-button`
490
+
491
+ - **Purpose** pay-online vs cash-on-delivery radio rail. The online tab
492
+ hosts Stripe `<PaymentElement layout:"accordion">` (every
493
+ Dashboard-enabled method, no per-method code; deliberately NO
494
+ `fields.billingDetails.address` override the strict-completeness
495
+ IntegrationError fix). `PaymentButton` = the Buy button: re-entry-guarded
496
+ click `performBuyClick`, cycling processing narration, translated
497
+ inline errors, Price total.
498
+ - **SDK calls** — renders `checkout.listPaymentProviders` results via the
499
+ hook's `hasCard`/`hasCod`; the click path runs the hook's calls.
500
+ - **Props contract** hook state + `buyButtonNotReady(Reason?)`,
501
+ `gatePaymentUntilDelivery?` (false = always-visible payment section),
502
+ `beforePaymentButton?` slot, `total` (pass `optimisticTotal`),
503
+ `logError?`.
504
+ - **Settings**COD integration (`labels.codNote` + fee timing), Stripe
505
+ credentials, checkout rules (`target_type=payment_method`) — hidden
506
+ methods are also re-enforced at complete (`checkout_method_hidden`).
507
+
508
+ ### `<OrderSummary />` (+ `CheckoutLineItem`, `<LineItemCard />`) — `checkout/order-summary`, `checkout/line-item-card`
509
+
510
+ - **Purpose** items (flat rows with qty pill), promo + gift-card
511
+ widgets, totals breakdown (subtotal / shipping / COD fee / discount /
512
+ VAT / total + gift-card tender rows UNDER the unchanged total), secure
513
+ badge. `LineItemCard` is the standalone card variant.
514
+ - **SDK calls** — `carts.updateLineItem` (quantity); child widgets below.
515
+ Totals are rendered STRAIGHT from the cart decoration: `item_total`,
516
+ `shipping_total`, `payment_method_fee_total`/`payment_method_fee_label`, `discount_total`,
517
+ `tax_total`, `total`, `gift_card_total`, `gift_card_remainder`.
518
+ - **Props contract** — `{client, cart, optimisticShippingCost,
519
+ onOptimisticShippingClear?, optimisticCodFee?, onOptimisticCodFeeClear?,
520
+ methodFeeLabel?, showGiftCards?, onCartChange?}`. Optimistic values clear
521
+ automatically once the server cart catches up.
522
+ - **Settings** — COD integration (fee row), gift cards, promotions.
523
+
524
+ ### `<DiscountSection />` — `checkout/discount-section`
525
+
526
+ - **Purpose** collapsible promo-code input + applied-promotion list
527
+ (percentage or fixed amount display).
528
+ - **SDK calls** `POST /api/store/carts/:id/promotions {promo_codes}`
529
+ via the client transport (additive apply; the carts SDK module ships no
530
+ wrapper for this route yet see [carts.md](carts.md) for the cart
531
+ shape; `cart.promotions` arrives as the `{promotion:{…}}` pivot embed,
532
+ unwrapped here).
533
+ - **Props contract** — `{client, cart, onCartChange?}`. Errors code-first
534
+ via `promotion-error-copy` (`promotion_not_found`/`promotion_inactive`/…
535
+ + the no-email campaign-budget heuristic).
536
+ - **Settings** — promotions admin (codes, status, application method).
537
+
538
+ ### `<GiftCardSection />` `checkout/gift-card-section`
539
+
540
+ - **Purpose** gift-card code input + applied-cards chips (masked
541
+ `••••last4`, per-card live coverage, remove). Cartbase-new — no legacy
542
+ equivalent.
543
+ - **SDK calls** — `giftCards.applyGiftCard` / `giftCards.removeGiftCard`
544
+ ([gift-cards.md](gift-cards.md)). Renders `cart.gift_cards[]` /
545
+ `gift_card_total` / `gift_card_remainder`server truth, no client
546
+ math; totals never move (tender, not discount).
547
+ - **Props contract** — `{client, cart, onCartChange?}`. Error copy honors
548
+ the anti-oracle contract: ONE generic message for `invalid_gift_card`
549
+ (never branch on reasons the API hides), `rate_limited` for burned
550
+ windows. After apply/remove the host must `syncPaymentAmount` (the
551
+ CheckoutClient wiring does).
552
+ - **Settings** gift-cards admin (issue/disable, expiry, balances).
553
+
554
+ ### Error-copy maps — `checkout/payment-error-copy`, `checkout/address-error-copy`, `checkout/promotion-error-copy`
555
+
556
+ - **Purpose** — every raw failure → actionable Bulgarian copy, CODE-FIRST
557
+ against the Cartbase error envelope (`StoreApiError.code`), then the
558
+ production substring layers (Stripe.js browser errors carry no Cartbase
559
+ code), then a clean per-context generic. `PAYMENT_ERROR_CODE_COPY`
560
+ covers EVERY documented code of complete/prepare/sync/refresh
561
+ `checkout_method_hidden`, `account_required`,
562
+ `gift_card_insufficient_balance`, the 402 payment family,
563
+ (unit-gated by `tests/unit/storefront-checkout.test.ts`).
564
+ - **SDK calls** none (pure).
565
+ - **Mount rules** — call `translatePaymentError(err, "card"|"cod")` /
566
+ `translateAddressError(err)` / `translatePromotionError(err,
567
+ {hasEmail})` / `translateGiftCardError(err)` at the display boundary;
568
+ never show `err.message` raw.
569
+
570
+ Also in the family barrel: `compareAddresses` (saved-address match
571
+ detection) and the `geocode` helpers (`normalizeForMatch`,
572
+ `distanceMeters`, `formatDistance`, `cleanAddress`, `geocodeAddress`)
573
+ extra modules beyond the per-file exports, imported from
574
+ `@cartbase/storefront/checkout`.
575
+
576
+ ---
577
+
578
+ ## Family: cart-drawer (`@cartbase/storefront/cart-drawer/*`)
579
+ Sliding cart UI, production-proven layout. All
580
+ components are `"use client"`. Money is EUR decimal major units everywhere
581
+ (cart totals are SERVER truth from the decorated cart — components render,
582
+ never compute; the only client arithmetic is the optimistic
583
+ `unit_price × quantity` preview that the next server snapshot replaces).
584
+ Domain doc for every call: [carts.md](carts.md); gift-card tender:
585
+ [gift-cards.md](gift-cards.md); cross-sell sources: [products.md](products.md)
586
+ + [search.md](search.md).
587
+
588
+ ### `<CartDrawerProvider client cart onCartChange … />` — `cart-drawer/context`
589
+
590
+ - **Purpose** the family's root: open/close state, the optimistic cart
591
+ snapshot (React 19 `useOptimistic`), labels + hrefs, and the SDK-wired
592
+ mutations every child uses. Auto-opens when the product-line item count
593
+ rises **from a nonzero base** (the guard that keeps the late-arriving
594
+ initial cart snapshot from opening the drawer on page load — so the very
595
+ FIRST add does not auto-open; open explicitly via `useCartDrawer().open`
596
+ / the header `CartButtonClient`, or wire `ProductActions`' `openCart`
597
+ seam in a client composition). Locks body scroll while open, closes on
598
+ Escape.
599
+ - **SDK calls** `@cartbase/storefront/api/carts`: `createCart` (first
600
+ `addItem` with no cart `region_id` falls back to the store default),
601
+ `retrieveCart` (mount in `client`+`cartId` mode, and `refresh()`),
602
+ `addLineItem`, `updateLineItem` (body `{quantity}` ONLY Cartbase accepts
603
+ no metadata on update; quantity 0 deletes), `deleteLineItem`. Every
604
+ mutation returns the FULL decorated cart, which becomes the confirmed
605
+ snapshot no page refetch needed.
606
+ - **Props contract** `cart?: Cart|null` (server-fetched snapshot; prop
607
+ updates win), `client?: StorefrontClient` (enables `addItem`/
608
+ `updateQuantity`/`removeItem`/`refresh`), `cartId?` (retrieve-on-mount
609
+ when no snapshot), `onCartChange?(cart)` (fires on every confirmed
610
+ change INCLUDING first-add cart creation persist `cart.id` here),
611
+ `onOptimisticError?(failure)` (the ops funnel for every failed
612
+ optimistic mutation wire to store logging; replaces the source's
613
+ `logEvent` backend call), `labels?: Partial<CartDrawerLabels>` (overrides:
614
+ the language mounted by `StorefrontLocaleProvider` is the default),
615
+ `hrefs?: {checkout, browse, productPrefix}`.
616
+ - **Hook** — `useCartDrawer()` → `{isOpen, open, close, toggle, cart,
617
+ addItem(variantId, qty?, display?), updateQuantity(lineId, qty),
618
+ removeItem(lineId), refresh, applyOptimistic, dispatchOptimistic,
619
+ labels, hrefs}`. `applyOptimistic(action, serverAction)` stays for
620
+ store-owned server actions (PDP add via RSC).
621
+ - **Settings** — store default region + enabled currencies (cart create),
622
+ B2B price lists via attached customer, gift-card product flags
623
+ (`is_giftcard` lines are non-discountable), automatic promotions,
624
+ COD-fee integration (injects the fee line the drawer hides).
625
+ - **Mount rules** — wrap the root layout; ONE provider per app. i18n: mount
626
+ `StorefrontLocaleProvider` above it and the drawer speaks the pack with
627
+ nothing handed over (since 2026-09-14); `labels` overrides single keys
628
+ (`cart-drawer/labels` holds the English defaults).
629
+
630
+ ### `<CartDrawer sidebar children />` — `cart-drawer/cart-drawer`
631
+
632
+ - **Purpose** — the slide-out shell: overlay + right panel (`z-[60]`/
633
+ `z-[61]` — the consent banner sits above at `z-[70]`), optional desktop
634
+ left sidebar slot for cross-sell. No SDK calls.
635
+ - **Mount rules** — render once, inside the provider; put drawer body
636
+ components in `children`. `panelClassName` composes onto the panel.
637
+
638
+ ### `<CartDrawerHeader />` `cart-drawer/header`
639
+
640
+ - **Purpose** — title + item-count badge + close button. Counts PRODUCT
641
+ lines via `productItemCount` (`lib/cart-helpers`) so a backend-injected
642
+ COD-fee line never inflates the count. No SDK calls (context cart).
643
+
644
+ ### `<CartPromoBanner message variant />` `cart-drawer/promo-banner`
645
+
646
+ - **Purpose**top strip message (`info|success|warning`). Pure props.
647
+
648
+ ### `<CartTieredProgress tiers currencyCode />` `cart-drawer/tiered-progress`
649
+
650
+ - **Purpose** — progress bar to the next shipping/discount tier with
651
+ checkpoint markers. Reads `cart.total` (tax-inclusive server truth).
652
+ - **Props contract** — `tiers: CartTier[]` sorted ascending;
653
+ `threshold` in EUR major units (50 = €50). Pure math exported as
654
+ `computeTierProgress(amount, tiers)` (unit-tested).
655
+
656
+ ### `<CartItem item currencyCode>{upsell?}</CartItem>` `cart-drawer/item`
657
+
658
+ - **Purpose** one line row: thumbnail, title link, variant, quantity
659
+ stepper, per-line price with strikethrough (server `total` <
660
+ `original_total`), remove button (optimistic, via the provider's
661
+ `removeItem` → `DELETE line-items/:id`).
662
+ - **Props contract** `item: CartLineItem` (the SDK's decorated line
663
+ per-line totals are server-computed), `currencyCode`, `children` =
664
+ per-item upsell slot.
665
+ - Subcomponents: `item/quantity` (`<CartItemQuantity lineId quantity
666
+ maxQuantity? />`stepper floor 1, calls `updateQuantity` with
667
+ `{quantity}`), `item/variant` (`<CartItemVariant variantTitle
668
+ options? />` Cartbase lines carry the flat `variant_title` string, not
669
+ an embedded variant object), `item/upsell` (`<CartItemUpsell products
670
+ onAdd />` — feed from `listRelatedProducts`, `variantId` included so
671
+ `onAdd` can call `addItem`).
672
+
673
+ ### `<CartFreeGift … />`, `<CartGiftWrap … />`, `<CartNotes … />`, `<CartRewardsPoints … />`
674
+
675
+ - Merchandising slots (`cart-drawer/free-gift`, `gift-wrap`, `notes`,
676
+ `rewards-points`) — pure props + labels; prices EUR major units. The
677
+ store owns the effects: `CartGiftWrap.onToggle` → add/remove the store's
678
+ gift-wrap variant via `addItem`/`removeItem`; `CartNotes.onSave` →
679
+ `updateCart(client, cartId, {metadata: {...cart.metadata, gift_note}})`
680
+ the update route REPLACES metadata wholesale (no merge), so always
681
+ spread the current `cart.metadata`; cart metadata is copied onto the
682
+ order at complete. No admin settings — configured per store in code.
683
+
684
+ ### `<CartCrossSellSidebar / CartCrossSellCarousel products onAdd label? />` `cart-drawer/cross-sell-*`
685
+
686
+ - **Purpose** desktop sidebar card list / horizontal strip of
687
+ recommendations.
688
+ - **SDK calls** feed `products` from `api/products.listProducts`
689
+ (curated collection/tag) or `api/search.listRelatedProducts` (anchor
690
+ complements — manual admin picks first, deterministic fallback fills;
691
+ see [search.md](search.md)). Map responses with `toCrossSellProduct`
692
+ (exported from `cart-drawer/cross-sell-sidebar`; picks the first variant
693
+ with a server-computed `calculated_price`, returns null unpriced pass
694
+ `currency_code` on the listing call). Wire `onAdd(productId, variantId)`
695
+ `addItem(variantId)`.
696
+ - **Settings** Admin Product Related (manual picks); price lists
697
+ (what `calculated_price` contains); publishable-key channel scope.
698
+
699
+ ### `<CartSummaryBreakdown />` — `cart-drawer/summary-breakdown`
700
+
701
+ - **Purpose** full totals breakdown rendered EXACTLY from the decorated
702
+ cart: `subtotal`, `discount_total` (>0, negated for display),
703
+ `shipping_total` (once a shipping method is set; 0 renders FREE; before
704
+ that "calculated at checkout"), `tax_total` (>0), `payment_method_fee_total` (>0,
705
+ labeled by the server's `payment_method_fee_label`), `total`, then one row per
706
+ applied gift card (masked `last4`, negated; a depleted card stays listed
707
+ at 0) and `gift_card_remainder` what the remainder provider charges.
708
+ Row selection is the pure `selectSummaryRows(cart)` (unit-tested).
709
+ - **SDK calls** — none directly (context cart; every cart read re-derives
710
+ gift-card tender from the live ledger).
711
+ - **Settings** — COD integration (fee + label), promotions, gift cards.
712
+
713
+ ### `<CartStickyFooter />``cart-drawer/sticky-footer`
714
+
715
+ - **Purpose** pre-checkout subtotal + checkout CTA. Deliberately shows
716
+ `productTotal(cart.items)` (product lines only) — NOT `cart.total`,
717
+ which carries shipping/tax/COD checkout-context state that must not
718
+ leak into the shopping drawer. Navigates to `hrefs.checkout`.
719
+
720
+ ### `<CartPaymentBadges methods? badges? />`, `<CartContinueShopping />`, `<CartEmpty />`
721
+
722
+ - `payment-badges` inline SVG payment logos (visa/mastercard/googlepay/
723
+ applepay/amex), overridable per store. `continue-shopping` — close link.
724
+ `empty` empty state with `hrefs.browse` CTA. Pure props + labels.
725
+
726
+ ### `<CartDrawerTemplate config />` — `cart-drawer/template`
727
+
728
+ - **Purpose**the optional default assembly; every
729
+ feature opt-in via `CartDrawerConfig`. Stores wanting a different layout
730
+ compose the primitives themselves inside `<CartDrawer>`.
731
+ - **Props contract** `config`: `promoBanner`, `shippingTiers`
732
+ (EUR thresholds), `freeGift` (`minCartTotal` EUR), `giftWrap`,
733
+ `notes`, `rewards` (`pointsPerCurrency` = points per 1 EUR
734
+ major-unit port change from the source's per-cent rate), `crossSell`
735
+ (`products` or async `loader` fires once when the drawer first has
736
+ items; build it from the SDK + `toCrossSellProduct`). Cross-sell adds
737
+ are wired to the provider's `addItem` automatically.
738
+ - **Mount rules** — renders product lines only (`isProductLine`) — a
739
+ fee-only cart renders as empty rather than a fake product row.
740
+
741
+ ---
742
+
743
+ ## Family: products (`@cartbase/storefront/products/*`)
744
+ The PDP + product-card family, production-proven. All prices
745
+ render the SERVER-computed `variant.calculated_price` via
746
+ `lib/get-product-price` (Cartbase's flat `price_list_type` wire shape) no
747
+ component computes money. Product data is the canonical `StoreProduct`
748
+ (`@cartbase/storefront/api/products`) every discovery endpoint serves.
749
+
750
+ **Labels / i18n** — `products/labels` (`ProductLabels` + English defaults),
751
+ and a pack from `locales/*` for any other language,
752
+ `products/context` (`<ProductLabelsProvider labels>` + `useProductLabels()`;
753
+ components read copy only through the context or explicit `labels` props).
754
+
755
+ ### `<ProductLabelsProvider labels />` `products/context`
756
+
757
+ - **SDK calls** none. **Settings** none.
758
+ - **Mount rules** — client component; wrap the product page (or app) once;
759
+ partial `labels` merge over English defaults.
760
+
761
+ ### `<Thumbnail thumbnail images size isFeatured />` `products/thumbnail`
762
+
763
+ - **Purpose** the product image tile used by cards; `ImageOff` fallback
764
+ when no image exists.
765
+ - **SDK calls** — none (props: `product.thumbnail` / `product.images`).
766
+ - **Props** — `size: "small"|"medium"|"large"|"full"|"square"` (aspect +
767
+ width), `isFeatured` (11/14 aspect), `className`.
768
+ - **Mount rules** server-safe; uses `next/image` (host must allow the
769
+ media domain in `next.config` images).
770
+
771
+ ### `<PreviewPrice price />` — `products/preview-price`
772
+
773
+ - **Purpose** — card price line; strikethrough original + accent price when
774
+ `price_type === "sale"`.
775
+ - **SDK calls** — none; takes a `VariantPrice` from
776
+ `lib/get-product-price` `getProductPrice(...).cheapestPrice`.
777
+ - **Settings** price lists (whether a `sale` type ever appears).
778
+
779
+ ### `<ProductPrice product variant? />` — `products/product-price`
780
+
781
+ - **Purpose** — PDP price panel: "From <cheapest>" until a variant is
782
+ selected, then the variant price; sale shows original + percentage off.
783
+ - **SDK calls** — none directly; the product must have been fetched WITH a
784
+ pricing context (`currency_code`/`region_id`) or it renders the loading
785
+ shimmer (no `calculated_price` → no price, by design).
786
+ - **Settings** — price lists / B2B groups (via the Bearer JWT on the fetch),
787
+ region/currency context.
788
+ - **Mount rules** client; reads labels from context.
789
+
790
+ ### `<OptionSelect option current updateOption title disabled />` `products/option-select`
791
+
792
+ - **Purpose** — one option row of value buttons (`product.options[]`, which
793
+ carries `values[]`).
794
+ - **SDK calls** none. **Mount rules** — client; controlled by the parent.
795
+
796
+ ### The product page contract — `products/variant-url`, `products/use-product-actions`
797
+
798
+ Five rules every buy panel is held to, the package's own and a store's:
799
+
800
+ 1. **The address is the state.** The chosen variant is named in the URL as
801
+ `?variant=<id>`, Shopify's parameter, so a link copied from an email, an
802
+ ad or a chat opens on the variant it was copied from and a refresh never
803
+ forgets a choice. The value is the id's DIGITS (`pvar_1000683784068`
804
+ travels as `1000683784068`), the platform's law for every id in a URL;
805
+ reading accepts the whole key too.
806
+ 2. **The first paint is right.** The page reads `searchParams.variant` on the
807
+ server and passes it down (`initialVariantId` on `ProductTemplate`,
808
+ `ProductActions` or the hook), so the server renders that variant's
809
+ price, code and stock and nothing flashes.
810
+ 3. **A switch is a browser event.** The address is rewritten with the
811
+ browser's own history call, which Next's router picks up; no server render
812
+ runs. (Going through the router made every click a navigation: the address
813
+ lagged a whole render behind the click and a fast second click left it on
814
+ the first variant.)
815
+ 4. **The default is the merchant's.** With no variant in the address, the
816
+ variant made of each option's FIRST value is chosen, in the option order
817
+ the admin shows; never the wire's first variant, which is the database's
818
+ scan order.
819
+ 5. **The server is asked only for what needs it:** adding to the cart.
820
+
821
+ `products/variant-url` is the pure half (`VARIANT_PARAM`,
822
+ `variantParamValue`, `findVariantByParam`, `defaultVariant`, `variantHref`;
823
+ unit-tested in tests/unit/storefront-product-page-contract.test.ts).
824
+ `useProductActions({product, addToCart, initialVariantId?, onAddToCart?,
825
+ openCart?, syncAddress?})` is the behaviour as a headless hook: the chosen
826
+ values, `choose`, the resolved `variant`, `quantity`/`setQuantity`,
827
+ `inStock` (the server's `in_stock` predicate, never re-derived;
828
+ `insufficient_inventory` on add flips it until the choice changes),
829
+ `price`, `add`. A store that wants its own look draws its own panel on the
830
+ hook and inherits every rule; `ProductActions` below is the package's own
831
+ panel on the same hook. Client hook; it reads `useSearchParams`, so mount it
832
+ under a `<Suspense>` boundary on a static page (a dynamic page needs none).
833
+
834
+ ### Pure: `products/variant-matching` (extra module, Cartbase addition)
835
+
836
+ `optionsAsKeymap` / `optionsMatch` / `findMatchingVariant` the
837
+ option-choice variant resolution extracted from product-actions, reading
838
+ Cartbase's option-value LINK shape (`variant.options[].value.{option_id,value}`)
839
+ with a legacy flat-row fallback. Unit-tested
840
+ (tests/unit/storefront-catalog.test.ts).
841
+
842
+ ### `<ImageGallery images />` — `products/image-gallery`
843
+
844
+ - **Purpose** — stacked PDP gallery in the merchant's order (the store API
845
+ serves every product's images by rank since 2026-09-15); first three
846
+ images `priority`.
847
+ - **SDK calls** none (props: `product.images`). Server-safe.
848
+
849
+ ### `<ProductActions product addToCart initialVariantId? disabled? onAddToCart? openCart? showQuantity? />` — `products/product-actions`
850
+
851
+ - **Purpose** — the package's own buy panel on `useProductActions`: option
852
+ rows, price, the quantity stepper (`showQuantity`, on by default), the
853
+ add button, the mobile bar. Every rule of the product page contract above
854
+ is the hook's; this is only the markup.
855
+ - **SDK calls** — none itself; the injected `addToCart({variantId,
856
+ quantity})` seam is the host's cart orchestration (typically `api/carts`
857
+ `addLineItem` + the host's cart-id cookie). `openCart` replaces the
858
+ legacy cart-drawer context import (no hard cross-family dependency).
859
+ - **Stock contract** the variant's `in_stock` is THE availability
860
+ predicate, computed by the server on every product read and only read
861
+ here; the SERVER still enforces at add (400 `insufficient_inventory`,
862
+ which flips the button to the out-of-stock state until the choice
863
+ changes).
864
+ - **Settings** — inventory (manage/backorder flags, kit components at add),
865
+ price lists, promotions (server re-applies on add).
866
+ - **Mount rules** client; needs `ProductLabelsProvider` for non-English.
867
+ Fire the tracking trio (`trackAddToCart`/GA4/Rybbit) from `onAddToCart`.
868
+
869
+ ### `<MobileActions />` `products/mobile-actions`
870
+
871
+ - **Purpose** — the `lg:hidden` sticky bottom bar + options bottom sheet
872
+ (z-[75], above the cart drawer's z-[60]) shown when the desktop actions
873
+ scroll out of view.
874
+ - **SDK calls** none; pure props from `ProductActions` (which mounts it —
875
+ rarely used directly).
876
+
877
+ ### `<ProductTabs product />` — `products/product-tabs`
878
+
879
+ - **Purpose** — accordion: product information (material, origin, type,
880
+ weight, dimensions) + static shipping/returns copy from labels.
881
+ - **SDK calls** — none. **Settings** — none (copy via labels).
882
+
883
+ ### `<ProductInfo product />` — `products/product-info`
884
+
885
+ - **Purpose** — collection link (`/collections/<handle>`), title,
886
+ description. **SDK calls** none. Server-safe.
887
+
888
+ ### `<ProductPreview product isFeatured? />` — `products/product-preview`
889
+
890
+ - **Purpose** — THE product card (links `/products/<handle>`): thumbnail +
891
+ title + cheapest price. Reused by store grids, search results, related
892
+ strip; every template accepts `renderProduct` to swap it for a custom
893
+ card.
894
+ - **SDK calls** — none; expects a `StoreProduct` fetched with pricing
895
+ context for the price line. Server-safe.
896
+
897
+ ### `<RelatedProducts client product labels pricingContext? limit? renderProduct? />` — `products/related-products`
898
+
899
+ - **Purpose**the "You might also like" strip.
900
+ - **SDK calls** — `api/search` `listRelatedProducts(product.id)` manual
901
+ admin picks first, deterministic fallback fills to `limit`
902
+ (`auto_filled`); anchor never appears. Renders nothing on empty/404.
903
+ - **Labels** — `labels: ProductLabels`, REQUIRED: a server component cannot
904
+ read the provider, so pass `STORE_LOCALE.products`; `ProductTemplate`
905
+ passes its own through.
906
+ - **Settings** — Admin → Product → Related (manual picks), price lists.
907
+ - **Mount rules** — async server component; render inside `<Suspense>`.
908
+
909
+ ### `<ProductActionsWrapper client id pricingContext? initialVariantId? addToCart … />` — `products/product-actions-wrapper`
910
+
911
+ - **Purpose** — re-fetches the product with the LIVE pricing context (and
912
+ the client's Bearer JWT group-aware B2B prices) and mounts
913
+ `ProductActions`; the PDP shell stays cacheable.
914
+ - **SDK calls** — `api/products` `retrieveProduct(idOrHandle,
915
+ pricingContext)`; 404 → renders nothing.
916
+ - **Mount rules** — async server component inside `<Suspense>` (fallback:
917
+ disabled `<ProductActions>`).
918
+
919
+ ### `<ProductTemplate client product labels addToCart initialVariantId? renderProduct? pricingContext? onAddToCart? openCart? promises? hideSpecs? sections? />` — `products/product-template`
920
+
921
+ - **Purpose** — the full PDP: sticky info column (`ProductInfo` +
922
+ `ProductTabs`), gallery, sticky actions column (suspended
923
+ `ProductActionsWrapper`), related strip. `initialVariantId` is the
924
+ address's variant (`searchParams.variant`, rule 2 of the contract);
925
+ `renderProduct` is the store's card for the related strip, so a store
926
+ with its own card no longer draws the library's preview on one page.
927
+ - **SDK calls** via children (retrieveProduct, listRelatedProducts). The
928
+ page fetches the product by handle (`retrieveProduct`) and passes it in.
929
+ - **Settings** — union of children's.
930
+ - **Mount rules** server component; `labels` is REQUIRED (the pack's
931
+ `products` area) because the related strip renders on the server; the
932
+ client parts inside read `ProductLabelsProvider`, which
933
+ `StorefrontLocaleProvider` mounts; `addToCart`/`openCart` seams as on
934
+ `ProductActions`.
935
+
936
+ ## Family: store (`@cartbase/storefront/store/*`)
937
+ The listing family: paginated grids, sort, collection/category/search
938
+ templates. Sorting discipline: the API's `order` param is the authority for
939
+ paginated listings; client-side re-sort (`lib/sort-products`) exists ONLY
940
+ for the price sorts on the plain products listing (price is not a products
941
+ column) over the legacy 100-item window.
942
+
943
+ **Labels / i18n** `store/labels` (`StoreLabels` + defaults + the pure
944
+ `sortOptionLabelKeys` map — completeness unit-tested) and `locale.store`
945
+ from a pack. Templates take `labels: StoreLabels`, REQUIRED: they are
946
+ server components and cannot read the provider, so a page hands them
947
+ `STORE_LOCALE.store` (`en.store` for an English store).
948
+
949
+ ### `<Pagination page totalPages />` — `store/pagination`
950
+
951
+ - **Purpose** — windowed page-number pagination; writes the `page` query
952
+ param and pushes the route (server re-renders with the new offset).
953
+ - **SDK calls** none. **Mount rules** — client.
954
+
955
+ ### `<SortSelect sortBy? labels? />` — `store/sort-select`
956
+
957
+ - **Purpose** — sort sidebar; writes the `sortBy` query param
958
+ (`created_at` | `price_asc` | `price_desc`, rendered from
959
+ `sortOptionLabelKeys`).
960
+ - **Props** `sortBy` OPTIONAL (port adaptation): on collection pages no
961
+ selection = the collection's admin `default_sort`, nothing highlighted.
962
+ - **SDK calls** none. **Mount rules** — client.
963
+
964
+ ### `<PaginatedProducts client page sortBy? collectionId? categoryId? productsIds? pricingContext? renderProduct? />` — `store/paginated-products`
965
+
966
+ - **Purpose** — the 12-per-page product grid + pagination over the plain
967
+ products listing.
968
+ - **SDK calls** — `api/products` `listProducts`: `created_at` →
969
+ server `order:"-created_at"` + real offset pagination; `price_asc`/
970
+ `price_desc` 100-item window fetch, `lib/sort-products` re-sort, slice
971
+ (the proven production approach see module JSDoc for the >100 caveat).
972
+ - **Props note** `collectionId` filters by collection MEMBERSHIP since
973
+ 2026-09-15 (a product in three collections is listed under all three); the
974
+ collection's own ordered page lives in `CollectionTemplate`.
975
+ - **Settings** price lists (pricing context), sales-channel/publishable-
976
+ key scope on the client.
977
+ - **Mount rules** async server component; render inside `<Suspense>`
978
+ with `<SkeletonProductGrid />`.
979
+
980
+ ### `<SkeletonProductGrid numberOfProducts? />` — `store/skeleton-product-grid`
981
+
982
+ - **Purpose** pulse skeleton for any product grid. Server-safe, no calls.
983
+
984
+ ### `<StoreTemplate client labels sortBy? page? pricingContext? renderProduct? />` — `store/store-template`
985
+
986
+ - **Purpose** the `/store` all-products page: sort sidebar + heading +
987
+ suspended `PaginatedProducts`.
988
+ - **SDK calls** — via `PaginatedProducts`. Pass the page's `sortBy`/`page`
989
+ query params straight in.
990
+
991
+ ### `<CollectionTemplate client collection labels sortBy? page? pricingContext? renderProduct? />` — `store/collection-template`
992
+
993
+ - **Purpose** collection page over the MEMBERSHIP listing (multi-
994
+ collection products appear in every collection).
995
+ - **SDK calls** — `api/collections` `listCollectionProducts(collection.id)`
996
+ the admin `default_sort` is honored SERVER-side when `sortBy` is unset
997
+ (no client re-sort, port adaptation); a shopper override maps
998
+ `created_at→newest`, `price_asc`, `price_desc` to the `order` param
999
+ (price sorting server-side here, unlike the plain listing). Fetch the
1000
+ collection itself via `listCollections({handle})` in the page.
1001
+ - **Settings** collection `default_sort` + manual order, smart-collection
1002
+ conditions, channel links (scoped-away collection 404s), price lists.
1003
+ - **Mount rules** — server component; grid suspends internally.
1004
+
1005
+ ### `<CategoryTemplate client category labels sortBy? page? pricingContext? renderProduct? />` — `store/category-template`
1006
+
1007
+ - **Purpose** — category page: breadcrumbs (ancestor chain), description,
1008
+ child-category links, `PaginatedProducts` filtered by `category_id`.
1009
+ - **SDK calls** — via `PaginatedProducts`. Fetch the category in the page
1010
+ with `retrieveCategory(id, {include_ancestors_tree: true,
1011
+ include_descendants_tree: true})` without the flags the breadcrumb and
1012
+ children sections don't render.
1013
+ - **Settings** category tree (active/internal flags are server-filtered).
1014
+
1015
+ ### `<SearchTemplate client searchParams labels basePath? pricingContext? limit? renderProduct? />` — `store/search-template`
1016
+
1017
+ - **Purpose** — the search results page in the
1018
+ store-template idiom GET query box, facet sidebar from the response
1019
+ `facets[]`, result grid (reuses the product card), pagination. Fully
1020
+ URL-state driven: works server-rendered with zero own client JS.
1021
+ - **SDK calls** — `api/search` `searchProducts` (only when `q` present).
1022
+ Facet buckets toggle by rewriting the wire-named query params
1023
+ (`collection_id`/`type_id`/`tag_id` CSV, `price_min`/`price_max`,
1024
+ `availability`, `option.<Title>` CSV) via the pure `store/search-params`
1025
+ helpers (round-trip unit-tested); every toggle resets `page`.
1026
+ - **Settings** Search & discovery: synonyms, pins/boosts, facet config
1027
+ (order/enabled, price bucket strategy `auto`/`fixed`); price facet
1028
+ currency follows the pricing context.
1029
+ - **Mount rules** async server component; pass the route's raw
1030
+ `searchParams`; `basePath` defaults to `/search`.
1031
+
1032
+ ### Pure: `store/search-params` (extra module, Cartbase addition)
1033
+
1034
+ `parseSearchParams` / `buildSearchQueryString` / `fromQueryString` /
1035
+ `toggleFacetSelection` / `isFacetSelected` / `clearFilters` /
1036
+ `toSearchQuery` the search page's URL-state machine, exported for custom
1037
+ search UIs (chips, drawers) that want the same URL contract.
1038
+
1039
+ ---
1040
+
1041
+ ## Family: order (`@cartbase/storefront/order/*`)
1042
+ Order confirmation + account order views, production-proven. Some
1043
+ platforms ship ONE `StoreOrder` object carrying computed line
1044
+ totals, order totals, shipping methods and payments; Cartbase splits those
1045
+ across surfaces, so the family takes them as separate props — the Cartbase
1046
+ `StoreOrderDetail` (`api/orders`) carries items as version-pivot rows
1047
+ (`{quantity, line_item}`), fulfillments with tracking labels, and both
1048
+ addresses, while MONEY comes from the decorated cart (`api/carts` `Cart`)
1049
+ or the order summary snapshot. Components render server truths; the only
1050
+ arithmetic is the two documented production subtractions in the totals
1051
+ selector.
1052
+
1053
+ Labels: `OrderLabelsProvider`/`useOrderLabels` (`order/context`) +
1054
+ `defaultOrderLabels` (`order/labels`, English) + `locale.order` from a
1055
+ pack in `locales/*` for any other language. Every
1056
+ component also takes a `labels` prop pick, and the template's is REQUIRED
1057
+ (`labels: OrderLabels`): it renders on the server, where no provider
1058
+ reaches, so the page passes `STORE_LOCALE.order`.
1059
+
1060
+ ### `<OrderCompletedTemplate order totals labels items? shippingMethod? paymentProviderId? cardLast4? … />` — `order/order-completed-template`
1061
+
1062
+ - **Purpose** — the full confirmation page: hero header, fulfillment
1063
+ timeline, items+totals card, contact/delivery/payment/help cards,
1064
+ continue-shopping CTA.
1065
+ - **SDK calls** none itself; feed it: `order` (`retrieveOrder` /
1066
+ `retrieveOrderByDisplayId` detail, or the `completeCart()` response),
1067
+ `totals` (the decorated cart from checkout, or
1068
+ `orderTotalsFromSummary(completeCart().order.summary)`), optional
1069
+ normalized `items` (prefer `displayItemFromCartLine` right after
1070
+ checkout — keeps server-computed per-line discounts), `shippingMethod` /
1071
+ `paymentProviderId` / `cardLast4` from checkout state.
1072
+ - **Mount rules** — order-confirmation route (server component OK; only
1073
+ the timeline child is client). The Purchase tracking trio (fbq/gtag/
1074
+ rybbit, deduped by `order.display_id`) is APP-OWNED: fire it from the
1075
+ confirmation route exactly once per order per the tracking family's
1076
+ dedupe contract — the template deliberately does NOT fire it.
1077
+ - **Settings** COD settings (fee row presence + `payment_method_fee_label`),
1078
+ checkout rules (which provider ids appear), store locales (labels pack).
1079
+
1080
+ ### `<OrderConfirmationHeader order />` — `order/order-confirmation-header`
1081
+
1082
+ - **Purpose** check hero + "Order #display_id" + localized date chips.
1083
+ - **Props** — structural `OrderHeaderData` (`display_id`, `email`,
1084
+ `created_at`) — order detail and `completeCart().order` both satisfy it.
1085
+ `locale` drives `toLocaleDateString`.
1086
+
1087
+ ### `<OrderItemsList items currencyCode />` + `<OrderItem item currencyCode />` — `order/order-items-list`, `order/order-item`
1088
+
1089
+ - **Purpose** the purchased lines (thumb, title, variant caption, qty,
1090
+ line total with discount strike-through when the source had it).
1091
+ - **Data seam** normalized `OrderDisplayItem[]` via the pure converters
1092
+ `displayItemFromOrderItem(pivot)` (order path: `unit_price × quantity`)
1093
+ / `displayItemFromCartLine(line)` (cart path: server `total` /
1094
+ `original_total`). Legacy COD-fee line items
1095
+ (`metadata.is_cod_fee=true`) are hidden here and surfaced in
1096
+ `OrderTotals` instead (`lib/cart-helpers.isProductLine`); Cartbase-native
1097
+ COD fees are never line items, so on pure Cartbase data the filter is a
1098
+ no-op safety net. Newest-first sort by `createdAt` when present.
1099
+
1100
+ ### `<OrderTotals totals currencyCode items? methodFeeLabel? />` `order/order-totals`
1101
+
1102
+ - **Purpose** — the money breakdown: Subtotal / Shipping (FREE badge at
1103
+ 0) / COD fee / Discount (negated) / Tax / Total, all via `Price`.
1104
+ - **Data seam** `OrderTotalsSource` (the decorated cart satisfies it:
1105
+ `item_subtotal`, `shipping_subtotal`, `discount_total`, `tax_total`,
1106
+ `total`, `payment_method_fee_total`, `payment_method_fee_label`); the summary snapshot adapts
1107
+ via `orderTotalsFromSummary`. Row policy is the pure, unit-tested
1108
+ `selectOrderTotalsRows`: native `payment_method_fee_total` wins over a legacy fee
1109
+ LINE; a legacy fee line's net is subtracted from the visible subtotal
1110
+ (v2.3.1 production fix); COD label preference `methodFeeLabel` prop
1111
+ server `payment_method_fee_label` → fee-line title → `labels.paymentMethodFee`.
1112
+ - **Settings** COD settings (`payment_method_fee_total`/`payment_method_fee_label`),
1113
+ promotions (discount row).
1114
+
1115
+ ### `<OrderAddressCard order />` `order/order-address-card`
1116
+
1117
+ - Contact info card: shipping-address name + phone + order email.
1118
+ Structural `OrderContactData` the order detail satisfies it.
1119
+
1120
+ ### `<OrderDeliveryCard order shippingMethod? currencyCode />` `order/order-delivery-card`
1121
+
1122
+ - **Purpose** pickup point (Econt office metadata / stable
1123
+ fulfillment-option ids `econt-office`, `boxnow-locker` — id beats name
1124
+ parsing, production fix) or shipping address, the method row with
1125
+ price/FREE, and Cartbase addition the fulfillment tracking labels.
1126
+ - **Data seam** `order.metadata` + `order.shipping_address` +
1127
+ `order.fulfillments` from the detail; `shippingMethod` (structural,
1128
+ `CartShippingMethod` fits) from checkout state since the Cartbase order
1129
+ read has no shipping-method embed. Tracking rows come from the pure,
1130
+ unit-tested `pickTrackingLabels(order.fulfillments)` skips canceled
1131
+ fulfillments, drops number-less labels, dedupes re-prints.
1132
+ - **Settings** carrier integrations (whether labels/urls exist),
1133
+ shipping options (ids/names).
1134
+
1135
+ ### `<OrderPaymentCard providerId cardLast4? />` — `order/order-payment-card`
1136
+
1137
+ - Payment method card; `resolvePaymentTitle(providerId, titles,
1138
+ methodName?)`the method's merchant NAME wins verbatim when present
1139
+ (the snapshot from session data); otherwise the processor id buckets
1140
+ via `lib/payment-constants` (`pp_stripe` = card) through the locale
1141
+ pack, falling back to `paymentInfoMap` then the raw id. Payment
1142
+ internals never cross the Cartbase store surface — both arrive via
1143
+ props from checkout state.
1144
+
1145
+ ### `<OrderTimeline fulfillmentStatus? />` `order/order-timeline`
1146
+
1147
+ - Placed Processing Shipped Delivered progress (client, animated).
1148
+ Cartbase has no `fulfillment_status` column on the store surface —
1149
+ derive it with the pure, unit-tested
1150
+ `deriveFulfillmentStatus(order.fulfillments)` (packed_at/shipped_at/
1151
+ delivered_at ladder, `partially_*` when only some active fulfillments
1152
+ reached a stage, canceled ignored).
1153
+
1154
+ ### `<OrderHelpSection contactHref? returnsHref? />` `order/order-help-section`
1155
+
1156
+ - "Need help?" links card (contact + returns). No SDK calls, no settings.
1157
+
1158
+ ---
1159
+
1160
+ ## Family: common (`@cartbase/storefront/common/*`)
1161
+ Shared storefront chrome, production-proven.
1162
+
1163
+ ### `<LocalizedLink href … />` — `common/localized-link`
1164
+
1165
+ - `next/link` that persists the URL locale/country segment when the route
1166
+ has one (`[countryCode]` param by default, `paramName` overridable);
1167
+ plain link on cookie-locale apps (the Cartbase default). Client.
1168
+
1169
+ ### `<CartButton client cartId? />` + `<CartButtonClient cart />` — `common/cart-button`, `common/cart-button-client`
1170
+
1171
+ - **Purpose** header cart button: badge count + opens the cart drawer.
1172
+ - **SDK calls** server wrapper: `api/carts.retrieveCart(client, cartId)`
1173
+ (the app owns the cart-id cookie); fetch failure degrades to an empty
1174
+ button. Client half takes the decorated `Cart`; badge count =
1175
+ `productItemCount(cart.items)` (fee-line-aware, consistent with the
1176
+ drawer).
1177
+ - **Mount rules** — `CartButtonClient` must sit inside the cart-drawer
1178
+ family's `<CartDrawerProvider>` (it calls `useCartDrawer().open`).
1179
+
1180
+ ### `<DeleteButton client cartId id onDeleted? />` — `common/delete-button`
1181
+
1182
+ - Cart-line remove with spinner. Calls
1183
+ `api/carts.deleteLineItem(client, cartId, id)` (idempotent) and hands
1184
+ the refreshed `{cart}` to `onDeleted`; spinner resets on failure.
1185
+
1186
+ ### `<MarketSelect regions value? onChange />` — `common/market-select`
1187
+
1188
+ - **Purpose** — the market picker for a header or footer: the store's
1189
+ REGIONS (`api/regions.listRegions`) by name, valued by region id;
1190
+ persistence is app-owned via `onChange` (usually
1191
+ `carts.updateCart(client, cartId, {region_id})` + a cookie). A country is
1192
+ a different thing: the checkout picks one from `listCountries`, with
1193
+ `CountryFlag` beside it.
1194
+ - **Was `CountrySelect`** (`common/country-select`) until 2026-09-14, a name
1195
+ the port carried for a component that never listed countries here. The
1196
+ old path and names still import for one release and then go.
1197
+ - **Settings** AdminSettings Markets Regions: which rows exist.
1198
+
1199
+ ### `<CountryFlag code title? className? />` — `common/country-flag`
1200
+
1201
+ - **Purpose** a country's flag from its ISO-3166 alpha-2 code, at the size
1202
+ of the text beside it. An unknown or empty code renders a neutral box that
1203
+ holds the same space, so a field does not jump when a shopper picks their
1204
+ country.
1205
+ - **Why a sprite and not an emoji** — Windows ships no flag glyphs, so an
1206
+ emoji flag reads as the two letters "BG" there. This draws `flag-icons`
1207
+ SVG under a CSS class, which renders the same on every platform, and it is
1208
+ the same sprite the Cartbase admin uses, so one country treatment runs
1209
+ across the admin and the storefront.
1210
+ - **Accessibility** decorative and `aria-hidden`: a flag never carries
1211
+ meaning the name beside it does not. Pass `title` when it stands alone.
1212
+
1213
+ ### `<LanguageSelect locales currentLocale onChange labels? />` — `common/language-select`
1214
+
1215
+ - **Purpose** — locale switcher. Feed `locales` from
1216
+ `api/regions.listLocales(client)` (bare codes, store default first —
1217
+ SDK wins over the source's `{code,name}` objects); display names via
1218
+ `localeDisplayName()` (`Intl.DisplayNames` autonym) with per-store
1219
+ `labels` overrides. `onChange` persists (cookie read by
1220
+ `StorefrontClient.getLocale`) inside the preserved `useTransition`
1221
+ pending-disable UX.
1222
+ - **Settings** — Settings → Store → locales (per-store `store_locales`).
1223
+
1224
+ ### `<Skeleton className? />` + `<SkeletonProductPreview />` — `common/skeleton`
1225
+
1226
+ - Loading placeholders (pure UI over theme tokens).
1227
+
1228
+ ---
1229
+
1230
+ ## Family: reviews-ui (`@cartbase/storefront/reviews-ui`)
1231
+ Verified-purchase review components, ported from a production storefront,
1232
+ over `api/reviews`. ONE barrel export seam: everything imports from
1233
+ `@cartbase/storefront/reviews-ui`. Endpoint truth:
1234
+ [reviews.md](reviews.md). Labels: the family reads the language mounted by
1235
+ `StorefrontLocaleProvider` on its own (English with none, since
1236
+ 2026-09-14), `labels?` on any component overrides keys, and `StarBadge`,
1237
+ the one server-safe piece, takes its labels as a prop. Strings are
1238
+ parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots
1239
+ (resolve via `formatLabel`).
1240
+
1241
+ ### `<ReviewWidget client productId initialData? />` — the PDP section
1242
+
1243
+ - **Purpose** — aggregate header (score badge + 5→1 distribution bars +
1244
+ sort select) + masonry card list + load-more + lightbox. Renders null
1245
+ at zero reviews (production rule); derives avg/distribution from the
1246
+ loaded page if the aggregate is missing (never a misleading "0.0").
1247
+ - **SDK calls** — bootstrap: `getWidget(client, productId)` (ONE call:
1248
+ aggregate + first page per the store's display options; edge-cached
1249
+ 60s) — server-fetch it and pass `initialData` (recommended), else the
1250
+ widget fetches on mount. Sort changes / load-more: `listReviews`
1251
+ (`sortParamsFor` maps the UI keys to the API `(sort, order)` tuple).
1252
+ - **Mount rules** — client component, PDP below the fold; `id="reviews"`
1253
+ anchor built in. Verified badge is unconditional (every review is
1254
+ token-minted — a system tautology, not a flag).
1255
+ - **Settings** — Settings → Reviews display options: `widget_layout`
1256
+ (masonry|list), `widget_page_size`, `widget_photo_first` (all arrive
1257
+ via `getWidget().options`); moderation decides visibility.
1258
+ - Pieces exported for custom layouts: `<StarRow>`, `<StarBadge>`,
1259
+ `<RatingDistribution>` (`star-badge`), `<ReviewList>` +
1260
+ `<ReviewLightbox>` (presentational cards + overlay),
1261
+ `reviewDisplayName` (first name + surname initial — shared with any
1262
+ app JSON-LD so UI and structured data can't drift, production fix),
1263
+ `formatReviewDate`.
1264
+
1265
+ ### `<ReviewWizard client token validation rewardPct? supportEmail? … />` — the token page
1266
+
1267
+ - **Purpose** — everything behind `<review_link_base>/<token>`: invalid/
1268
+ expired panels, the terminal already-submitted panel, and the two-step
1269
+ form (rate → photo → done) with the reward-code reveal.
1270
+ - **SDK calls** — `validateToken(client, token)` SERVER-SIDE in the page
1271
+ (pass the result as `validation` — never flash a form on a dead
1272
+ token; mark the route noindex), then client-side: `submitReview`
1273
+ (step 1 — consumes the token, rating locked in even if the customer
1274
+ bails), `createUploadUrl` → signed R2 PUT → `attachReviewPhoto`
1275
+ (step 2 — mints the single-use reward code; `code: null` on 200 =
1276
+ media saved, mint failed → "write to us" note, never an error).
1277
+ - **Step resolution** — the pure, unit-tested
1278
+ `resolveWizardEntry(validation)`; THE RESUME RULE: consumed token +
1279
+ review row + `reward_code` null → resume at photo; `reward_code` set →
1280
+ done showing the code; consumed with no review row → terminal panel.
1281
+ Submit errors map by status via `submitErrorKeyFor` (429/409/410).
1282
+ - **Mount rules** — `ReviewWizard` is the full page body (client);
1283
+ `ReviewWizardForm` and `<ReviewPhotoUpload>` (drag-drop, per-file slot
1284
+ caps ≤6 images/≤1 video, 8/50 MB, 60s video, blob-preview swap +
1285
+ revoke) are exported for custom pages.
1286
+ - **Settings** — Settings → Reviews: `reward_enabled` /
1287
+ `reward_percentage` (the store surface does not expose the percentage —
1288
+ pass `rewardPct`, default 10), `moderation_mode` (`hold` lands the
1289
+ review pending; the thanks copy stays true either way), request-scanner
1290
+ settings decide when tokens are minted at all.