create-cartbase 0.1.14 → 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,1246 +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), 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-GATED — only 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 architecture — no 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" state — discover 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
- ### Pure: `products/variant-matching` (extra module, Cartbase addition)
797
-
798
- `optionsAsKeymap` / `optionsMatch` / `findMatchingVariant` the
799
- option-choice → variant resolution extracted from product-actions, reading
800
- Cartbase's option-value LINK shape (`variant.options[].value.{option_id,value}`)
801
- with a legacy flat-row fallback. Unit-tested
802
- (tests/unit/storefront-catalog.test.ts).
803
-
804
- ### `<ImageGallery images />` `products/image-gallery`
805
-
806
- - **Purpose** stacked PDP gallery (rank order as served); first three
807
- images `priority`.
808
- - **SDK calls** none (props: `product.images`). Server-safe.
809
-
810
- ### `<ProductActions product addToCart disabled? onAddToCart? openCart? />` `products/product-actions`
811
-
812
- - **Purpose** THE add-to-cart panel: option selection variant
813
- resolution, URL `v_id` sync, price, stock gate, add button, mobile bar.
814
- - **SDK calls** — none itself; the injected `addToCart({variantId,
815
- quantity})` seam is the host's cart orchestration (typically `api/carts`
816
- `addLineItem` + the host's cart-id cookie). `openCart` replaces the
817
- legacy cart-drawer context import (no hard cross-family dependency).
818
- - **Stock contract** — Cartbase's store surface exposes NO
819
- `inventory_quantity`; managed-inventory variants are optimistically in
820
- stock and the SERVER enforces at add (400 `insufficient_inventory`, which
821
- flips the button to the out-of-stock state). `!manage_inventory` and
822
- `allow_backorder` are always addable.
823
- - **Settings** — inventory (manage/backorder flags, kit components at add),
824
- price lists, promotions (server re-applies on add).
825
- - **Mount rules** client; needs `ProductLabelsProvider` for non-English.
826
- Fire the tracking trio (`trackAddToCart`/GA4/Rybbit) from `onAddToCart`.
827
-
828
- ### `<MobileActions />` `products/mobile-actions`
829
-
830
- - **Purpose** the `lg:hidden` sticky bottom bar + options bottom sheet
831
- (z-[75], above the cart drawer's z-[60]) shown when the desktop actions
832
- scroll out of view.
833
- - **SDK calls** — none; pure props from `ProductActions` (which mounts it —
834
- rarely used directly).
835
-
836
- ### `<ProductTabs product />``products/product-tabs`
837
-
838
- - **Purpose** accordion: product information (material, origin, type,
839
- weight, dimensions) + static shipping/returns copy from labels.
840
- - **SDK calls** — none. **Settings** — none (copy via labels).
841
-
842
- ### `<ProductInfo product />` — `products/product-info`
843
-
844
- - **Purpose** — collection link (`/collections/<handle>`), title,
845
- description. **SDK calls** none. Server-safe.
846
-
847
- ### `<ProductPreview product isFeatured? />` `products/product-preview`
848
-
849
- - **Purpose** THE product card (links `/products/<handle>`): thumbnail +
850
- title + cheapest price. Reused by store grids, search results, related
851
- strip; every template accepts `renderProduct` to swap it for a custom
852
- card.
853
- - **SDK calls** none; expects a `StoreProduct` fetched with pricing
854
- context for the price line. Server-safe.
855
-
856
- ### `<RelatedProducts client product labels pricingContext? limit? renderProduct? />` — `products/related-products`
857
-
858
- - **Purpose** the "You might also like" strip.
859
- - **SDK calls** — `api/search` `listRelatedProducts(product.id)` manual
860
- admin picks first, deterministic fallback fills to `limit`
861
- (`auto_filled`); anchor never appears. Renders nothing on empty/404.
862
- - **Labels** `labels: ProductLabels`, REQUIRED: a server component cannot
863
- read the provider, so pass `STORE_LOCALE.products`; `ProductTemplate`
864
- passes its own through.
865
- - **Settings** Admin Product → Related (manual picks), price lists.
866
- - **Mount rules** — async server component; render inside `<Suspense>`.
867
-
868
- ### `<ProductActionsWrapper client id pricingContext? addToCart … />` — `products/product-actions-wrapper`
869
-
870
- - **Purpose** — re-fetches the product with the LIVE pricing context (and
871
- the client's Bearer JWT group-aware B2B prices) and mounts
872
- `ProductActions`; the PDP shell stays cacheable.
873
- - **SDK calls** — `api/products` `retrieveProduct(idOrHandle,
874
- pricingContext)`; 404 renders nothing.
875
- - **Mount rules** — async server component inside `<Suspense>` (fallback:
876
- disabled `<ProductActions>`).
877
-
878
- ### `<ProductTemplate client product labels addToCart pricingContext? onAddToCart? openCart? promises? hideSpecs? sections? />` — `products/product-template`
879
-
880
- - **Purpose** the full PDP: sticky info column (`ProductInfo` +
881
- `ProductTabs`), gallery, sticky actions column (suspended
882
- `ProductActionsWrapper`), related strip.
883
- - **SDK calls**via children (retrieveProduct, listRelatedProducts). The
884
- page fetches the product by handle (`retrieveProduct`) and passes it in.
885
- - **Settings** — union of children's.
886
- - **Mount rules** — server component; `labels` is REQUIRED (the pack's
887
- `products` area) because the related strip renders on the server; the
888
- client parts inside read `ProductLabelsProvider`, which
889
- `StorefrontLocaleProvider` mounts; `addToCart`/`openCart` seams as on
890
- `ProductActions`.
891
-
892
- ## Family: store (`@cartbase/storefront/store/*`)
893
- The listing family: paginated grids, sort, collection/category/search
894
- templates. Sorting discipline: the API's `order` param is the authority for
895
- paginated listings; client-side re-sort (`lib/sort-products`) exists ONLY
896
- for the price sorts on the plain products listing (price is not a products
897
- column) over the legacy 100-item window.
898
-
899
- **Labels / i18n** — `store/labels` (`StoreLabels` + defaults + the pure
900
- `sortOptionLabelKeys` mapcompleteness unit-tested) and `locale.store`
901
- from a pack. Templates take `labels: StoreLabels`, REQUIRED: they are
902
- server components and cannot read the provider, so a page hands them
903
- `STORE_LOCALE.store` (`en.store` for an English store).
904
-
905
- ### `<Pagination page totalPages />` — `store/pagination`
906
-
907
- - **Purpose** — windowed page-number pagination; writes the `page` query
908
- param and pushes the route (server re-renders with the new offset).
909
- - **SDK calls** none. **Mount rules**client.
910
-
911
- ### `<SortSelect sortBy? labels? />` `store/sort-select`
912
-
913
- - **Purpose** sort sidebar; writes the `sortBy` query param
914
- (`created_at` | `price_asc` | `price_desc`, rendered from
915
- `sortOptionLabelKeys`).
916
- - **Props** — `sortBy` OPTIONAL (port adaptation): on collection pages no
917
- selection = the collection's admin `default_sort`, nothing highlighted.
918
- - **SDK calls** — none. **Mount rules** — client.
919
-
920
- ### `<PaginatedProducts client page sortBy? collectionId? categoryId? productsIds? pricingContext? renderProduct? />` — `store/paginated-products`
921
-
922
- - **Purpose** the 12-per-page product grid + pagination over the plain
923
- products listing.
924
- - **SDK calls** `api/products` `listProducts`: `created_at`
925
- server `order:"-created_at"` + real offset pagination; `price_asc`/
926
- `price_desc` 100-item window fetch, `lib/sort-products` re-sort, slice
927
- (the proven production approach see module JSDoc for the >100 caveat).
928
- - **Props note** `collectionId` filters by PRIMARY collection
929
- (`products.collection_id`); the membership join lives in
930
- `CollectionTemplate`.
931
- - **Settings** price lists (pricing context), sales-channel/publishable-
932
- key scope on the client.
933
- - **Mount rules** async server component; render inside `<Suspense>`
934
- with `<SkeletonProductGrid />`.
935
-
936
- ### `<SkeletonProductGrid numberOfProducts? />` — `store/skeleton-product-grid`
937
-
938
- - **Purpose** pulse skeleton for any product grid. Server-safe, no calls.
939
-
940
- ### `<StoreTemplate client labels sortBy? page? pricingContext? renderProduct? />` `store/store-template`
941
-
942
- - **Purpose** — the `/store` all-products page: sort sidebar + heading +
943
- suspended `PaginatedProducts`.
944
- - **SDK calls** via `PaginatedProducts`. Pass the page's `sortBy`/`page`
945
- query params straight in.
946
-
947
- ### `<CollectionTemplate client collection labels sortBy? page? pricingContext? renderProduct? />` — `store/collection-template`
948
-
949
- - **Purpose** — collection page over the MEMBERSHIP listing (multi-
950
- collection products appear in every collection).
951
- - **SDK calls** — `api/collections` `listCollectionProducts(collection.id)`
952
- the admin `default_sort` is honored SERVER-side when `sortBy` is unset
953
- (no client re-sort, port adaptation); a shopper override maps
954
- `created_at→newest`, `price_asc`, `price_desc` to the `order` param
955
- (price sorting server-side here, unlike the plain listing). Fetch the
956
- collection itself via `listCollections({handle})` in the page.
957
- - **Settings** — collection `default_sort` + manual order, smart-collection
958
- conditions, channel links (scoped-away collection 404s), price lists.
959
- - **Mount rules** — server component; grid suspends internally.
960
-
961
- ### `<CategoryTemplate client category labels sortBy? page? pricingContext? renderProduct? />` — `store/category-template`
962
-
963
- - **Purpose** — category page: breadcrumbs (ancestor chain), description,
964
- child-category links, `PaginatedProducts` filtered by `category_id`.
965
- - **SDK calls** — via `PaginatedProducts`. Fetch the category in the page
966
- with `retrieveCategory(id, {include_ancestors_tree: true,
967
- include_descendants_tree: true})` — without the flags the breadcrumb and
968
- children sections don't render.
969
- - **Settings** category tree (active/internal flags are server-filtered).
970
-
971
- ### `<SearchTemplate client searchParams labels basePath? pricingContext? limit? renderProduct? />` `store/search-template`
972
-
973
- - **Purpose** the search results page in the
974
- store-template idiom GET query box, facet sidebar from the response
975
- `facets[]`, result grid (reuses the product card), pagination. Fully
976
- URL-state driven: works server-rendered with zero own client JS.
977
- - **SDK calls** — `api/search` `searchProducts` (only when `q` present).
978
- Facet buckets toggle by rewriting the wire-named query params
979
- (`collection_id`/`type_id`/`tag_id` CSV, `price_min`/`price_max`,
980
- `availability`, `option.<Title>` CSV) via the pure `store/search-params`
981
- helpers (round-trip unit-tested); every toggle resets `page`.
982
- - **Settings** — Search & discovery: synonyms, pins/boosts, facet config
983
- (order/enabled, price bucket strategy `auto`/`fixed`); price facet
984
- currency follows the pricing context.
985
- - **Mount rules** — async server component; pass the route's raw
986
- `searchParams`; `basePath` defaults to `/search`.
987
-
988
- ### Pure: `store/search-params` (extra module, Cartbase addition)
989
-
990
- `parseSearchParams` / `buildSearchQueryString` / `fromQueryString` /
991
- `toggleFacetSelection` / `isFacetSelected` / `clearFilters` /
992
- `toSearchQuery` — the search page's URL-state machine, exported for custom
993
- search UIs (chips, drawers) that want the same URL contract.
994
-
995
- ---
996
-
997
- ## Family: order (`@cartbase/storefront/order/*`)
998
- Order confirmation + account order views, production-proven. Some
999
- platforms ship ONE `StoreOrder` object carrying computed line
1000
- totals, order totals, shipping methods and payments; Cartbase splits those
1001
- across surfaces, so the family takes them as separate props — the Cartbase
1002
- `StoreOrderDetail` (`api/orders`) carries items as version-pivot rows
1003
- (`{quantity, line_item}`), fulfillments with tracking labels, and both
1004
- addresses, while MONEY comes from the decorated cart (`api/carts` `Cart`)
1005
- or the order summary snapshot. Components render server truths; the only
1006
- arithmetic is the two documented production subtractions in the totals
1007
- selector.
1008
-
1009
- Labels: `OrderLabelsProvider`/`useOrderLabels` (`order/context`) +
1010
- `defaultOrderLabels` (`order/labels`, English) + `locale.order` from a
1011
- pack in `locales/*` for any other language. Every
1012
- component also takes a `labels` prop pick, and the template's is REQUIRED
1013
- (`labels: OrderLabels`): it renders on the server, where no provider
1014
- reaches, so the page passes `STORE_LOCALE.order`.
1015
-
1016
- ### `<OrderCompletedTemplate order totals labels items? shippingMethod? paymentProviderId? cardLast4? … />` — `order/order-completed-template`
1017
-
1018
- - **Purpose**the full confirmation page: hero header, fulfillment
1019
- timeline, items+totals card, contact/delivery/payment/help cards,
1020
- continue-shopping CTA.
1021
- - **SDK calls** — none itself; feed it: `order` (`retrieveOrder` /
1022
- `retrieveOrderByDisplayId` detail, or the `completeCart()` response),
1023
- `totals` (the decorated cart from checkout, or
1024
- `orderTotalsFromSummary(completeCart().order.summary)`), optional
1025
- normalized `items` (prefer `displayItemFromCartLine` right after
1026
- checkoutkeeps server-computed per-line discounts), `shippingMethod` /
1027
- `paymentProviderId` / `cardLast4` from checkout state.
1028
- - **Mount rules** order-confirmation route (server component OK; only
1029
- the timeline child is client). The Purchase tracking trio (fbq/gtag/
1030
- rybbit, deduped by `order.display_id`) is APP-OWNED: fire it from the
1031
- confirmation route exactly once per order per the tracking family's
1032
- dedupe contract the template deliberately does NOT fire it.
1033
- - **Settings** — COD settings (fee row presence + `payment_method_fee_label`),
1034
- checkout rules (which provider ids appear), store locales (labels pack).
1035
-
1036
- ### `<OrderConfirmationHeader order />` `order/order-confirmation-header`
1037
-
1038
- - **Purpose** — check hero + "Order #display_id" + localized date chips.
1039
- - **Props** — structural `OrderHeaderData` (`display_id`, `email`,
1040
- `created_at`) — order detail and `completeCart().order` both satisfy it.
1041
- `locale` drives `toLocaleDateString`.
1042
-
1043
- ### `<OrderItemsList items currencyCode />` + `<OrderItem item currencyCode />` — `order/order-items-list`, `order/order-item`
1044
-
1045
- - **Purpose** the purchased lines (thumb, title, variant caption, qty,
1046
- line total with discount strike-through when the source had it).
1047
- - **Data seam** normalized `OrderDisplayItem[]` via the pure converters
1048
- `displayItemFromOrderItem(pivot)` (order path: `unit_price × quantity`)
1049
- / `displayItemFromCartLine(line)` (cart path: server `total` /
1050
- `original_total`). Legacy COD-fee line items
1051
- (`metadata.is_cod_fee=true`) are hidden here and surfaced in
1052
- `OrderTotals` instead (`lib/cart-helpers.isProductLine`); Cartbase-native
1053
- COD fees are never line items, so on pure Cartbase data the filter is a
1054
- no-op safety net. Newest-first sort by `createdAt` when present.
1055
-
1056
- ### `<OrderTotals totals currencyCode items? methodFeeLabel? />` `order/order-totals`
1057
-
1058
- - **Purpose** the money breakdown: Subtotal / Shipping (FREE badge at
1059
- 0) / COD fee / Discount (negated) / Tax / Total, all via `Price`.
1060
- - **Data seam** `OrderTotalsSource` (the decorated cart satisfies it:
1061
- `item_subtotal`, `shipping_subtotal`, `discount_total`, `tax_total`,
1062
- `total`, `payment_method_fee_total`, `payment_method_fee_label`); the summary snapshot adapts
1063
- via `orderTotalsFromSummary`. Row policy is the pure, unit-tested
1064
- `selectOrderTotalsRows`: native `payment_method_fee_total` wins over a legacy fee
1065
- LINE; a legacy fee line's net is subtracted from the visible subtotal
1066
- (v2.3.1 production fix); COD label preference `methodFeeLabel` prop →
1067
- server `payment_method_fee_label` fee-line title `labels.paymentMethodFee`.
1068
- - **Settings** — COD settings (`payment_method_fee_total`/`payment_method_fee_label`),
1069
- promotions (discount row).
1070
-
1071
- ### `<OrderAddressCard order />` `order/order-address-card`
1072
-
1073
- - Contact info card: shipping-address name + phone + order email.
1074
- Structural `OrderContactData` the order detail satisfies it.
1075
-
1076
- ### `<OrderDeliveryCard order shippingMethod? currencyCode />` `order/order-delivery-card`
1077
-
1078
- - **Purpose** pickup point (Econt office metadata / stable
1079
- fulfillment-option ids `econt-office`, `boxnow-locker` — id beats name
1080
- parsing, production fix) or shipping address, the method row with
1081
- price/FREE, and — Cartbase addition — the fulfillment tracking labels.
1082
- - **Data seam** — `order.metadata` + `order.shipping_address` +
1083
- `order.fulfillments` from the detail; `shippingMethod` (structural,
1084
- `CartShippingMethod` fits) from checkout state since the Cartbase order
1085
- read has no shipping-method embed. Tracking rows come from the pure,
1086
- unit-tested `pickTrackingLabels(order.fulfillments)` — skips canceled
1087
- fulfillments, drops number-less labels, dedupes re-prints.
1088
- - **Settings** — carrier integrations (whether labels/urls exist),
1089
- shipping options (ids/names).
1090
-
1091
- ### `<OrderPaymentCard providerId cardLast4? />` `order/order-payment-card`
1092
-
1093
- - Payment method card; `resolvePaymentTitle(providerId, titles,
1094
- methodName?)` the method's merchant NAME wins verbatim when present
1095
- (the snapshot from session data); otherwise the processor id buckets
1096
- via `lib/payment-constants` (`pp_stripe` = card) through the locale
1097
- pack, falling back to `paymentInfoMap` then the raw id. Payment
1098
- internals never cross the Cartbase store surface both arrive via
1099
- props from checkout state.
1100
-
1101
- ### `<OrderTimeline fulfillmentStatus? />` — `order/order-timeline`
1102
-
1103
- - Placed Processing Shipped Delivered progress (client, animated).
1104
- Cartbase has no `fulfillment_status` column on the store surface —
1105
- derive it with the pure, unit-tested
1106
- `deriveFulfillmentStatus(order.fulfillments)` (packed_at/shipped_at/
1107
- delivered_at ladder, `partially_*` when only some active fulfillments
1108
- reached a stage, canceled ignored).
1109
-
1110
- ### `<OrderHelpSection contactHref? returnsHref? />` `order/order-help-section`
1111
-
1112
- - "Need help?" links card (contact + returns). No SDK calls, no settings.
1113
-
1114
- ---
1115
-
1116
- ## Family: common (`@cartbase/storefront/common/*`)
1117
- Shared storefront chrome, production-proven.
1118
-
1119
- ### `<LocalizedLink href … />` — `common/localized-link`
1120
-
1121
- - `next/link` that persists the URL locale/country segment when the route
1122
- has one (`[countryCode]` param by default, `paramName` overridable);
1123
- plain link on cookie-locale apps (the Cartbase default). Client.
1124
-
1125
- ### `<CartButton client cartId? />` + `<CartButtonClient cart />` — `common/cart-button`, `common/cart-button-client`
1126
-
1127
- - **Purpose** header cart button: badge count + opens the cart drawer.
1128
- - **SDK calls** server wrapper: `api/carts.retrieveCart(client, cartId)`
1129
- (the app owns the cart-id cookie); fetch failure degrades to an empty
1130
- button. Client half takes the decorated `Cart`; badge count =
1131
- `productItemCount(cart.items)` (fee-line-aware, consistent with the
1132
- drawer).
1133
- - **Mount rules** — `CartButtonClient` must sit inside the cart-drawer
1134
- family's `<CartDrawerProvider>` (it calls `useCartDrawer().open`).
1135
-
1136
- ### `<DeleteButton client cartId id onDeleted? />` — `common/delete-button`
1137
-
1138
- - Cart-line remove with spinner. Calls
1139
- `api/carts.deleteLineItem(client, cartId, id)` (idempotent) and hands
1140
- the refreshed `{cart}` to `onDeleted`; spinner resets on failure.
1141
-
1142
- ### `<MarketSelect regions value? onChange />``common/market-select`
1143
-
1144
- - **Purpose** — the market picker for a header or footer: the store's
1145
- REGIONS (`api/regions.listRegions`) by name, valued by region id;
1146
- persistence is app-owned via `onChange` (usually
1147
- `carts.updateCart(client, cartId, {region_id})` + a cookie). A country is
1148
- a different thing: the checkout picks one from `listCountries`, with
1149
- `CountryFlag` beside it.
1150
- - **Was `CountrySelect`** (`common/country-select`) until 2026-09-14, a name
1151
- the port carried for a component that never listed countries here. The
1152
- old path and names still import for one release and then go.
1153
- - **Settings** — Admin → Settings → Markets → Regions: which rows exist.
1154
-
1155
- ### `<CountryFlag code title? className? />` — `common/country-flag`
1156
-
1157
- - **Purpose** — a country's flag from its ISO-3166 alpha-2 code, at the size
1158
- of the text beside it. An unknown or empty code renders a neutral box that
1159
- holds the same space, so a field does not jump when a shopper picks their
1160
- country.
1161
- - **Why a sprite and not an emoji** — Windows ships no flag glyphs, so an
1162
- emoji flag reads as the two letters "BG" there. This draws `flag-icons`
1163
- SVG under a CSS class, which renders the same on every platform, and it is
1164
- the same sprite the Cartbase admin uses, so one country treatment runs
1165
- across the admin and the storefront.
1166
- - **Accessibility** decorative and `aria-hidden`: a flag never carries
1167
- meaning the name beside it does not. Pass `title` when it stands alone.
1168
-
1169
- ### `<LanguageSelect locales currentLocale onChange labels? />` — `common/language-select`
1170
-
1171
- - **Purpose** — locale switcher. Feed `locales` from
1172
- `api/regions.listLocales(client)` (bare codes, store default first —
1173
- SDK wins over the source's `{code,name}` objects); display names via
1174
- `localeDisplayName()` (`Intl.DisplayNames` autonym) with per-store
1175
- `labels` overrides. `onChange` persists (cookie read by
1176
- `StorefrontClient.getLocale`) inside the preserved `useTransition`
1177
- pending-disable UX.
1178
- - **Settings** Settings → Store → locales (per-store `store_locales`).
1179
-
1180
- ### `<Skeleton className? />` + `<SkeletonProductPreview />` — `common/skeleton`
1181
-
1182
- - Loading placeholders (pure UI over theme tokens).
1183
-
1184
- ---
1185
-
1186
- ## Family: reviews-ui (`@cartbase/storefront/reviews-ui`)
1187
- Verified-purchase review components, ported from a production storefront,
1188
- over `api/reviews`. ONE barrel export seam: everything imports from
1189
- `@cartbase/storefront/reviews-ui`. Endpoint truth:
1190
- [reviews.md](reviews.md). Labels: the family reads the language mounted by
1191
- `StorefrontLocaleProvider` on its own (English with none, since
1192
- 2026-09-14), `labels?` on any component overrides keys, and `StarBadge`,
1193
- the one server-safe piece, takes its labels as a prop. Strings are
1194
- parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots
1195
- (resolve via `formatLabel`).
1196
-
1197
- ### `<ReviewWidget client productId initialData? />` the PDP section
1198
-
1199
- - **Purpose** aggregate header (score badge + 5→1 distribution bars +
1200
- sort select) + masonry card list + load-more + lightbox. Renders null
1201
- at zero reviews (production rule); derives avg/distribution from the
1202
- loaded page if the aggregate is missing (never a misleading "0.0").
1203
- - **SDK calls** bootstrap: `getWidget(client, productId)` (ONE call:
1204
- aggregate + first page per the store's display options; edge-cached
1205
- 60s) server-fetch it and pass `initialData` (recommended), else the
1206
- widget fetches on mount. Sort changes / load-more: `listReviews`
1207
- (`sortParamsFor` maps the UI keys to the API `(sort, order)` tuple).
1208
- - **Mount rules** client component, PDP below the fold; `id="reviews"`
1209
- anchor built in. Verified badge is unconditional (every review is
1210
- token-minteda system tautology, not a flag).
1211
- - **Settings** Settings Reviews display options: `widget_layout`
1212
- (masonry|list), `widget_page_size`, `widget_photo_first` (all arrive
1213
- via `getWidget().options`); moderation decides visibility.
1214
- - Pieces exported for custom layouts: `<StarRow>`, `<StarBadge>`,
1215
- `<RatingDistribution>` (`star-badge`), `<ReviewList>` +
1216
- `<ReviewLightbox>` (presentational cards + overlay),
1217
- `reviewDisplayName` (first name + surname initial shared with any
1218
- app JSON-LD so UI and structured data can't drift, production fix),
1219
- `formatReviewDate`.
1220
-
1221
- ### `<ReviewWizard client token validation rewardPct? supportEmail? … />` — the token page
1222
-
1223
- - **Purpose** — everything behind `<review_link_base>/<token>`: invalid/
1224
- expired panels, the terminal already-submitted panel, and the two-step
1225
- form (rate → photo → done) with the reward-code reveal.
1226
- - **SDK calls** — `validateToken(client, token)` SERVER-SIDE in the page
1227
- (pass the result as `validation` — never flash a form on a dead
1228
- token; mark the route noindex), then client-side: `submitReview`
1229
- (step 1 — consumes the token, rating locked in even if the customer
1230
- bails), `createUploadUrl` signed R2 PUT → `attachReviewPhoto`
1231
- (step 2 — mints the single-use reward code; `code: null` on 200 =
1232
- media saved, mint failed "write to us" note, never an error).
1233
- - **Step resolution** — the pure, unit-tested
1234
- `resolveWizardEntry(validation)`; THE RESUME RULE: consumed token +
1235
- review row + `reward_code` null resume at photo; `reward_code` set →
1236
- done showing the code; consumed with no review row → terminal panel.
1237
- Submit errors map by status via `submitErrorKeyFor` (429/409/410).
1238
- - **Mount rules** — `ReviewWizard` is the full page body (client);
1239
- `ReviewWizardForm` and `<ReviewPhotoUpload>` (drag-drop, per-file slot
1240
- caps ≤6 images/≤1 video, 8/50 MB, 60s video, blob-preview swap +
1241
- revoke) are exported for custom pages.
1242
- - **Settings** — Settings → Reviews: `reward_enabled` /
1243
- `reward_percentage` (the store surface does not expose the percentage
1244
- pass `rewardPct`, default 10), `moderation_mode` (`hold` lands the
1245
- review pending; the thanks copy stays true either way), request-scanner
1246
- 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-GATED — only 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 architecture — no 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" state — discover 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` mapcompleteness 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 idiomGET 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** Admin Settings 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.