create-cartbase 0.0.1 → 0.1.0

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