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.

  • 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 @barter/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 event helpers — tracking/fbq, tracking/gtag, tracking/rybbit-events

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

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

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

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

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

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

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

Family: primitives (@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 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 (@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/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 the Medusa nested shape as fallback. Affected by: price lists / B2B pricing context (what calculated_price contains), currency + region query context.
  • lib/get-percentage-diff — sale badge math.
  • lib/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 / isManual + paymentInfoMap. Translated to Cartbase ids: matches pp_stripe exactly (code truth src/lib/stripe/providers.ts) plus the Medusa-era prefixes; pp_system_default = COD. Affected by: enabled payment providers + checkout rules (which ids ever reach the client).
  • lib/store-api-errorstoreApiError(err): display-boundary normalizer (capitalized message + terminal period), successor of medusaError. Catch StoreApiError directly instead when branching on status/code.
  • lib/hooks/use-intersection, lib/hooks/use-toggle-state — viewport + toggle micro-hooks (client).

Family: checkout (@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 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?, codConfig?, orderConfirmedPath?, onOrderPlaced?, resolveTrackingMetadata?, logError?}. countries is caller-supplied (Cartbase regions embed NO countries array). codConfig = the integrations cod block — the COD fee prediction is never hardcoded. logError replaces the legacy log writers (all production log points preserved). Returns the full orchestration surface (performBuyClick, optimisticTotal(Cents), deliveryReady, …).
  • Cartbase specifics — zero-remainder gift path: prepareCheckout returning client_secret:null + provider_id:null SKIPS Stripe and completes on the gift session (gift-cards.md). Provider ids are pp_stripe/pp_cod/pp_manual exactly; pp_manual doubles as the offline tab when no true COD provider exists (fee predicted only for pp_cod). COD fee reads the cart-level cod_fee_total decoration.
  • Settings — checkout rules (filter the listings + complete guard), COD integration (fee), Stripe credentials, gift cards, accounts_mode.

<CheckoutClient />checkout/checkout-client

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

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

  • Purpose — deferred-intent Stripe context: PaymentWrapper publishes {stripePromise, amount, currency, appearance, fonts}; StripeElementsScope mounts <Elements mode:"payment"> where needed (passthrough renders children scope-less on COD-only stores); StripeContext boolean = "Stripe.js ready".
  • SDK calls — none (env: NEXT_PUBLIC_STRIPE_KEY, Medusa-era names kept as fallbacks).
  • Mount 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, 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 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 @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 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@barter/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), cod_fee_total (>0, labeled by the server's cod_fee_label), total, then one row per applied gift card (masked last4, negated; a depleted card stays listed at 0) and gift_card_remainder — what the remainder provider charges. Row selection is the pure selectSummaryRows(cart) (unit-tested).
  • SDK calls — none directly (context cart; every cart read re-derives gift-card tender from the live ledger).
  • Settings — COD integration (fee + label), promotions, gift cards.
  • 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 (@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 / 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 the Medusa 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 (@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 / 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 (@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 / retrieveOrderByDisplayId detail, or the completeCart() response), totals (the decorated cart from checkout, or orderTotalsFromSummary(completeCart().order.summary)), optional normalized items (prefer displayItemFromCartLine right after checkout — keeps server-computed per-line discounts), shippingMethod / paymentProviderId / cardLast4 from checkout state.
  • Mount rules — order-confirmation route (server component OK; only the timeline child is client). The Purchase tracking trio (fbq/gtag/ rybbit, deduped by order.display_id) is APP-OWNED: fire it from the confirmation route exactly once per order per the tracking family's dedupe contract — the template deliberately does NOT fire it.
  • Settings — COD settings (fee row presence + cod_fee_label), checkout rules (which provider ids appear), store locales (labels pack).

<OrderConfirmationHeader order />order/order-confirmation-header

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

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

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

<OrderTotals totals currencyCode items? codFeeLabel? />order/order-totals

  • Purpose — the money breakdown: Subtotal / Shipping (FREE badge at
    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, cod_fee_total, cod_fee_label); the summary snapshot adapts via orderTotalsFromSummary. Row policy is the pure, unit-tested selectOrderTotalsRows: native cod_fee_total wins over a legacy fee LINE; a legacy fee line's net is subtracted from the visible subtotal (v2.3.1 production fix); COD label preference codFeeLabel prop → server cod_fee_label → fee-line title → labels.codFee.
  • Settings — COD settings (cod_fee_total/cod_fee_label), promotions (discount row).

<OrderAddressCard order />order/order-address-card

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

<OrderDeliveryCard order shippingMethod? currencyCode />order/order-delivery-card

  • Purpose — pickup point (Econt office metadata / stable fulfillment-option ids econt-office, boxnow-locker — id beats name parsing, production fix) or shipping address, the method row with price/FREE, and — Cartbase addition — the fulfillment tracking labels.
  • Data 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 buckets the provider id via lib/payment-constants (pp_stripe → card, pp_system_default → COD) through the locale pack, falling back to paymentInfoMap then the raw id. Payment internals never cross the Cartbase store surface — the id arrives via props from checkout state.

<OrderTimeline fulfillmentStatus? />order/order-timeline

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

<OrderHelpSection contactHref? returnsHref? />order/order-help-section

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

Family: common (@barter/storefront/common/*) — SHIPPED

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 (@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 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.