# Components — `@cartbase/storefront` UI families

The component layer of `@cartbase/storefront`: what each family ships, the SDK
calls it requires, the admin settings that change its behavior, and its mount
rules. Every family is production-proven — ported from live commerce
storefronts and rewired to Cartbase data seams.

This page has no executable curls — the endpoints these components consume
are executably documented in their own domain files
([integrations.md](integrations.md), [consent.md](consent.md), etc.); this
page is the component-side contract that binds to them.

Import discipline: always import from the subpath
(`@cartbase/storefront/tracking/meta-pixel`, `@cartbase/storefront/lib/money`) —
tree-shaking and Next.js RSC boundary detection both work better than via
barrels. Styling resolves against STOREFRONT theme tokens (`bg-card`,
`text-foreground`, …, shadcn-standard names) — mount the Tailwind preset
(`@cartbase/storefront/tailwind-preset`) and define the token variables in the
app's CSS; no component hardcodes a color.

---

## Family: tracking (`@cartbase/storefront/tracking/*`) — SHIPPED

Meta Pixel + GA4 + Rybbit + Consent Mode v2. The Cartbase split of duties:
**this package fires client events and writes attribution; server-side CAPI /
GA4 Measurement Protocol sending is Cartbase-backend-owned** (the
`order.placed` forwarder). The whole family is safe to render
unconditionally — every component renders nothing when its id prop is absent,
every helper no-ops outside the browser.

### `<ConsentInit />` — `tracking/consent-init`

- **Purpose** — the synchronous Consent Mode v2 "default" snippet: reads the
  `_1c_consent` cookie and sets gtag's consent default (denied when no stored
  choice) before any Google tag loads.
- **SDK calls** — none. It must NEVER wait on a fetch: the per-store config
  drives the banner, not this default (async default = first-hit consent
  race).
- **Mount rules** — FIRST child of `<body>` in the root layout, before any
  tag component. Plain inline `<script>` by design (not next/script).
- **Settings** — none (static; the shared cookie name is baked in).

### `<ConsentBanner copy layout privacyHref rejectOnFirstLayer />` — `tracking/consent-banner`

- **Purpose** — the built-in two-layer CMP UI; writes the `_1c_consent`
  cookie and applies the live `gtag('consent','update')` + `fbq('consent')`.
- **SDK calls** — `GET /api/store/consent` (via `@cartbase/storefront/api/consent`)
  for the store's config; pass `copy = pickConsentCopy(consent.copy, locale)`,
  plus `layout` / `privacy_href` / `reject_on_first_layer` from the payload.
