create-cartbase 0.1.4 → 0.1.6

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