Components — @barter/storefront UI families
The component layer of @barter/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
(@barter/storefront/tracking/meta-pixel, @barter/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
(@barter/storefront/tailwind-preset) and define the token variables in the
app's CSS; no component hardcodes a color.
Family: tracking (@barter/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_consentcookie 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_consentcookie and applies the livegtag('consent','update')+fbq('consent'). - SDK calls —
GET /api/store/consent(via@barter/storefront/api/consent) for the store's config; passcopy = pickConsentCopy(consent.copy, locale), pluslayout/privacy_href/reject_on_first_layerfrom the payload. - Mount rules — mount ONLY when
shouldRenderBanner(consent)(i.e.enabled && mode === "builtin"). Inmode: "external"render nothing — the merchant's CMP must write the same_1c_consentcookie or callsetConsent(); all gating keeps working off that one seam. z-index isz-[70](above the cart drawer'sz-[60]). - Settings — the consent card config (admin → Settings → Consent):
enabled,mode,layout(modalblocks + scroll-locks;banner-bottomis non-blocking and must not trap focus),privacy_href,reject_on_first_layer, per-localecopy. - 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_consentBEFOREfbq('init')(pre-init revoke = Pixel queues events until grant), fires the initial PageView. - SDK calls —
getTrackingConfig(client)(tracking/get-tracking-config, wrapsGET /api/store/integrations→tracking.facebookPixel.pixelId). - Mount rules — root layout, after
<ConsentInit>. Renders nothing whenpixelIdis falsy. Whentracking.consent_requiredis true the consent gate above is mandatory (it is wired in by construction — the snippet always reads the cookie). - Settings — admin Integrations hub
facebook_capirow (enabled+credentials.pixel_id); consent cardenabled→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 forgetKnownVisitor().
<GA4 measurementId /> — tracking/ga4
- Purpose — loads gtag.js +
gtag('config'). gtag then owns the_ga/_ga_<MEASUREMENT_ID>cookies thatgetTrackingAttribution()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 — firepage_viewfrom a route-change effect if per-route views are wanted. - Settings — Integrations hub
ga4row (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
trackingblock: platform-provisioned, the host app passes props from platform env. Outside the consent system by design (cookieless). - Mount rules — root layout; renders nothing when
siteIdfalsy. - 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 backendorder.placedCAPI Purchase uses.trackPurchase()builds it from the caller'sdisplay_idso 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_first365d, last-touch_1c_utm_last90d),getOrCreateFbp()/getOrCreateFbc()(defensive_fbp/_fbcwrites so the Pixel-load race never ships empty match keys — production fix),getOrCreateAnonId()(_1c_anon, the guestexternal_id),initEngagementTime(). - Server side, at checkout completion:
getTrackingAttribution(clientHints?, opts?)reads the cookies + headers and returns theTrackingAttributionkeys to write intocart.metadata(CONSENT-GATED — only when the visitor's choice allows). Cart completion copies them toorder.metadata; the backend forwarder inherits fbp/fbc/anon-id/ga signals from there. Pass{ engagementTimeMsec: getEngagementTimeMsec() }as clientHints, and eitheropts.ga4MeasurementIdoropts.clientso the_ga_<id>session cookie can be located. - Settings — Integrations hub rows (which signals exist), consent card (whether capture happens).
Family: primitives (@barter/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 thepulseattention 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-standardbutton(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 (@barter/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 exportsEUR_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→ formattedVariantPrice(cheapest + selected) from server-computedcalculated_price. Reads Cartbase's flatprice_list_typewith the Medusa nested shape as fallback. Affected by: price lists / B2B pricing context (whatcalculated_pricecontains), 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'sorderparam stays the authority for paginated listings.lib/payment-constants—isStripeLike/isPaypal/isManual+paymentInfoMap. Translated to Cartbase ids: matchespp_stripeexactly (code truthsrc/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 ofmedusaError. CatchStoreApiErrordirectly instead when branching onstatus/code.lib/hooks/use-intersection,lib/hooks/use-toggle-state— viewport + toggle micro-hooks (client).
Family: checkout (@barter/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):
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 pageAmounts 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-classcompany_name/company_eik),checkout.calculateShippingOption(calculated-rate forward-compat),checkout.prepareCheckout,checkout.syncPaymentAmount(exposed + fired on payment-tab switch with the newprovider_id),checkout.refreshPaymentIfTerminal(exposed — call REACTIVELY from Elementsloaderroronly),carts.completeCart. - Props contract —
{client, cart, customer, availableShippingMethods, availablePaymentMethods, countryCode?, countries?, paymentMethodFilter?, codConfig?, orderConfirmedPath?, onOrderPlaced?, resolveTrackingMetadata?, logError?}.countriesis caller-supplied (Cartbase regions embed NO countries array).codConfig= the integrationscodblock — the COD fee prediction is never hardcoded.logErrorreplaces the legacy log writers (all production log points preserved). Returns the full orchestration surface (performBuyClick,optimisticTotal(Cents),deliveryReady, …). - Cartbase specifics — zero-remainder gift path:
prepareCheckoutreturningclient_secret:null+provider_id:nullSKIPS Stripe and completes on the gift session (gift-cards.md). Provider ids arepp_stripe/pp_cod/pp_manualexactly;pp_manualdoubles as the offline tab when no true COD provider exists (fee predicted only forpp_cod). COD fee reads the cart-levelcod_fee_totaldecoration. - 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?, Stripeappearance/fonts,onCartChange?(receives every decorated cart from summary mutations; the layout also re-runssyncPaymentAmounton those). - Mount rules —
"use client"; insideCheckoutProvider; redirect server-side whencart.completed_atis set (the hook also flagscartIsCompleted).
<PaymentWrapper cart amount /> + Stripe scope — checkout/payment-wrapper, checkout/stripe-wrapper
- Purpose — deferred-intent Stripe context:
PaymentWrapperpublishes{stripePromise, amount, currency, appearance, fonts};StripeElementsScopemounts<Elements mode:"payment">where needed (passthroughrenders children scope-less on COD-only stores);StripeContextboolean = "Stripe.js ready". - SDK calls — none (env:
NEXT_PUBLIC_STRIPE_KEY, Medusa-era names kept as fallbacks). - Mount rules —
PaymentWrapperwraps the page ONCE withamount={optimisticTotalCents}(cents at this Stripe boundary only);StripeElementsScopelives INSIDE the payment section so a session rotation never tears down the form/tracking tree. - Settings — Stripe integration (whether
pp_stripeis 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 fromcustomers.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_orderare server-side). - Props contract — hook state +
econt?/boxnow?picker configs (detection by the STABLEshipping_option.data.id—"econt-office"/"boxnow-locker"— never display names;boxnow.clientcarries 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 viacarriers.boxnow.lockers_url, don't probe. - Props contract —
{userCity, userAddress, selectedOffice|Locker, onSelect}(+clientfor BoxNow). The CHOSEN office/locker is client state; the hook writes it intocarrier_metadataexactly 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 NOfields.billingDetails.addressoverride — 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.listPaymentProvidersresults via the hook'shasCard/hasCod; the click path runs the hook's calls. - Props contract — hook state +
buyButtonNotReady(Reason?),gatePaymentUntilDelivery?(false = always-visible payment section),beforePaymentButton?slot,total(passoptimisticTotal),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.
LineItemCardis 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 for the cart shape;cart.promotionsarrives as the{promotion:{…}}pivot embed, unwrapped here). - Props contract —
{client, cart, onCartChange?}. Errors code-first viapromotion-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). Renderscart.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 forinvalid_gift_card(never branch on reasons the API hides),rate_limitedfor burned windows. After apply/remove the host mustsyncPaymentAmount(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_COPYcovers EVERY documented code of complete/prepare/sync/refresh —checkout_method_hidden,account_required,gift_card_insufficient_balance, the 402 payment family, … (unit-gated bytests/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 showerr.messageraw.
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
@barter/storefront/checkout.
Family: cart-drawer (@barter/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; 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 viauseCartDrawer().open/ the headerCartButtonClient, or wireProductActions'openCartseam in a client composition). Locks body scroll while open, closes on Escape. - SDK calls —
@barter/storefront/api/carts:createCart(firstaddItemwith no cart —region_idfalls back to the store default),retrieveCart(mount inclient+cartIdmode, andrefresh()),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(enablesaddItem/updateQuantity/removeItem/refresh),cartId?(retrieve-on-mount when no snapshot),onCartChange?(cart)(fires on every confirmed change INCLUDING first-add cart creation — persistcart.idhere),onOptimisticError?(failure)(the ops funnel for every failed optimistic mutation — wire to store logging; replaces the source'slogEventbackend 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_giftcardlines 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/labelsdefaults,cart-drawer/labels-bgBulgarian — 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 atz-[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.panelClassNamecomposes 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;thresholdin EUR major units (50 = €50). Pure math exported ascomputeTierProgress(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'sremoveItem→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, callsupdateQuantitywith{quantity}),item/variant(<CartItemVariant variantTitle options? />— Cartbase lines carry the flatvariant_titlestring, not an embedded variant object),item/upsell(<CartItemUpsell products onAdd />— feed fromlistRelatedProducts,variantIdincluded soonAddcan calladdItem).
<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 viaaddItem/removeItem;CartNotes.onSave→updateCart(client, cartId, {metadata: {...cart.metadata, gift_note}})— the update route REPLACES metadata wholesale (no merge), so always spread the currentcart.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
productsfromapi/products.listProducts(curated collection/tag) orapi/search.listRelatedProducts(anchor complements — manual admin picks first, deterministic fallback fills; see search.md). Map responses withtoCrossSellProduct(exported fromcart-drawer/cross-sell-sidebar; picks the first variant with a server-computedcalculated_price, returns null unpriced — passcurrency_codeon the listing call). WireonAdd(productId, variantId)→addItem(variantId). - Settings — Admin → Product → Related (manual picks); price lists
(what
calculated_pricecontains); 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'scod_fee_label),total, then one row per applied gift card (maskedlast4, negated; a depleted card stays listed at 0) andgift_card_remainder— what the remainder provider charges. Row selection is the pureselectSummaryRows(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) — NOTcart.total, which carries shipping/tax/COD checkout-context state that must not leak into the shopping drawer. Navigates tohrefs.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 withhrefs.browseCTA. 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(minCartTotalEUR),giftWrap,notes,rewards(pointsPerCurrency= points per 1 EUR — major-unit port change from the source's per-cent rate),crossSell(productsor asyncloader— fires once when the drawer first has items; build it from the SDK +toCrossSellProduct). Cross-sell adds are wired to the provider'saddItemautomatically. - Mount rules — renders product lines only (
isProductLine) — a fee-only cart renders as empty rather than a fake product row.
Family: products (@barter/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
(@barter/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
labelsmerge over English defaults.
<Thumbnail thumbnail images size isFeatured /> — products/thumbnail
- Purpose — the product image tile used by cards;
ImageOfffallback 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 innext.configimages).
<PreviewPrice price /> — products/preview-price
- Purpose — card price line; strikethrough original + accent price when
price_type === "sale". - SDK calls — none; takes a
VariantPricefromlib/get-product-pricegetProductPrice(...).cheapestPrice. - Settings — price lists (whether a
saletype 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 (nocalculated_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 carriesvalues[]). - 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_idsync, price, stock gate, add button, mobile bar. - SDK calls — none itself; the injected
addToCart({variantId, quantity})seam is the host's cart orchestration (typicallyapi/cartsaddLineItem+ the host's cart-id cookie).openCartreplaces 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 (400insufficient_inventory, which flips the button to the out-of-stock state).!manage_inventoryandallow_backorderare always addable. - Settings — inventory (manage/backorder flags, kit components at add), price lists, promotions (server re-applies on add).
- Mount rules — client; needs
ProductLabelsProviderfor non-English. Fire the tracking trio (trackAddToCart/GA4/Rybbit) fromonAddToCart.
<MobileActions … /> — products/mobile-actions
- Purpose — the
lg:hiddensticky 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 acceptsrenderProductto swap it for a custom card. - SDK calls — none; expects a
StoreProductfetched 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/searchlistRelatedProducts(product.id)— manual admin picks first, deterministic fallback fills tolimit(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/productsretrieveProduct(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 (suspendedProductActionsWrapper), 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
ProductLabelsProviderfor i18n;addToCart/openCartseams as onProductActions.
Family: store (@barter/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
pagequery 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
sortByquery param (created_at|price_asc|price_desc, rendered fromsortOptionLabelKeys). - Props —
sortByOPTIONAL (port adaptation): on collection pages no selection = the collection's admindefault_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/productslistProducts:created_at→ serverorder:"-created_at"+ real offset pagination;price_asc/price_desc→ 100-item window fetch,lib/sort-productsre-sort, slice (the proven production approach — see module JSDoc for the >100 caveat). - Props note —
collectionIdfilters by PRIMARY collection (products.collection_id); the membership join lives inCollectionTemplate. - 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
/storeall-products page: sort sidebar + heading + suspendedPaginatedProducts. - SDK calls — via
PaginatedProducts. Pass the page'ssortBy/pagequery 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/collectionslistCollectionProducts(collection.id)— the admindefault_sortis honored SERVER-side whensortByis unset (no client re-sort, port adaptation); a shopper override mapscreated_at→newest,price_asc,price_descto theorderparam (price sorting server-side here, unlike the plain listing). Fetch the collection itself vialistCollections({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,
PaginatedProductsfiltered bycategory_id. - SDK calls — via
PaginatedProducts. Fetch the category in the page withretrieveCategory(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/searchsearchProducts(only whenqpresent). Facet buckets toggle by rewriting the wire-named query params (collection_id/type_id/tag_idCSV,price_min/price_max,availability,option.<Title>CSV) via the purestore/search-paramshelpers (round-trip unit-tested); every toggle resetspage. - 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;basePathdefaults 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 (@barter/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/retrieveOrderByDisplayIddetail, or thecompleteCart()response),totals(the decorated cart from checkout, ororderTotalsFromSummary(completeCart().order.summary)), optional normalizeditems(preferdisplayItemFromCartLineright after checkout — keeps server-computed per-line discounts),shippingMethod/paymentProviderId/cardLast4from 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 andcompleteCart().orderboth satisfy it.localedrivestoLocaleDateString.
<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 convertersdisplayItemFromOrderItem(pivot)(order path:unit_price × quantity) /displayItemFromCartLine(line)(cart path: servertotal/original_total). Legacy COD-fee line items (metadata.is_cod_fee=true) are hidden here and surfaced inOrderTotalsinstead (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 bycreatedAtwhen present.
<OrderTotals totals currencyCode items? codFeeLabel? /> — order/order-totals
- Purpose — the money breakdown: Subtotal / Shipping (FREE badge at
- / COD fee / Discount (negated) / Tax / Total, all via
DualPrice.
- / COD fee / Discount (negated) / Tax / Total, all via
- 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 viaorderTotalsFromSummary. Row policy is the pure, unit-testedselectOrderTotalsRows: nativecod_fee_totalwins over a legacy fee LINE; a legacy fee line's net is subtracted from the visible subtotal (v2.3.1 production fix); COD label preferencecodFeeLabelprop → servercod_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.fulfillmentsfrom the detail;shippingMethod(structural,CartShippingMethodfits) from checkout state since the Cartbase order read has no shipping-method embed. Tracking rows come from the pure, unit-testedpickTrackingLabels(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;
resolvePaymentTitlebuckets the provider id vialib/payment-constants(pp_stripe→ card,pp_system_default→ COD) through the locale pack, falling back topaymentInfoMapthen 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_statuscolumn on the store surface — derive it with the pure, unit-testedderiveFulfillmentStatus(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 (@barter/storefront/common/*) — SHIPPED
Shared storefront chrome, production-proven.
<LocalizedLink href … /> — common/localized-link
next/linkthat persists the URL locale/country segment when the route has one ([countryCode]param by default,paramNameoverridable); 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 decoratedCart; badge count =productItemCount(cart.items)(fee-line-aware, consistent with the drawer). - Mount rules —
CartButtonClientmust sit inside the cart-drawer family's<CartDrawerProvider>(it callsuseCartDrawer().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}toonDeleted; 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 viaonChange(usuallycarts.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
localesfromapi/regions.listLocales(client)(bare codes, store default first — SDK wins over the source's{code,name}objects); display names vialocaleDisplayName()(Intl.DisplayNamesautonym) with per-storelabelsoverrides.onChangepersists (cookie read byStorefrontClient.getLocale) inside the preserveduseTransitionpending-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 (@barter/storefront/reviews-ui) — SHIPPED
Verified-purchase review components, ported from a production storefront,
over api/reviews. ONE barrel export seam: everything imports from
@barter/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 passinitialData(recommended), else the widget fetches on mount. Sort changes / load-more:listReviews(sortParamsFormaps 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 viagetWidget().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 asvalidation— 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: nullon 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_codenull → resume at photo;reward_codeset → done showing the code; consumed with no review row → terminal panel. Submit errors map by status viasubmitErrorKeyFor(429/409/410). - Mount rules —
ReviewWizardis the full page body (client);ReviewWizardFormand<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 — passrewardPct, default 10),moderation_mode(holdlands the review pending; the thanks copy stays true either way), request-scanner settings decide when tokens are minted at all.