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