- **Mount rules** — mount ONLY when `shouldRenderBanner(consent)` (i.e.
  `enabled && mode === "builtin"`). In `mode: "external"` render nothing —
  the merchant's CMP must write the same `_1c_consent` cookie or call
  `setConsent()`; all gating keeps working off that one seam. z-index is
  `z-[70]` (above the cart drawer's `z-[60]`).
- **Settings** — the consent card config (admin → Settings → Consent):
  `enabled`, `mode`, `layout` (`modal` blocks + scroll-locks;
  `banner-bottom` is non-blocking and must not trap focus), `privacy_href`,
  `reject_on_first_layer`, per-locale `copy`.
- Also ships `<ConsentSettingsLink>` (footer link that re-opens the settings
  layer — "withdraw as easily as given") and the pure `<ConsentBannerCard>`.

### `<MetaPixel pixelId />` — `tracking/meta-pixel`

- **Purpose** — injects fbevents.js, pushes the consent state from
  `_1c_consent` BEFORE `fbq('init')` (pre-init revoke = Pixel queues events
  until grant), fires the initial PageView.
- **SDK calls** — `getTrackingConfig(client)` (`tracking/get-tracking-config`,
  wraps `GET /api/store/integrations` → `tracking.facebookPixel.pixelId`).
- **Mount rules** — root layout, after `<ConsentInit>`. Renders nothing when
  `pixelId` is falsy. When `tracking.consent_required` is true the consent
  gate above is mandatory (it is wired in by construction — the snippet
  always reads the cookie).
- **Settings** — admin Integrations hub `facebook_capi` row (`enabled` +
  `credentials.pixel_id`); consent card `enabled` → `consent_required`.
- Companion: `updatePixelAdvancedMatching(visitor)` — call from checkout /
  signup as PII becomes known; hashes em/ph/fn/ln/ct/st/zp/country
  (SHA-256, Meta normalization) and re-inits the Pixel so every subsequent
  event carries Advanced Matching. Also persists raw values for
  `getKnownVisitor()`.

### `<GA4 measurementId />` — `tracking/ga4`

- **Purpose** — loads gtag.js + `gtag('config')`. gtag then owns the `_ga` /
  `_ga_<MEASUREMENT_ID>` cookies that `getTrackingAttribution()` later reads.
- **SDK calls** — `getTrackingConfig(client)` → `tracking.ga4.measurementId`.
- **Mount rules** — root layout, after `<ConsentInit>` (the consent default
  governs whether gtag writes cookies or sends cookieless pings). Renders
  nothing when falsy. SPA route changes are NOT auto-tracked — fire
  `page_view` from a route-change effect if per-route views are wanted.
- **Settings** — Integrations hub `ga4` row (`credentials.measurement_id`);
  consent card.

### `<Rybbit siteId baseUrl? />` — `tracking/rybbit`

- **Purpose** — the platform's self-hosted, cookieless analytics tracker;
  auto-tracks pageviews incl. SPA route changes.
- **SDK calls** — NONE. Rybbit is deliberately absent from the store
  `tracking` block: platform-provisioned, the host app passes props from
  platform env. Outside the consent system by design (cookieless).
- **Mount rules** — root layout; renders nothing when `siteId` falsy.
- **Settings** — none merchant-facing.

### Client event helpers — `tracking/fbq`, `tracking/gtag`, `tracking/rybbit-events`

Call on the matching funnel step; all are no-op-safe (SSR, blocked, absent
tag). The Rybbit helpers additionally queue-buffer through the script-load
race (10s cap, v2.2.6 production fix).

| Step | Meta (`fbq.ts`) | GA4 (`gtag.ts`) | Rybbit (`rybbit-events.ts`) |
|---|---|---|---|
| Product view | `trackViewContent` | `trackGAViewItem` | `trackRybbitViewItem` |
| Add to cart | `trackAddToCart` | `trackGAAddToCart` | `trackRybbitAddToCart` |
| Checkout start | `trackInitiateCheckout` | `trackGABeginCheckout` | `trackRybbitBeginCheckout` |
| Order confirmed | `trackPurchase(data, order.display_id)` | `trackGAPurchase({transaction_id: String(order.display_id), …})` | `trackRybbitPurchase` |
| Newsletter/popup signup | `trackLead` | — | — |

**THE Purchase dedupe contract** (must never drift; mirrored server-side in
`src/lib/tracking/constants.ts`):

- Meta: browser Pixel `eventID` = **`"purchase_" + order.display_id`** — the
  exact id the backend `order.placed` CAPI Purchase uses. `trackPurchase()`
  builds it from the caller's `display_id` so the format cannot drift. Meta
  dedupes by event_name + event_id (~3 days).
- GA4: `transaction_id = String(order.display_id)` on both browser event and
  backend Measurement Protocol — GA4 dedupes by transaction_id natively.
- All other events get `<eventname>_<unixSeconds>_<6hex>` ids
  (`generateEventId`).

`setEnhancedConversions(input)` (gtag.ts) — Google Ads Enhanced Conversions
for Web: hashes email/phone/names client-side (same normalization as the
Meta side) and `gtag('set','user_data',…)`. Call alongside
`updatePixelAdvancedMatching` at checkout.

### Attribution — `tracking/attribution`, `tracking/get-tracking-attribution`, `tracking/use-engagement-time`

- Browser side (call from a top-level client component, e.g. `TrackInit`):
  `captureUtmsFromUrl()` (first-touch `_1c_utm_first` 365d, last-touch
  `_1c_utm_last` 90d), `getOrCreateFbp()` / `getOrCreateFbc()` (defensive
  `_fbp`/`_fbc` writes so the Pixel-load race never ships empty match keys —
  production fix), `getOrCreateAnonId()` (`_1c_anon`, the guest
  `external_id`), `initEngagementTime()`.
- Server side, at checkout completion:
  `getTrackingAttribution(clientHints?, opts?)` reads the cookies + headers
  and returns the `TrackingAttribution` keys to write into `cart.metadata`
  (CONSENT-GATED — only when the visitor's choice allows). Cart completion
  copies them to `order.metadata`; the backend forwarder inherits
  fbp/fbc/anon-id/ga signals from there. Pass
  `{ engagementTimeMsec: getEngagementTimeMsec() }` as clientHints, and
  either `opts.ga4MeasurementId` or `opts.client` so the `_ga_<id>` session
  cookie can be located.
- **Settings** — Integrations hub rows (which signals exist), consent card
  (whether capture happens).

---

## Family: primitives (`@cartbase/storefront/primitives/*`) — SHIPPED

Generic shadcn-style building blocks. No SDK calls, no admin settings —
pure UI over the theme tokens.

- `primitives/field` — `<Field label … />`: floating-label input (checkout
  house style) with the `pulse` attention cue (soft blue 2-cycle pulse,
  distinct from focus/error — production UX fix).
- `primitives/select-field` — `<SelectField label>…options…</SelectField>`:
  floating-label native select (native mobile keyboards).
- `primitives/ui/*` — shadcn-standard `button` (cva variants incl.
  `default/destructive/outline/secondary/ghost/link`), `input`, `label`,
  `select`, `dialog`, `sheet`, `tabs`, `accordion`, `collapsible`,
  `popover`. Radix-backed, ported verbatim; import per file.
- Mount rules: all are `"use client"`. Requires the Tailwind preset tokens.

---

## Family: lib (`@cartbase/storefront/lib/*`) — SHIPPED

Pure helpers — no React except `dual-price`, no fetches. The SDK never
invents server truths: prices/totals arrive computed from the API
(`variant.calculated_price`, `cart.total`); these helpers only select and
format.

- `lib/utils` — `cn()` (clsx + tailwind-merge).
- `lib/money` — `convertToLocale({amount, currency_code, …})` Intl currency
  formatting (amounts are decimal EUR major units per the store contract);
  `noDivisionCurrencies`.
- `lib/dual-price` — `<DualPrice amount currencyCode />`: EUR price with the
  statutory BGN dual display (1 EUR = 1.95583 лв., Bulgarian dual-display
  law through 2026-08); non-EUR currencies render single. Also exports
  `EUR_TO_BGN_RATE`.
- `lib/cart-helpers` — `isProductLine` / `isFeeLine` / `productTotal` /
  `productItemCount` / `findFeeLine` + `COD_FEE_METADATA_KEY`. THE single
  source of truth for hiding the backend-injected COD-fee line in cart
  surfaces while checkout/order totals show it as its own row. Affected by:
  COD settings (whether a fee line ever appears).
- `lib/get-product-price` — `getProductPrice({product, variantId})` /
  `getPricesForVariant` → formatted `VariantPrice` (cheapest + selected)
  from server-computed `calculated_price`. Reads Cartbase's flat
  `price_list_type` with the Medusa nested shape as fallback. Affected by:
  price lists / B2B pricing context (what `calculated_price` contains),
  currency + region query context.
- `lib/get-percentage-diff` — sale badge math.
- `lib/product` — `isSimpleProduct` (skip the option selector for
  one-option-one-value products).
- `lib/sort-products` — client-side re-sort of a fetched page
  (`price_asc|price_desc|created_at`); the API's `order` param stays the
  authority for paginated listings.
- `lib/payment-constants` — `isStripeLike` / `isPaypal` / `isManual` +
  `paymentInfoMap`. Translated to Cartbase ids: matches `pp_stripe` exactly
  (code truth `src/lib/stripe/providers.ts`) plus the Medusa-era prefixes;
  `pp_system_default` = COD. Affected by: enabled payment providers +
  checkout rules (which ids ever reach the client).
- `lib/store-api-error` — `storeApiError(err)`: display-boundary normalizer
  (capitalized message + terminal period), successor of `medusaError`.
  Catch `StoreApiError` directly instead when branching on `status`/`code`.
- `lib/hooks/use-intersection`, `lib/hooks/use-toggle-state` — viewport +
  toggle micro-hooks (client).

---

## Family: checkout (`@cartbase/storefront/checkout/*`) — SHIPPED

The full checkout page family, production-proven (deferred-
intent architecture — no payment session exists until Buy click) onto the
Cartbase orchestration endpoints. The flow every component serves
(executable ground truth: [checkout.md](checkout.md)):

```
listShippingOptions(cart_id) + listPaymentProviders(cart_id)  (render pickers)
   → Buy click → prepareCheckout (ONE atomic, compensated call)
   → pp_stripe: stripe.confirmPayment(client_secret) | pp_cod/pp_manual: skip
   → completeCart → navigate to the confirmed page
```

Amounts are EUR major units and SERVER truth — components render totals,
never compute money; the only client-side arithmetic is the *optimistic
display* overlay (shipping/COD-fee prediction) that the server value
replaces at prepare. All user-facing copy flows from `CheckoutProvider`
labels (EN default `labels`, full BG pack `labels-bg`); errors surface
code-first through the error-copy maps, never raw API strings.

### `<CheckoutProvider labels orderConfirmedPath />` — `checkout/context`

- **Purpose** — supplies labels + the order-confirmed path template
  (`{id}`/`{country}` substitution) to every checkout primitive.
- **SDK calls** — none.
- **Mount rules** — wrap the checkout page (or any custom composition).
  Default path template `"/{country}/order/{id}/confirmed"`; single-country
  stores pass `"/order/{id}/confirmed"`.
- **Settings** — none (labels are store-supplied).

### `useCheckoutOrchestration(options)` — `checkout/use-checkout-orchestration`

- **Purpose** — THE hardened checkout state machine: address form +
  debounced autosave, shipping/payment selection (client-state-only
  pre-Buy), carrier metadata, optimistic totals, the atomic Buy click,
  3DS-return handling, completed-cart detection. Every production race
  guard from production is preserved.
- **SDK calls** — `carts.updateCart` (address autosave + tracking metadata),
  `customers.updateMe` (best-effort profile sync incl. Cartbase's first-class
  `company_name`/`company_eik`), `checkout.calculateShippingOption`
  (calculated-rate forward-compat), `checkout.prepareCheckout`,
  `checkout.syncPaymentAmount` (exposed + fired on payment-tab switch with
  the new `provider_id`), `checkout.refreshPaymentIfTerminal` (exposed —
  call REACTIVELY from Elements `loaderror` only), `carts.completeCart`.
- **Props contract** — `{client, cart, customer, availableShippingMethods,
  availablePaymentMethods, countryCode?, countries?, paymentMethodFilter?,
  codConfig?, orderConfirmedPath?, onOrderPlaced?,
  resolveTrackingMetadata?, logError?}`. `countries` is caller-supplied
  (Cartbase regions embed NO countries array). `codConfig` = the
  integrations `cod` block — the COD fee prediction is never hardcoded.
  `logError` replaces the legacy log writers (all production log
  points preserved). Returns the full orchestration surface
  (`performBuyClick`, `optimisticTotal(Cents)`, `deliveryReady`, …).
- **Cartbase specifics** — zero-remainder gift path: `prepareCheckout`
  returning `client_secret:null` + `provider_id:null` SKIPS Stripe and
  completes on the gift session ([gift-cards.md](gift-cards.md)). Provider
  ids are `pp_stripe`/`pp_cod`/`pp_manual` exactly; `pp_manual` doubles as
  the offline tab when no true COD provider exists (fee predicted only for
  `pp_cod`). COD fee reads the cart-level `cod_fee_total` decoration.
- **Settings** — checkout rules (filter the listings + complete guard),
  COD integration (fee), Stripe credentials, gift cards, `accounts_mode`.

### `<CheckoutClient />` — `checkout/checkout-client`

- **Purpose** — the assembled single-page checkout layout over the hook +
  every component below. Stores wanting a custom layout compose the same
  hook + primitives instead.
- **SDK calls** — everything the hook + summary widgets call; the host
  fetches `listShippingOptions`/`listPaymentProviders` (+ customer, +
  `getIntegrationsConfig().cod`) and passes them down.
- **Props contract** — hook options + `showGiftCards?`,
  `logoByFulfillmentOptionId?`, Stripe `appearance`/`fonts`, `onCartChange?`
  (receives every decorated cart from summary mutations; the layout also
  re-runs `syncPaymentAmount` on those).
- **Mount rules** — `"use client"`; inside `CheckoutProvider`; redirect
  server-side when `cart.completed_at` is set (the hook also flags
  `cartIsCompleted`).

### `<PaymentWrapper cart amount />` + Stripe scope — `checkout/payment-wrapper`, `checkout/stripe-wrapper`

- **Purpose** — deferred-intent Stripe context: `PaymentWrapper` publishes
  `{stripePromise, amount, currency, appearance, fonts}`;
  `StripeElementsScope` mounts `<Elements mode:"payment">` where needed
  (`passthrough` renders children scope-less on COD-only stores);
  `StripeContext` boolean = "Stripe.js ready".
- **SDK calls** — none (env: `NEXT_PUBLIC_STRIPE_KEY`, Medusa-era names
  kept as fallbacks).
- **Mount rules** — `PaymentWrapper` wraps the page ONCE with
  `amount={optimisticTotalCents}` (cents at this Stripe boundary only);
  `StripeElementsScope` lives INSIDE the payment section so a session
  rotation never tears down the form/tracking tree.
- **Settings** — Stripe integration (whether `pp_stripe` is ever listed).

### `<CheckoutAddressForm />` (+ `<AddressSelect />`, `<CompanyDetails />`) — `checkout/address-form`, `checkout/address-select`, `checkout/company-details`

- **Purpose** — email + delivery address (floating-label `Field`s, 3s-idle
  pulse cue), saved-address picker, collapsible BG company-invoice fields
  (name/VAT/MOL/address → `cart.metadata` + customer profile).
- **SDK calls** — none directly; the hook autosaves via `updateCart` /
  `updateMe`. Saved addresses come from `customers.getMe()` →
  `addressesInRegion`.
- **Props contract** — everything from the hook (`formData`,
  `handleFormChange`, `handleFieldBlur`, `regionCountries`, `addressInput`,
  `addressError`, `pulseFields`); `hideCountry?` for single-country stores
  (single-entry lists render a readonly localized country field).
- **Settings** — regions/countries (via the caller-supplied list).

### `<CheckoutShippingMethodList />` — `checkout/shipping-method-list`

- **Purpose** — radio list of shipping options with inline carrier-picker
  expansion, free-shipping label, optional per-carrier logos, optional
  read-only price preview pre-address (`previewWhenAddressNotReady`).
- **SDK calls** — renders `checkout.listShippingOptions(client, {cart_id})`
  rows AS SERVED (rule-filtering + `checkout_method_order` are server-side).
- **Props contract** — hook state + `econt?`/`boxnow?` picker configs
  (detection by the STABLE `shipping_option.data.id` — `"econt-office"` /
  `"boxnow-locker"` — never display names; `boxnow.client` carries the SDK
  transport) + `logoByFulfillmentOptionId?`.
- **Settings** — checkout rules (`target_type=shipping_option`), method
  ordering, carrier integrations (which options exist at all).

### `<EcontOfficeSelector />` / `<BoxNowLockerSelector />` — `checkout/econt-office-selector`, `checkout/boxnow-locker-selector`

- **Purpose** — Bulgarian office/locker pickers: nearest-3 by haversine
  distance (Nominatim geocode of the typed address), city-locked search
  with Cyrillic↔Latin normalization, selected pill + change.
- **SDK calls** — Econt: none (public Econt Nomenclatures endpoint,
  page-level cache). BoxNow: `integrations.listBoxNowLockers(client)`
  ([integrations.md](integrations.md)); 503/502/network all render one
  "temporarily unavailable" state — discover availability via
  `carriers.boxnow.lockers_url`, don't probe.
- **Props contract** — `{userCity, userAddress, selectedOffice|Locker,
  onSelect}` (+ `client` for BoxNow). The CHOSEN office/locker is client
  state; the hook writes it into `carrier_metadata` exactly once at
  prepare (previous carrier keys are server-side swept —
  `_prepared_carrier_keys`).
- **Settings** — the carrier integrations (enabled/lockers).

### `<CheckoutPaymentMethodList />` + `<PaymentButton />` — `checkout/payment-method-list`, `checkout/payment-button`

- **Purpose** — pay-online vs cash-on-delivery radio rail. The online tab
  hosts Stripe `<PaymentElement layout:"accordion">` (every
  Dashboard-enabled method, no per-method code; deliberately NO
  `fields.billingDetails.address` override — the strict-completeness
  IntegrationError fix). `PaymentButton` = the Buy button: re-entry-guarded
  click → `performBuyClick`, cycling processing narration, translated
  inline errors, DualPrice total.
- **SDK calls** — renders `checkout.listPaymentProviders` results via the
  hook's `hasCard`/`hasCod`; the click path runs the hook's calls.
- **Props contract** — hook state + `buyButtonNotReady(Reason?)`,
  `gatePaymentUntilDelivery?` (false = always-visible payment section),
  `beforePaymentButton?` slot, `total` (pass `optimisticTotal`),
  `logError?`.
- **Settings** — COD integration (`labels.codNote` + fee timing), Stripe
  credentials, checkout rules (`target_type=payment_method`) — hidden
  methods are also re-enforced at complete (`checkout_method_hidden`).

### `<OrderSummary />` (+ `CheckoutLineItem`, `<LineItemCard />`) — `checkout/order-summary`, `checkout/line-item-card`

- **Purpose** — items (flat rows with qty pill), promo + gift-card
  widgets, totals breakdown (subtotal / shipping / COD fee / discount /
  VAT / total + gift-card tender rows UNDER the unchanged total), secure
  badge. `LineItemCard` is the standalone card variant.
- **SDK calls** — `carts.updateLineItem` (quantity); child widgets below.
  Totals are rendered STRAIGHT from the cart decoration: `item_total`,
  `shipping_total`, `cod_fee_total`/`cod_fee_label`, `discount_total`,
  `tax_total`, `total`, `gift_card_total`, `gift_card_remainder`.
- **Props contract** — `{client, cart, optimisticShippingCost,
  onOptimisticShippingClear?, optimisticCodFee?, onOptimisticCodFeeClear?,
  codFeeLabel?, showGiftCards?, onCartChange?}`. Optimistic values clear
  automatically once the server cart catches up.
- **Settings** — COD integration (fee row), gift cards, promotions.

### `<DiscountSection />` — `checkout/discount-section`

- **Purpose** — collapsible promo-code input + applied-promotion list
  (percentage or fixed amount display).
- **SDK calls** — `POST /api/store/carts/:id/promotions {promo_codes}`
  via the client transport (additive apply; the carts SDK module ships no
  wrapper for this route yet — see [carts.md](carts.md) for the cart
  shape; `cart.promotions` arrives as the `{promotion:{…}}` pivot embed,
  unwrapped here).
- **Props contract** — `{client, cart, onCartChange?}`. Errors code-first
  via `promotion-error-copy` (`promotion_not_found`/`promotion_inactive`/…
  + the no-email campaign-budget heuristic).
- **Settings** — promotions admin (codes, status, application method).

### `<GiftCardSection />` — `checkout/gift-card-section`

- **Purpose** — gift-card code input + applied-cards chips (masked
  `••••last4`, per-card live coverage, remove). Cartbase-new — no legacy
  equivalent.
- **SDK calls** — `giftCards.applyGiftCard` / `giftCards.removeGiftCard`
  ([gift-cards.md](gift-cards.md)). Renders `cart.gift_cards[]` /
  `gift_card_total` / `gift_card_remainder` — server truth, no client
  math; totals never move (tender, not discount).
- **Props contract** — `{client, cart, onCartChange?}`. Error copy honors
  the anti-oracle contract: ONE generic message for `invalid_gift_card`
  (never branch on reasons the API hides), `rate_limited` for burned
  windows. After apply/remove the host must `syncPaymentAmount` (the
  CheckoutClient wiring does).
- **Settings** — gift-cards admin (issue/disable, expiry, balances).

### Error-copy maps — `checkout/payment-error-copy`, `checkout/address-error-copy`, `checkout/promotion-error-copy`

- **Purpose** — every raw failure → actionable Bulgarian copy, CODE-FIRST
  against the Cartbase error envelope (`StoreApiError.code`), then the
  production substring layers (Stripe.js browser errors carry no Cartbase
  code), then a clean per-context generic. `PAYMENT_ERROR_CODE_COPY`
  covers EVERY documented code of complete/prepare/sync/refresh —
  `checkout_method_hidden`, `account_required`,
  `gift_card_insufficient_balance`, the 402 payment family, …
  (unit-gated by `tests/unit/storefront-checkout.test.ts`).
- **SDK calls** — none (pure).
- **Mount rules** — call `translatePaymentError(err, "card"|"cod")` /
  `translateAddressError(err)` / `translatePromotionError(err,
  {hasEmail})` / `translateGiftCardError(err)` at the display boundary;
  never show `err.message` raw.

Also in the family barrel: `compareAddresses` (saved-address match
detection) and the `geocode` helpers (`normalizeForMatch`,
`distanceMeters`, `formatDistance`, `cleanAddress`, `geocodeAddress`) —
extra modules beyond the per-file exports, imported from
`@cartbase/storefront/checkout`.

---

## Family: cart-drawer (`@cartbase/storefront/cart-drawer/*`) — SHIPPED

Sliding cart UI, production-proven layout. All
components are `"use client"`. Money is EUR decimal major units everywhere
(cart totals are SERVER truth from the decorated cart — components render,
never compute; the only client arithmetic is the optimistic
`unit_price × quantity` preview that the next server snapshot replaces).
Domain doc for every call: [carts.md](carts.md); gift-card tender:
[gift-cards.md](gift-cards.md); cross-sell sources: [products.md](products.md)
+ [search.md](search.md).

### `<CartDrawerProvider client cart onCartChange … />` — `cart-drawer/context`

- **Purpose** — the family's root: open/close state, the optimistic cart
  snapshot (React 19 `useOptimistic`), labels + hrefs, and the SDK-wired
  mutations every child uses. Auto-opens when the product-line item count
  rises **from a nonzero base** (the guard that keeps the late-arriving
  initial cart snapshot from opening the drawer on page load — so the very
  FIRST add does not auto-open; open explicitly via `useCartDrawer().open`
  / the header `CartButtonClient`, or wire `ProductActions`' `openCart`
  seam in a client composition). Locks body scroll while open, closes on
  Escape.
- **SDK calls** — `@cartbase/storefront/api/carts`: `createCart` (first
  `addItem` with no cart — `region_id` falls back to the store default),
  `retrieveCart` (mount in `client`+`cartId` mode, and `refresh()`),
  `addLineItem`, `updateLineItem` (body `{quantity}` ONLY — Cartbase accepts
  no metadata on update; quantity 0 deletes), `deleteLineItem`. Every
  mutation returns the FULL decorated cart, which becomes the confirmed
  snapshot — no page refetch needed.
- **Props contract** — `cart?: Cart|null` (server-fetched snapshot; prop
  updates win), `client?: StorefrontClient` (enables `addItem`/
  `updateQuantity`/`removeItem`/`refresh`), `cartId?` (retrieve-on-mount
  when no snapshot), `onCartChange?(cart)` (fires on every confirmed
  change INCLUDING first-add cart creation — persist `cart.id` here),
  `onOptimisticError?(failure)` (the ops funnel for every failed
  optimistic mutation — wire to store logging; replaces the source's
  `logEvent` backend call), `labels?: Partial<CartDrawerLabels>`,
  `hrefs?: {checkout, browse, productPrefix}`.
- **Hook** — `useCartDrawer()` → `{isOpen, open, close, toggle, cart,
  addItem(variantId, qty?, display?), updateQuantity(lineId, qty),
  removeItem(lineId), refresh, applyOptimistic, dispatchOptimistic,
  labels, hrefs}`. `applyOptimistic(action, serverAction)` stays for
  store-owned server actions (PDP add via RSC).
- **Settings** — store default region + enabled currencies (cart create),
  B2B price lists via attached customer, gift-card product flags
  (`is_giftcard` lines are non-discountable), automatic promotions,
  COD-fee integration (injects the fee line the drawer hides).
- **Mount rules** — wrap the root layout; ONE provider per app. i18n via
  `labels` (`cart-drawer/labels` defaults, `cart-drawer/labels-bg`
  Bulgarian — key parity is unit-tested).

### `<CartDrawer sidebar children />` — `cart-drawer/cart-drawer`

- **Purpose** — the slide-out shell: overlay + right panel (`z-[60]`/
  `z-[61]` — the consent banner sits above at `z-[70]`), optional desktop
  left sidebar slot for cross-sell. No SDK calls.
- **Mount rules** — render once, inside the provider; put drawer body
  components in `children`. `panelClassName` composes onto the panel.

### `<CartDrawerHeader />` — `cart-drawer/header`

- **Purpose** — title + item-count badge + close button. Counts PRODUCT
  lines via `productItemCount` (`lib/cart-helpers`) so a backend-injected
  COD-fee line never inflates the count. No SDK calls (context cart).

### `<CartPromoBanner message variant />` — `cart-drawer/promo-banner`

- **Purpose** — top strip message (`info|success|warning`). Pure props.

### `<CartTieredProgress tiers currencyCode />` — `cart-drawer/tiered-progress`

- **Purpose** — progress bar to the next shipping/discount tier with
  checkpoint markers. Reads `cart.total` (tax-inclusive server truth).
- **Props contract** — `tiers: CartTier[]` sorted ascending;
  `threshold` in EUR major units (50 = €50). Pure math exported as
  `computeTierProgress(amount, tiers)` (unit-tested).

### `<CartItem item currencyCode>{upsell?}</CartItem>` — `cart-drawer/item`

- **Purpose** — one line row: thumbnail, title link, variant, quantity
  stepper, per-line price with strikethrough (server `total` <
  `original_total`), remove button (optimistic, via the provider's
  `removeItem` → `DELETE line-items/:id`).
- **Props contract** — `item: CartLineItem` (the SDK's decorated line —
  per-line totals are server-computed), `currencyCode`, `children` =
  per-item upsell slot.
- Subcomponents: `item/quantity` (`<CartItemQuantity lineId quantity
  maxQuantity? />` — stepper floor 1, calls `updateQuantity` with
  `{quantity}`), `item/variant` (`<CartItemVariant variantTitle
  options? />` — Cartbase lines carry the flat `variant_title` string, not
  an embedded variant object), `item/upsell` (`<CartItemUpsell products
  onAdd />` — feed from `listRelatedProducts`, `variantId` included so
  `onAdd` can call `addItem`).

### `<CartFreeGift … />`, `<CartGiftWrap … />`, `<CartNotes … />`, `<CartRewardsPoints … />`

- Merchandising slots (`cart-drawer/free-gift`, `gift-wrap`, `notes`,
  `rewards-points`) — pure props + labels; prices EUR major units. The
  store owns the effects: `CartGiftWrap.onToggle` → add/remove the store's
  gift-wrap variant via `addItem`/`removeItem`; `CartNotes.onSave` →
  `updateCart(client, cartId, {metadata: {...cart.metadata, gift_note}})`
  — the update route REPLACES metadata wholesale (no merge), so always
  spread the current `cart.metadata`; cart metadata is copied onto the
  order at complete. No admin settings — configured per store in code.

### `<CartCrossSellSidebar / CartCrossSellCarousel products onAdd label? />` — `cart-drawer/cross-sell-*`

- **Purpose** — desktop sidebar card list / horizontal strip of
  recommendations.
- **SDK calls** — feed `products` from `api/products.listProducts`
  (curated collection/tag) or `api/search.listRelatedProducts` (anchor
  complements — manual admin picks first, deterministic fallback fills;
  see [search.md](search.md)). Map responses with `toCrossSellProduct`
  (exported from `cart-drawer/cross-sell-sidebar`; picks the first variant
  with a server-computed `calculated_price`, returns null unpriced — pass
  `currency_code` on the listing call). Wire `onAdd(productId, variantId)`
  → `addItem(variantId)`.
- **Settings** — Admin → Product → Related (manual picks); price lists
  (what `calculated_price` contains); publishable-key channel scope.

### `<CartSummaryBreakdown />` — `cart-drawer/summary-breakdown`

- **Purpose** — full totals breakdown rendered EXACTLY from the decorated
  cart: `subtotal`, `discount_total` (>0, negated for display),
  `shipping_total` (once a shipping method is set; 0 renders FREE; before
  that "calculated at checkout"), `tax_total` (>0), `cod_fee_total` (>0,
  labeled by the server's `cod_fee_label`), `total`, then one row per
  applied gift card (masked `last4`, negated; a depleted card stays listed
  at 0) and `gift_card_remainder` — what the remainder provider charges.
  Row selection is the pure `selectSummaryRows(cart)` (unit-tested).
- **SDK calls** — none directly (context cart; every cart read re-derives
  gift-card tender from the live ledger).
- **Settings** — COD integration (fee + label), promotions, gift cards.

### `<CartStickyFooter />` — `cart-drawer/sticky-footer`

- **Purpose** — pre-checkout subtotal + checkout CTA. Deliberately shows
  `productTotal(cart.items)` (product lines only) — NOT `cart.total`,
  which carries shipping/tax/COD checkout-context state that must not
  leak into the shopping drawer. Navigates to `hrefs.checkout`.

### `<CartPaymentBadges methods? badges? />`, `<CartContinueShopping />`, `<CartEmpty />`

- `payment-badges` — inline SVG payment logos (visa/mastercard/googlepay/
  applepay/amex), overridable per store. `continue-shopping` — close link.
  `empty` — empty state with `hrefs.browse` CTA. Pure props + labels.

### `<CartDrawerTemplate config />` — `cart-drawer/template`

- **Purpose** — the optional default assembly; every
  feature opt-in via `CartDrawerConfig`. Stores wanting a different layout
  compose the primitives themselves inside `<CartDrawer>`.
- **Props contract** — `config`: `promoBanner`, `shippingTiers`
  (EUR thresholds), `freeGift` (`minCartTotal` EUR), `giftWrap`,
  `notes`, `rewards` (`pointsPerCurrency` = points per 1 EUR —
  major-unit port change from the source's per-cent rate), `crossSell`
  (`products` or async `loader` — fires once when the drawer first has
  items; build it from the SDK + `toCrossSellProduct`). Cross-sell adds
  are wired to the provider's `addItem` automatically.
- **Mount rules** — renders product lines only (`isProductLine`) — a
  fee-only cart renders as empty rather than a fake product row.

---

## Family: products (`@cartbase/storefront/products/*`) — SHIPPED

The PDP + product-card family, production-proven. All prices
render the SERVER-computed `variant.calculated_price` via
`lib/get-product-price` (Cartbase's flat `price_list_type` wire shape) — no
component computes money. Product data is the canonical `StoreProduct`
(`@cartbase/storefront/api/products`) every discovery endpoint serves.

**Labels / i18n** — `products/labels` (`ProductLabels` + English defaults),
`products/labels-bg` (full Bulgarian map, typed complete),
`products/context` (`<ProductLabelsProvider labels>` + `useProductLabels()`;
components read copy only through the context or explicit `labels` props).

### `<ProductLabelsProvider labels />` — `products/context`

- **SDK calls** — none. **Settings** — none.
- **Mount rules** — client component; wrap the product page (or app) once;
  partial `labels` merge over English defaults.

### `<Thumbnail thumbnail images size isFeatured />` — `products/thumbnail`

- **Purpose** — the product image tile used by cards; `ImageOff` fallback
  when no image exists.
- **SDK calls** — none (props: `product.thumbnail` / `product.images`).
- **Props** — `size: "small"|"medium"|"large"|"full"|"square"` (aspect +
  width), `isFeatured` (11/14 aspect), `className`.
- **Mount rules** — server-safe; uses `next/image` (host must allow the
  media domain in `next.config` images).

### `<PreviewPrice price />` — `products/preview-price`

- **Purpose** — card price line; strikethrough original + accent price when
  `price_type === "sale"`.
- **SDK calls** — none; takes a `VariantPrice` from
  `lib/get-product-price` `getProductPrice(...).cheapestPrice`.
- **Settings** — price lists (whether a `sale` type ever appears).

### `<ProductPrice product variant? />` — `products/product-price`

- **Purpose** — PDP price panel: "From <cheapest>" until a variant is
  selected, then the variant price; sale shows original + percentage off.
- **SDK calls** — none directly; the product must have been fetched WITH a
  pricing context (`currency_code`/`region_id`) or it renders the loading
  shimmer (no `calculated_price` → no price, by design).
- **Settings** — price lists / B2B groups (via the Bearer JWT on the fetch),
  region/currency context.
- **Mount rules** — client; reads labels from context.

### `<OptionSelect option current updateOption title disabled />` — `products/option-select`

- **Purpose** — one option row of value buttons (`product.options[]`, which
  carries `values[]`).
- **SDK calls** — none. **Mount rules** — client; controlled by the parent.

### Pure: `products/variant-matching` (extra module, Cartbase addition)

`optionsAsKeymap` / `optionsMatch` / `findMatchingVariant` — the
option-choice → variant resolution extracted from product-actions, reading
Cartbase's option-value LINK shape (`variant.options[].value.{option_id,value}`)
with the Medusa flat-row fallback. Unit-tested
(tests/unit/storefront-catalog.test.ts).

### `<ImageGallery images />` — `products/image-gallery`

- **Purpose** — stacked PDP gallery (rank order as served); first three
  images `priority`.
- **SDK calls** — none (props: `product.images`). Server-safe.

### `<ProductActions product addToCart disabled? onAddToCart? openCart? />` — `products/product-actions`

- **Purpose** — THE add-to-cart panel: option selection → variant
  resolution, URL `v_id` sync, price, stock gate, add button, mobile bar.
- **SDK calls** — none itself; the injected `addToCart({variantId,
  quantity})` seam is the host's cart orchestration (typically `api/carts`
  `addLineItem` + the host's cart-id cookie). `openCart` replaces the
  legacy cart-drawer context import (no hard cross-family dependency).
- **Stock contract** — Cartbase's store surface exposes NO
  `inventory_quantity`; managed-inventory variants are optimistically in
  stock and the SERVER enforces at add (400 `insufficient_inventory`, which
  flips the button to the out-of-stock state). `!manage_inventory` and
  `allow_backorder` are always addable.
- **Settings** — inventory (manage/backorder flags, kit components at add),
  price lists, promotions (server re-applies on add).
- **Mount rules** — client; needs `ProductLabelsProvider` for non-English.
  Fire the tracking trio (`trackAddToCart`/GA4/Rybbit) from `onAddToCart`.

### `<MobileActions … />` — `products/mobile-actions`

- **Purpose** — the `lg:hidden` sticky bottom bar + options bottom sheet
  (z-[75], above the cart drawer's z-[60]) shown when the desktop actions
  scroll out of view.
- **SDK calls** — none; pure props from `ProductActions` (which mounts it —
  rarely used directly).

### `<ProductTabs product />` — `products/product-tabs`

- **Purpose** — accordion: product information (material, origin, type,
  weight, dimensions) + static shipping/returns copy from labels.
- **SDK calls** — none. **Settings** — none (copy via labels).

### `<ProductInfo product />` — `products/product-info`

- **Purpose** — collection link (`/collections/<handle>`), title,
  description. **SDK calls** — none. Server-safe.

### `<ProductPreview product isFeatured? />` — `products/product-preview`

- **Purpose** — THE product card (links `/products/<handle>`): thumbnail +
  title + cheapest price. Reused by store grids, search results, related
  strip; every template accepts `renderProduct` to swap it for a custom
  card.
- **SDK calls** — none; expects a `StoreProduct` fetched with pricing
  context for the price line. Server-safe.

### `<RelatedProducts client product pricingContext? limit? labels? renderProduct? />` — `products/related-products`

- **Purpose** — the "You might also like" strip.
- **SDK calls** — `api/search` `listRelatedProducts(product.id)` — manual
  admin picks first, deterministic fallback fills to `limit`
  (`auto_filled`); anchor never appears. Renders nothing on empty/404.
- **Settings** — Admin → Product → Related (manual picks), price lists.
- **Mount rules** — async server component; render inside `<Suspense>`.

### `<ProductActionsWrapper client id pricingContext? addToCart … />` — `products/product-actions-wrapper`

- **Purpose** — re-fetches the product with the LIVE pricing context (and
  the client's Bearer JWT → group-aware B2B prices) and mounts
  `ProductActions`; the PDP shell stays cacheable.
- **SDK calls** — `api/products` `retrieveProduct(idOrHandle,
  pricingContext)`; 404 → renders nothing.
- **Mount rules** — async server component inside `<Suspense>` (fallback:
  disabled `<ProductActions>`).

### `<ProductTemplate client product pricingContext? addToCart onAddToCart? openCart? />` — `products/product-template`

- **Purpose** — the full PDP: sticky info column (`ProductInfo` +
  `ProductTabs`), gallery, sticky actions column (suspended
  `ProductActionsWrapper`), related strip.
- **SDK calls** — via children (retrieveProduct, listRelatedProducts). The
  page fetches the product by handle (`retrieveProduct`) and passes it in.
- **Settings** — union of children's.
- **Mount rules** — server component; wrap the page in
  `ProductLabelsProvider` for i18n; `addToCart`/`openCart` seams as on
  `ProductActions`.

## Family: store (`@cartbase/storefront/store/*`) — SHIPPED

The listing family: paginated grids, sort, collection/category/search
templates. Sorting discipline: the API's `order` param is the authority for
paginated listings; client-side re-sort (`lib/sort-products`) exists ONLY
for the price sorts on the plain products listing (price is not a products
column) over the legacy 100-item window.

**Labels / i18n** — `store/labels` (`StoreLabels` + defaults + the pure
`sortOptionLabelKeys` map — completeness unit-tested), `store/labels-bg`
(full Bulgarian map). Templates take `labels?: Partial<StoreLabels>` props
(no context in this family, matching the production original).

### `<Pagination page totalPages />` — `store/pagination`

- **Purpose** — windowed page-number pagination; writes the `page` query
  param and pushes the route (server re-renders with the new offset).
- **SDK calls** — none. **Mount rules** — client.

### `<SortSelect sortBy? labels? />` — `store/sort-select`

- **Purpose** — sort sidebar; writes the `sortBy` query param
  (`created_at` | `price_asc` | `price_desc`, rendered from
  `sortOptionLabelKeys`).
- **Props** — `sortBy` OPTIONAL (port adaptation): on collection pages no
  selection = the collection's admin `default_sort`, nothing highlighted.
- **SDK calls** — none. **Mount rules** — client.

### `<PaginatedProducts client page sortBy? collectionId? categoryId? productsIds? pricingContext? renderProduct? />` — `store/paginated-products`

- **Purpose** — the 12-per-page product grid + pagination over the plain
  products listing.
- **SDK calls** — `api/products` `listProducts`: `created_at` →
  server `order:"-created_at"` + real offset pagination; `price_asc`/
  `price_desc` → 100-item window fetch, `lib/sort-products` re-sort, slice
  (the proven production approach — see module JSDoc for the >100 caveat).
- **Props note** — `collectionId` filters by PRIMARY collection
  (`products.collection_id`); the membership join lives in
  `CollectionTemplate`.
- **Settings** — price lists (pricing context), sales-channel/publishable-
  key scope on the client.
- **Mount rules** — async server component; render inside `<Suspense>`
  with `<SkeletonProductGrid />`.

### `<SkeletonProductGrid numberOfProducts? />` — `store/skeleton-product-grid`

- **Purpose** — pulse skeleton for any product grid. Server-safe, no calls.

### `<StoreTemplate client sortBy? page? pricingContext? labels? renderProduct? />` — `store/store-template`

- **Purpose** — the `/store` all-products page: sort sidebar + heading +
  suspended `PaginatedProducts`.
- **SDK calls** — via `PaginatedProducts`. Pass the page's `sortBy`/`page`
  query params straight in.

### `<CollectionTemplate client collection sortBy? page? pricingContext? labels? renderProduct? />` — `store/collection-template`

- **Purpose** — collection page over the MEMBERSHIP listing (multi-
  collection products appear in every collection).
- **SDK calls** — `api/collections` `listCollectionProducts(collection.id)`
  — the admin `default_sort` is honored SERVER-side when `sortBy` is unset
  (no client re-sort, port adaptation); a shopper override maps
  `created_at→newest`, `price_asc`, `price_desc` to the `order` param
  (price sorting server-side here, unlike the plain listing). Fetch the
  collection itself via `listCollections({handle})` in the page.
- **Settings** — collection `default_sort` + manual order, smart-collection
  conditions, channel links (scoped-away collection 404s), price lists.
- **Mount rules** — server component; grid suspends internally.

### `<CategoryTemplate client category sortBy? page? pricingContext? labels? renderProduct? />` — `store/category-template`

- **Purpose** — category page: breadcrumbs (ancestor chain), description,
  child-category links, `PaginatedProducts` filtered by `category_id`.
- **SDK calls** — via `PaginatedProducts`. Fetch the category in the page
  with `retrieveCategory(id, {include_ancestors_tree: true,
  include_descendants_tree: true})` — without the flags the breadcrumb and
  children sections don't render.
- **Settings** — category tree (active/internal flags are server-filtered).

### `<SearchTemplate client searchParams basePath? pricingContext? limit? labels? renderProduct? />` — `store/search-template`

- **Purpose** — the search results page in the
  store-template idiom — GET query box, facet sidebar from the response
  `facets[]`, result grid (reuses the product card), pagination. Fully
  URL-state driven: works server-rendered with zero own client JS.
- **SDK calls** — `api/search` `searchProducts` (only when `q` present).
  Facet buckets toggle by rewriting the wire-named query params
  (`collection_id`/`type_id`/`tag_id` CSV, `price_min`/`price_max`,
  `availability`, `option.<Title>` CSV) via the pure `store/search-params`
  helpers (round-trip unit-tested); every toggle resets `page`.
- **Settings** — Search & discovery: synonyms, pins/boosts, facet config
  (order/enabled, price bucket strategy `auto`/`fixed`); price facet
  currency follows the pricing context.
- **Mount rules** — async server component; pass the route's raw
  `searchParams`; `basePath` defaults to `/search`.

### Pure: `store/search-params` (extra module, Cartbase addition)

`parseSearchParams` / `buildSearchQueryString` / `fromQueryString` /
`toggleFacetSelection` / `isFacetSelected` / `clearFilters` /
`toSearchQuery` — the search page's URL-state machine, exported for custom
search UIs (chips, drawers) that want the same URL contract.

---

## Family: order (`@cartbase/storefront/order/*`) — SHIPPED

Order confirmation + account order views, production-proven. The Medusa `StoreOrder` was ONE object carrying computed line
totals, order totals, shipping methods and payments; Cartbase splits those
across surfaces, so the family takes them as separate props — the Cartbase
`StoreOrderDetail` (`api/orders`) carries items as version-pivot rows
(`{quantity, line_item}`), fulfillments with tracking labels, and both
addresses, while MONEY comes from the decorated cart (`api/carts` `Cart`)
or the order summary snapshot. Components render server truths; the only
arithmetic is the two documented production subtractions in the totals
selector.

Labels: `OrderLabelsProvider`/`useOrderLabels` (`order/context`) +
`defaultOrderLabels` (`order/labels`) + `bulgarianOrderLabels`
(`order/labels-bg`, production Bulgarian copy). Every
component also takes a `labels` prop pick.

### `<OrderCompletedTemplate order totals items? shippingMethod? paymentProviderId? cardLast4? … />` — `order/order-completed-template`

- **Purpose** — the full confirmation page: hero header, fulfillment
  timeline, items+totals card, contact/delivery/payment/help cards,
  continue-shopping CTA.
- **SDK calls** — none itself; feed it: `order` (`retrieveOrder` /
  `retrieveOrderByDisplayId` detail, or the `completeCart()` response),
  `totals` (the decorated cart from checkout, or
  `orderTotalsFromSummary(completeCart().order.summary)`), optional
  normalized `items` (prefer `displayItemFromCartLine` right after
  checkout — keeps server-computed per-line discounts), `shippingMethod` /
  `paymentProviderId` / `cardLast4` from checkout state.
- **Mount rules** — order-confirmation route (server component OK; only
  the timeline child is client). The Purchase tracking trio (fbq/gtag/
  rybbit, deduped by `order.display_id`) is APP-OWNED: fire it from the
  confirmation route exactly once per order per the tracking family's
  dedupe contract — the template deliberately does NOT fire it.
- **Settings** — COD settings (fee row presence + `cod_fee_label`),
  checkout rules (which provider ids appear), store locales (labels pack).

### `<OrderConfirmationHeader order />` — `order/order-confirmation-header`

- **Purpose** — check hero + "Order #display_id" + localized date chips.
- **Props** — structural `OrderHeaderData` (`display_id`, `email`,
  `created_at`) — order detail and `completeCart().order` both satisfy it.
  `locale` drives `toLocaleDateString`.

### `<OrderItemsList items currencyCode />` + `<OrderItem item currencyCode />` — `order/order-items-list`, `order/order-item`

- **Purpose** — the purchased lines (thumb, title, variant caption, qty,
  line total with discount strike-through when the source had it).
- **Data seam** — normalized `OrderDisplayItem[]` via the pure converters
  `displayItemFromOrderItem(pivot)` (order path: `unit_price × quantity`)
  / `displayItemFromCartLine(line)` (cart path: server `total` /
  `original_total`). Legacy COD-fee line items
  (`metadata.is_cod_fee=true`) are hidden here and surfaced in
  `OrderTotals` instead (`lib/cart-helpers.isProductLine`); Cartbase-native
  COD fees are never line items, so on pure Cartbase data the filter is a
  no-op safety net. Newest-first sort by `createdAt` when present.

### `<OrderTotals totals currencyCode items? codFeeLabel? />` — `order/order-totals`

- **Purpose** — the money breakdown: Subtotal / Shipping (FREE badge at
  0) / COD fee / Discount (negated) / Tax / Total, all via `DualPrice`.
- **Data seam** — `OrderTotalsSource` (the decorated cart satisfies it:
  `item_subtotal`, `shipping_subtotal`, `discount_total`, `tax_total`,
  `total`, `cod_fee_total`, `cod_fee_label`); the summary snapshot adapts
  via `orderTotalsFromSummary`. Row policy is the pure, unit-tested
  `selectOrderTotalsRows`: native `cod_fee_total` wins over a legacy fee
  LINE; a legacy fee line's net is subtracted from the visible subtotal
  (v2.3.1 production fix); COD label preference `codFeeLabel` prop →
  server `cod_fee_label` → fee-line title → `labels.codFee`.
- **Settings** — COD settings (`cod_fee_total`/`cod_fee_label`),
  promotions (discount row).

### `<OrderAddressCard order />` — `order/order-address-card`

- Contact info card: shipping-address name + phone + order email.
  Structural `OrderContactData` — the order detail satisfies it.

### `<OrderDeliveryCard order shippingMethod? currencyCode />` — `order/order-delivery-card`

- **Purpose** — pickup point (Econt office metadata / stable
  fulfillment-option ids `econt-office`, `boxnow-locker` — id beats name
  parsing, production fix) or shipping address, the method row with
  price/FREE, and — Cartbase addition — the fulfillment tracking labels.
- **Data seam** — `order.metadata` + `order.shipping_address` +
  `order.fulfillments` from the detail; `shippingMethod` (structural,
  `CartShippingMethod` fits) from checkout state since the Cartbase order
  read has no shipping-method embed. Tracking rows come from the pure,
  unit-tested `pickTrackingLabels(order.fulfillments)` — skips canceled
  fulfillments, drops number-less labels, dedupes re-prints.
- **Settings** — carrier integrations (whether labels/urls exist),
  shipping options (ids/names).

### `<OrderPaymentCard providerId cardLast4? />` — `order/order-payment-card`

- Payment method card; `resolvePaymentTitle` buckets the provider id via
  `lib/payment-constants` (`pp_stripe` → card, `pp_system_default` → COD)
  through the locale pack, falling back to `paymentInfoMap` then the raw
  id. Payment internals never cross the Cartbase store surface — the id
  arrives via props from checkout state.

### `<OrderTimeline fulfillmentStatus? />` — `order/order-timeline`

- Placed → Processing → Shipped → Delivered progress (client, animated).
  Cartbase has no `fulfillment_status` column on the store surface —
  derive it with the pure, unit-tested
  `deriveFulfillmentStatus(order.fulfillments)` (packed_at/shipped_at/
  delivered_at ladder, `partially_*` when only some active fulfillments
  reached a stage, canceled ignored).

### `<OrderHelpSection contactHref? returnsHref? />` — `order/order-help-section`

- "Need help?" links card (contact + returns). No SDK calls, no settings.

---

## Family: common (`@cartbase/storefront/common/*`) — SHIPPED

Shared storefront chrome, production-proven.

### `<LocalizedLink href … />` — `common/localized-link`

- `next/link` that persists the URL locale/country segment when the route
  has one (`[countryCode]` param by default, `paramName` overridable);
  plain link on cookie-locale apps (the Cartbase default). Client.

### `<CartButton client cartId? />` + `<CartButtonClient cart />` — `common/cart-button`, `common/cart-button-client`

- **Purpose** — header cart button: badge count + opens the cart drawer.
- **SDK calls** — server wrapper: `api/carts.retrieveCart(client, cartId)`
  (the app owns the cart-id cookie); fetch failure degrades to an empty
  button. Client half takes the decorated `Cart`; badge count =
  `productItemCount(cart.items)` (fee-line-aware, consistent with the
  drawer).
- **Mount rules** — `CartButtonClient` must sit inside the cart-drawer
  family's `<CartDrawerProvider>` (it calls `useCartDrawer().open`).

### `<DeleteButton client cartId id onDeleted? />` — `common/delete-button`

- Cart-line remove with spinner. Calls
  `api/carts.deleteLineItem(client, cartId, id)` (idempotent) and hands
  the refreshed `{cart}` to `onDeleted`; spinner resets on failure.

### `<CountrySelect regions value? onChange />` — `common/country-select`

- **Purpose** — region picker. SDK-forced divergence from the source:
  Cartbase regions carry NO `countries[]` embed on the store surface, so
  the select lists REGIONS (`api/regions.listRegions`), valued by region
  id; persistence is app-owned via `onChange` (usually
  `carts.updateCart(client, cartId, {region_id})` + a cookie).
- **Settings** — Regions (Settings → Regions): which rows exist.

### `<LanguageSelect locales currentLocale onChange labels? />` — `common/language-select`

- **Purpose** — locale switcher. Feed `locales` from
  `api/regions.listLocales(client)` (bare codes, store default first —
  SDK wins over the source's `{code,name}` objects); display names via
  `localeDisplayName()` (`Intl.DisplayNames` autonym) with per-store
  `labels` overrides. `onChange` persists (cookie read by
  `StorefrontClient.getLocale`) inside the preserved `useTransition`
  pending-disable UX.
- **Settings** — Settings → Store → locales (per-store `store_locales`).

### `<Skeleton className? />` + `<SkeletonProductPreview />` — `common/skeleton`

- Loading placeholders (pure UI over theme tokens).

---

## Family: reviews-ui (`@cartbase/storefront/reviews-ui`) — SHIPPED

Verified-purchase review components, ported from a production storefront,
over `api/reviews`. ONE barrel export seam: everything imports from
`@cartbase/storefront/reviews-ui`. Endpoint truth:
[reviews.md](reviews.md). Labels: `defaultReviewsUiLabels` +
`bulgarianReviewsUiLabels` (production Bulgarian copy,
parameterized with `{n}`/`{pct}`/`{name}`/`{mb}`/`{s}`/`{email}` slots —
resolve via `formatLabel`).

### `<ReviewWidget client productId initialData? />` — the PDP section

- **Purpose** — aggregate header (score badge + 5→1 distribution bars +
  sort select) + masonry card list + load-more + lightbox. Renders null
  at zero reviews (production rule); derives avg/distribution from the
  loaded page if the aggregate is missing (never a misleading "0.0").
- **SDK calls** — bootstrap: `getWidget(client, productId)` (ONE call:
  aggregate + first page per the store's display options; edge-cached
  60s) — server-fetch it and pass `initialData` (recommended), else the
  widget fetches on mount. Sort changes / load-more: `listReviews`
  (`sortParamsFor` maps the UI keys to the API `(sort, order)` tuple).
- **Mount rules** — client component, PDP below the fold; `id="reviews"`
  anchor built in. Verified badge is unconditional (every review is
  token-minted — a system tautology, not a flag).
- **Settings** — Settings → Reviews display options: `widget_layout`
  (masonry|list), `widget_page_size`, `widget_photo_first` (all arrive
  via `getWidget().options`); moderation decides visibility.
- Pieces exported for custom layouts: `<StarRow>`, `<StarBadge>`,
  `<RatingDistribution>` (`star-badge`), `<ReviewList>` +
  `<ReviewLightbox>` (presentational cards + overlay),
  `reviewDisplayName` (first name + surname initial — shared with any
  app JSON-LD so UI and structured data can't drift, production fix),
  `formatReviewDate`.

### `<ReviewWizard client token validation rewardPct? supportEmail? … />` — the token page

- **Purpose** — everything behind `<review_link_base>/<token>`: invalid/
  expired panels, the terminal already-submitted panel, and the two-step
  form (rate → photo → done) with the reward-code reveal.
- **SDK calls** — `validateToken(client, token)` SERVER-SIDE in the page
  (pass the result as `validation` — never flash a form on a dead
  token; mark the route noindex), then client-side: `submitReview`
  (step 1 — consumes the token, rating locked in even if the customer
  bails), `createUploadUrl` → signed R2 PUT → `attachReviewPhoto`
  (step 2 — mints the single-use reward code; `code: null` on 200 =
  media saved, mint failed → "write to us" note, never an error).
- **Step resolution** — the pure, unit-tested
  `resolveWizardEntry(validation)`; THE RESUME RULE: consumed token +
  review row + `reward_code` null → resume at photo; `reward_code` set →
  done showing the code; consumed with no review row → terminal panel.
  Submit errors map by status via `submitErrorKeyFor` (429/409/410).
- **Mount rules** — `ReviewWizard` is the full page body (client);
  `ReviewWizardForm` and `<ReviewPhotoUpload>` (drag-drop, per-file slot
  caps ≤6 images/≤1 video, 8/50 MB, 60s video, blob-preview swap +
  revoke) are exported for custom pages.
- **Settings** — Settings → Reviews: `reward_enabled` /
  `reward_percentage` (the store surface does not expose the percentage —
  pass `rewardPct`, default 10), `moderation_mode` (`hold` lands the
  review pending; the thanks copy stays true either way), request-scanner
  settings decide when tokens are minted at all.
