Components

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, 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) — @import "@cartbase/storefront/theme" in the app's CSS supplies both the token names and a filled default set of values, so components render designed out of the box. No component hardcodes a colour; retheming means overriding values, never renaming tokens.


Family: tracking (@cartbase/storefront/tracking/*)

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.

  • 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).
  • Purpose — the built-in two-layer CMP UI; writes the _1c_consent cookie and applies the live gtag('consent','update') + fbq('consent').
  • SDK callsGET /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 callsgetTrackingConfig(client) (tracking/get-tracking-config, wraps GET /api/store/integrationstracking.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 enabledconsent_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 callsgetTrackingConfig(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 events — tracking/events (use this one)

ONE call per commerce moment, every vendor at once. Reach for these rather than the per-vendor helpers below, because the failure they prevent is the common one and it is invisible: a store adds a vendor, updates three of the four call sites, and an ad account optimises on partial data for a month before anyone notices.

Step Call
Product view trackProductView({ line, currency, value })
Add to cart trackCartAdd({ line, currency, value })
Checkout start trackCheckoutStart({ lines, currency, value, coupon? })
Order confirmed trackOrderPurchase(order, trackingConfig)

A line is { productId, variantId?, title, quantity, price }. Pass the PRODUCT id: Meta's content_ids, TikTok's content_id and the catalogue feed's <g:id> must be the same value or the event matches no catalogue entry, which costs dynamic ads on both platforms. GA4 is keyed by variant instead, on purpose, and these helpers do that split for you.

trackOrderPurchase takes the tracking config as its second argument because Google Ads only fires when the store configured BOTH the account id and the purchase label. Pass order.metadata.customer_type through as customerType when present: it is computed once server-side, so the browser and the server tell Google and TikTok the same thing.

Per-vendor helpers — tracking/fbq, tracking/gtag, tracking/ttq, tracking/rybbit-events, tracking/google-ads

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) TikTok (ttq.ts) GA4 (gtag.ts) Rybbit (rybbit-events.ts)
Product view trackViewContent trackTikTokViewContent trackGAViewItem trackRybbitViewItem
Add to cart trackAddToCart trackTikTokAddToCart trackGAAddToCart trackRybbitAddToCart
Checkout start trackInitiateCheckout trackTikTokInitiateCheckout trackGABeginCheckout trackRybbitBeginCheckout
Order confirmed trackPurchase(data, order.display_id) trackTikTokPurchase({…, displayId}) trackGAPurchase({transaction_id: String(order.display_id), …}) trackRybbitPurchase
Newsletter/popup signup trackLead

Google Ads has one event, the purchase conversion: trackGoogleAdsPurchase({ sendTo, value, currency, transactionId, newCustomer? }), with sendTo from googleAdsPurchaseSendTo(config) (null when the label is not configured — skip the conversion rather than fire a malformed send_to, which Google accepts and silently drops).

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).
  • TikTok: browser pixel event_id = "tt_purchase_" + order.display_id, the same id the backend Events API Purchase sends. The tt_ prefix keeps it from colliding with Meta's on a page carrying both pixels. TikTok dedupes on event_source_id + event + event_id and discards duplicates for 48 hours; the backend ALSO carries an idempotency flag, because 48 hours does not cover an order email opened on day four.
  • GA4: transaction_id = String(order.display_id) on both browser event and backend Measurement Protocol — GA4 dedupes by transaction_id natively.
  • Google Ads: transaction_id again, the same value. Never send it empty: an empty transaction_id dedupes nothing, so every re-open of the confirmation page counts another conversion.
  • 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 and gtag('set','user_data',…). Call alongside updatePixelAdvancedMatching at checkout. The phone is normalised to E.164 WITH the plus, which is NOT the digits-only string Meta wants: two vendors, two normalisers, and merging them re-creates a defect that fails silently and costs match rate with nothing in any log.

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/*)

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/*)

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/utilscn() (clsx + tailwind-merge).
  • lib/moneyconvertToLocale({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-helpersisProductLine / 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-pricegetProductPrice({product, variantId}) / getPricesForVariant → formatted VariantPrice (cheapest + selected) from server-computed calculated_price. Reads Cartbase's flat price_list_type with a legacy 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/productisSimpleProduct (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-constantsisStripeLike / isPaypal + paymentInfoMap. Two tender shapes since the pp_* kill: a processor provider_id (pp_stripe exact, code truth src/lib/stripe/providers.ts, plus legacy Medusa-era prefixes as fallbacks) or a merchant METHOD rendered by its snapshot NAME — a method never has a provider id to bucket. Affected by: connected processors + enabled methods + checkout rules (which entries ever reach the client).
  • lib/store-api-errorstoreApiError(err): display-boundary normalizer (capitalized message + terminal period). 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/*)

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):

listShippingOptions(cart_id) + listPaymentProviders(cart_id)  (render pickers)
   → Buy click → prepareCheckout (ONE atomic, compensated call)
   → pp_stripe: stripe.confirmPayment(client_secret) | merchant method: 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 callscarts.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?, orderConfirmedPath?, onOrderPlaced?, resolveTrackingMetadata?, logError?}. countries is caller-supplied (Cartbase regions embed NO countries array). Fee prediction is never hardcoded: each method LISTING entry carries its own fee_amount/fee_label. 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 + payment_method_id:null SKIPS Stripe and completes on the gift session (gift-cards.md). Processors are pp_stripe exactly; merchant methods list as {payment_method_id, name, kind, instructions, fee_amount, fee_label} entries — the COD-kind method wins the offline tab, else the first manual method. The charged fee reads the cart-level payment_method_fee_total decoration.
  • Settings — checkout rules (filter the listings + complete guard), the methods' own fee configuration (Payments settings), 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; legacy env names kept as fallbacks).
  • Mount rulesPaymentWrapper 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 Fields, 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); 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 callscarts.updateLineItem (quantity); child widgets below. Totals are rendered STRAIGHT from the cart decoration: item_total, shipping_total, payment_method_fee_total/payment_method_fee_label, discount_total, tax_total, total, gift_card_total, gift_card_remainder.
  • Props contract{client, cart, optimisticShippingCost, onOptimisticShippingClear?, optimisticCodFee?, onOptimisticCodFeeClear?, methodFeeLabel?, 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 callsPOST /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 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 callsgiftCards.applyGiftCard / giftCards.removeGiftCard (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/*)

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; gift-card tender: gift-cards.md; cross-sell sources: products.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 contractcart?: 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}.
  • HookuseCartDrawer(){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 contracttiers: 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 removeItemDELETE line-items/:id).
  • Props contractitem: 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.onSaveupdateCart(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). 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), payment_method_fee_total (>0, labeled by the server's payment_method_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.
  • 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 contractconfig: 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/*)

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 / i18nproducts/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).
  • Propssize: "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 " 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 a legacy flat-row fallback. Unit-tested (tests/unit/storefront-catalog.test.ts).

  • 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.
  • Purpose — the "You might also like" strip.
  • SDK callsapi/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 callsapi/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/*)

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 / i18nstore/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).
  • PropssortBy 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 callsapi/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 notecollectionId 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 callsapi/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 callsapi/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/*)

Order confirmation + account order views, production-proven. Some platforms ship ONE StoreOrder 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 + payment_method_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? methodFeeLabel? />order/order-totals

  • Purpose — the money breakdown: Subtotal / Shipping (FREE badge at
    1. / COD fee / Discount (negated) / Tax / Total, all via DualPrice.
  • Data seamOrderTotalsSource (the decorated cart satisfies it: item_subtotal, shipping_subtotal, discount_total, tax_total, total, payment_method_fee_total, payment_method_fee_label); the summary snapshot adapts via orderTotalsFromSummary. Row policy is the pure, unit-tested selectOrderTotalsRows: native payment_method_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 methodFeeLabel prop → server payment_method_fee_label → fee-line title → labels.paymentMethodFee.
  • Settings — COD settings (payment_method_fee_total/payment_method_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 seamorder.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(providerId, titles, methodName?) — the method's merchant NAME wins verbatim when present (the snapshot from session data); otherwise the processor id buckets via lib/payment-constants (pp_stripe = card) through the locale pack, falling back to paymentInfoMap then the raw id. Payment internals never cross the Cartbase store surface — both arrive 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/*)

Shared storefront chrome, production-proven.

  • 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 rulesCartButtonClient 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)

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. 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 callsvalidateToken(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 rulesReviewWizard 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.