create-cartbase 0.1.8 → 0.1.9

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