@shoppexio/storefront-react 0.0.0 → 0.1.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. package/package.json +75 -1
@@ -0,0 +1,3087 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Which account surface a URL names.
6
+ *
7
+ * The account lives under `/dashboard`, and the panel is a PATH SEGMENT:
8
+ * `/dashboard/orders`, `/dashboard/orders/<uniqid>`, `/dashboard/settings`.
9
+ * `/dashboard` itself is the overview. This is the platform's route shape and it
10
+ * is identical on both storefront lanes (ADR-0064), so a theme that invents its
11
+ * own account paths breaks links it does not own.
12
+ *
13
+ * The older query spelling — `?tab=<tab>` and `?order=<id>` — is still ACCEPTED,
14
+ * because it sits in order emails already sent and in the platform's post-login
15
+ * redirect. It is read as input and never written: a URL carrying it is
16
+ * canonicalized to the path form, by replacement rather than a push, so the back
17
+ * button does not bounce the buyer between the two spellings. The path always
18
+ * wins when a URL carries both.
19
+ *
20
+ * `/customer-portal` is the oldest spelling of the same route and folds in with
21
+ * its tail and query intact.
22
+ *
23
+ * A locale prefix (`/de/dashboard/orders`) is part of the served URL on a
24
+ * localized storefront. The router's basename normally strips it before any of
25
+ * this sees it, so the parser accepts a pathname with or without one and
26
+ * canonicalization keeps whatever prefix it was given.
27
+ *
28
+ * Everything here is pure string work, so `tests/account-route.test.ts` can pin
29
+ * the parts email links depend on.
30
+ */
31
+ declare const ACCOUNT_PATH = "/dashboard";
32
+ /** The older spelling of the same route. Folded, never served separately. */
33
+ declare const ACCOUNT_PATH_ALIAS = "/customer-portal";
34
+ /**
35
+ * Every tab the Shoppex account surface defines, in navigation order.
36
+ *
37
+ * Listed in full so a link to a real platform tab can be told apart from a typo
38
+ * and answered differently.
39
+ */
40
+ declare const ACCOUNT_TABS: readonly ["overview", "orders", "downloads", "subscriptions", "rewards", "referrals", "favorites", "support", "settings", "reseller"];
41
+ type AccountTab = (typeof ACCOUNT_TABS)[number];
42
+ /**
43
+ * The catalog key a tab's label lives under.
44
+ *
45
+ * A key rather than a string: this file is imported by the router, the
46
+ * navigation and the "not part of this store" notice, and each of them needs
47
+ * the label in the locale the edge served — see `account/i18n.ts`.
48
+ */
49
+ declare function accountTabLabelKey(tab: AccountTab): string;
50
+ /**
51
+ * The tabs the account renders.
52
+ *
53
+ * All ten platform tabs. Adding a panel means adding its tab here AND
54
+ * rendering it, in the same change: a tab listed without a panel would navigate
55
+ * to the "not built" notice, which is worse than not offering it.
56
+ *
57
+ * Reseller payloads are locally narrowed because shared contracts do not cover
58
+ * them, but the worker now allowlists their SDK routes.
59
+ */
60
+ declare const IMPLEMENTED_ACCOUNT_TABS: readonly ["overview", "orders", "downloads", "subscriptions", "rewards", "referrals", "favorites", "support", "settings", "reseller"];
61
+ type ImplementedAccountTab = (typeof IMPLEMENTED_ACCOUNT_TABS)[number];
62
+ declare const DEFAULT_ACCOUNT_TAB: ImplementedAccountTab;
63
+ /**
64
+ * What the account page should show.
65
+ *
66
+ * `not-built` and `unknown-tab` are separate on purpose. The first is a real
67
+ * platform tab this storefront chose not to render, the second is a value that
68
+ * names no tab at all. Collapsing them into "show the overview instead" would
69
+ * hide a broken link behind a page that looks like it worked.
70
+ */
71
+ type AccountView = {
72
+ kind: 'panel';
73
+ tab: ImplementedAccountTab;
74
+ } | {
75
+ kind: 'order';
76
+ orderId: string;
77
+ } | {
78
+ kind: 'not-built';
79
+ tab: AccountTab;
80
+ } | {
81
+ kind: 'unknown-tab';
82
+ };
83
+ declare function isAccountTab(value: string): value is AccountTab;
84
+ declare function isImplementedAccountTab(value: string): value is ImplementedAccountTab;
85
+ interface AccountRouteParts {
86
+ /** What preceded `/dashboard`: '' or a locale prefix such as '/de'. */
87
+ prefix: string;
88
+ /** Whether the URL used the retired `/customer-portal` spelling. */
89
+ alias: boolean;
90
+ /** The segments after `/dashboard`, decoded. */
91
+ tail: string[];
92
+ }
93
+ /**
94
+ * Split an account URL into its prefix and the segments below `/dashboard`.
95
+ *
96
+ * Null when the path is not the account route at all. The account segment is
97
+ * only recognised first or directly after a locale segment — searching for it
98
+ * anywhere would make `/product/dashboard-pro` an account URL.
99
+ */
100
+ declare function parseAccountPath(pathname: string): AccountRouteParts | null;
101
+ /**
102
+ * What to render for a URL.
103
+ *
104
+ * The path is read first and the query only when the path names no panel, so a
105
+ * canonical URL is never reinterpreted by a stale parameter riding along on it.
106
+ */
107
+ declare function resolveAccountView(pathname: string, search: URLSearchParams): AccountView;
108
+ /** `/dashboard/<tab>`. The overview is the bare path, not `/dashboard/overview`. */
109
+ declare function accountTabHref(tab: AccountTab): string;
110
+ /** `/dashboard/orders/<id>`, the one order-detail spelling. */
111
+ declare function accountOrderHref(orderId: string): string;
112
+ /**
113
+ * The canonical path for a URL that is not written the canonical way, or null
114
+ * when it already is.
115
+ *
116
+ * Three URLs need rewriting: the `/customer-portal` alias, a `?tab=`/`?order=`
117
+ * query, and the redundant `/dashboard/overview`. The caller replaces rather
118
+ * than pushes — a push would put the retired spelling in the history and send
119
+ * the back button straight back into it.
120
+ *
121
+ * A URL naming nothing real (`?tab=orderz`, `/dashboard/nope`) has no canonical
122
+ * form and keeps the address it came with: rewriting it to the overview would
123
+ * turn a broken link into one that looks like it worked.
124
+ *
125
+ * The prefix the URL came with is preserved, so a buyer on `/de/dashboard` is
126
+ * not quietly moved out of their locale.
127
+ */
128
+ declare function canonicalAccountPath(pathname: string, search: URLSearchParams): string | null;
129
+ /**
130
+ * The storefront routes the account links to outside itself: the catalog
131
+ * behind every "browse the store" action, and a product page per favorite.
132
+ * React Router does not check a `to`, so a host that does not route one of
133
+ * these answers the account's own buttons with its not-found page. A host
134
+ * checks its router against this list (ForkMe: `tests/account-routes.test.ts`).
135
+ */
136
+ declare const ACCOUNT_STOREFRONT_ROUTES: readonly ["/all-products", "/product/:slug"];
137
+ /**
138
+ * The artifact capabilities the account relies on. A storefront that mounts
139
+ * the account lists each of them in its `public/shoppex.capabilities.json`;
140
+ * the storefront worker reads that file from the artifact and only then hands
141
+ * the account what the capability promises. `account-reseller-invite-cookie`
142
+ * is pinned to `RESELLER_INVITE_ARTIFACT_CAPABILITY` in
143
+ * `workers/storefront/src/reseller-invite-link.ts`: without it the worker
144
+ * leaves a reseller invitation in the URL for that build.
145
+ */
146
+ declare const ACCOUNT_ARTIFACT_CAPABILITIES: readonly ["account-reseller-invite-cookie"];
147
+
148
+ declare const ACCOUNT_ROUTE_MESSAGE_KEYS: readonly ["account.checkingSession", "common.notLoaded", "common.genericError", "common.retry"];
149
+ type AccountRouteMessageKey = (typeof ACCOUNT_ROUTE_MESSAGE_KEYS)[number];
150
+ declare function accountRouteMessage(key: AccountRouteMessageKey, lang?: string | null): string;
151
+
152
+ /**
153
+ * Type surface of the Shoppex Storefront SDK global (window.shoppex), loaded
154
+ * as a UMD script from cdn.shoppex.io in index.html.
155
+ *
156
+ * This declares the whole customer surface the SDK exports in addition to the
157
+ * storefront subset this theme consumes today: merchants fork this template,
158
+ * and a runtime method must not be hidden by its local types. The full API is
159
+ * documented at https://docs.shoppex.io/storefront-sdk. Keep field names in
160
+ * sync with the SDK's public response contracts (snake_case).
161
+ */
162
+ /** One addon selection on a cart line. Part of the line's identity. */
163
+ interface SdkCartAddon {
164
+ id: string;
165
+ option_id?: string;
166
+ quantity?: number;
167
+ }
168
+ interface SdkCartItem {
169
+ line_id: string;
170
+ product_id: string;
171
+ variant_id: string;
172
+ quantity: number;
173
+ price_variant_id?: string | null;
174
+ addons?: SdkCartAddon[];
175
+ custom_fields?: Record<string, string>;
176
+ price_data?: {
177
+ unit_price: number;
178
+ };
179
+ pay_what_you_want_price?: number;
180
+ title?: string;
181
+ variant_title?: string;
182
+ image_url?: string;
183
+ addon_labels?: string[];
184
+ max_quantity?: number;
185
+ min_quantity?: number;
186
+ }
187
+ interface SdkCartAppliedDiscount {
188
+ source: 'PRODUCT_DISCOUNT' | 'VOLUME_DISCOUNT' | 'AUTOMATIC_PRODUCT_DEAL' | 'BUNDLE_DEAL' | 'STORE_PROMOTION' | 'COUPON' | 'AFFILIATE';
189
+ label: string;
190
+ amount: string;
191
+ }
192
+ /**
193
+ * One cart line as the SERVER resolved it — the authoritative bounds and
194
+ * sellability for the option that line names.
195
+ *
196
+ * PAYMENT PATH, and a contract with rules that cannot be guessed from the
197
+ * shape. `min_quantity` is OMITTED when it is 1; `max_quantity` is OMITTED when
198
+ * nothing bounds the line (`-1` is never emitted, so a missing key means
199
+ * UNBOUNDED and must never be read as 0). When several cart lines name one
200
+ * product+option they draw from one shelf and the checkout sums them, so
201
+ * `max_quantity` is that line's SHARE of the option's ceiling. `sellable: false`
202
+ * means this cart cannot be ordered as it stands — the option is unorderable,
203
+ * or the cart's lines of it together exceed its ceiling — and it answers for the
204
+ * OPTION, so every line of one option carries the same value.
205
+ *
206
+ * The consumer duty is in `cart-quote-bounds.ts`: REPLACE the stored
207
+ * bounds rather than merging them, and refuse the hand-off while any line is
208
+ * unsellable.
209
+ */
210
+ interface SdkCartQuoteLine {
211
+ product_id: string;
212
+ /** The resolved option id, or null for a product with no variants. */
213
+ variant_id: string | null;
214
+ /** The quantity this row was quoted AT, after per-line summing. */
215
+ quantity: number;
216
+ /** Absent when 1. */
217
+ min_quantity?: number;
218
+ /** Absent when unbounded. Never `-1`. */
219
+ max_quantity?: number;
220
+ sellable: boolean;
221
+ }
222
+ interface SdkEligibleCartDeal {
223
+ deal_uniqid: string;
224
+ product_id: string;
225
+ label: string;
226
+ missing_quantity: number;
227
+ required_quantity: number;
228
+ }
229
+ /**
230
+ * How the backend classified an accepted cart code. The buyer types ONE field;
231
+ * the server decides whether that string is a coupon or an affiliate referral
232
+ * (`CartQuoteService`), and the two are paid out completely differently — a
233
+ * referral submitted as a coupon credits nobody.
234
+ */
235
+ type SdkCartCodeSource = 'COUPON' | 'AFFILIATE';
236
+ interface SdkCartQuote {
237
+ subtotal: string;
238
+ discount: string;
239
+ total: string;
240
+ currency: string;
241
+ coupon_applied: boolean;
242
+ /**
243
+ * Which kind of code the quote accepted; null when it accepted none. The
244
+ * theme must not classify a code itself — only the server knows which of the
245
+ * merchant's coupons and which affiliates exist.
246
+ */
247
+ coupon_source: SdkCartCodeSource | null;
248
+ applied_discounts: SdkCartAppliedDiscount[];
249
+ /**
250
+ * Per-line effective bounds and sellability — see {@link SdkCartQuoteLine}.
251
+ * Absent on a backend older than the contract; a consumer must then keep the
252
+ * add-time snapshot rather than invent bounds of its own.
253
+ */
254
+ lines?: SdkCartQuoteLine[];
255
+ /**
256
+ * TRUE when this cart's total already exceeds the shop's configured checkout
257
+ * maximum. OMITTED when the cart is within it.
258
+ *
259
+ * PAYMENT PATH, and advisory by design: the quote still prices and returns
260
+ * the cart so the storefront can warn the buyer while they are still looking
261
+ * at it, instead of letting them spend a click on a
262
+ * `cart_maximum_checkout_exceeded` rejection that explains nothing.
263
+ * `/from-cart` refuses authoritatively either way — this flag never
264
+ * AUTHORISES a checkout, it only ever stops one.
265
+ */
266
+ cart_max_exceeded?: true;
267
+ /**
268
+ * TRUE when at least one line is a PHYSICAL product. `total` then EXCLUDES
269
+ * shipping: the hosted checkout prices it from the buyer's address. Absent on
270
+ * a backend without physical products, so read it as `=== true`.
271
+ */
272
+ requires_shipping?: boolean;
273
+ /**
274
+ * The configured maximum, in this quote's currency. Present only alongside
275
+ * `cart_max_exceeded`, so it is read only when that flag is set.
276
+ */
277
+ cart_maximum_checkout?: string;
278
+ eligible_deals: SdkEligibleCartDeal[];
279
+ eligible_bundle_deals: Array<{
280
+ bundle_uniqid: string;
281
+ label: string;
282
+ /**
283
+ * The members the cart is still short of. `price` and `default_variant_id`
284
+ * are what let the teaser's Add button cart them with a real price
285
+ * snapshot — the storefront payload carries no catalog entry for a product
286
+ * the buyer has not opened, so this is the only price available here.
287
+ */
288
+ missing_products: Array<{
289
+ product_uniqid: string;
290
+ title: string;
291
+ quantity: number;
292
+ price: string;
293
+ default_variant_id: string;
294
+ }>;
295
+ }>;
296
+ }
297
+ interface SdkCartAddOptions {
298
+ price_variant_id?: string | null;
299
+ /** Identity fields — omitting one on a rebuild changes which line it is. */
300
+ addons?: SdkCartAddon[];
301
+ custom_fields?: Record<string, string>;
302
+ price_data?: {
303
+ unit_price: number;
304
+ };
305
+ pay_what_you_want_price?: number;
306
+ /** Display snapshot fields shown in the cart without a product lookup. */
307
+ title?: string;
308
+ variant_title?: string;
309
+ image_url?: string;
310
+ addon_labels?: string[];
311
+ max_quantity?: number;
312
+ min_quantity?: number;
313
+ replace_display_snapshot?: boolean;
314
+ }
315
+ interface SdkPage {
316
+ id: string;
317
+ slug: string;
318
+ name: string;
319
+ /**
320
+ * The merchant's body as MARKDOWN, not HTML — the dashboard's Pages editor
321
+ * stores it that way and every lane renders it through a markdown pass
322
+ * (`renderCmsPageMarkdownHtml` on the Easy lane). Null when the page has no
323
+ * body yet.
324
+ */
325
+ content: string | null;
326
+ /** 'faq' switches the page to the accordion; anything else renders the body. */
327
+ template_type?: string;
328
+ is_system?: boolean;
329
+ /** Template-specific payload. `faq_items` lives here for `template_type: 'faq'`. */
330
+ settings?: Record<string, unknown> | null;
331
+ }
332
+ interface SdkMenuItem {
333
+ id: string;
334
+ title: string;
335
+ /** Absent on a PAGE item — `page_slug` carries the destination instead. */
336
+ url?: string | null;
337
+ /**
338
+ * Set when the item points at one of the merchant's CMS pages. It wins over
339
+ * `url`, and resolves to `/page/<slug>` in the same tab.
340
+ */
341
+ page_slug?: string | null;
342
+ link_type?: string | null;
343
+ target?: string | null;
344
+ sort_order?: number;
345
+ }
346
+ interface SdkMenu {
347
+ id: string;
348
+ title: string;
349
+ /** 'header' | 'footer' | custom slot name. */
350
+ slot?: string | null;
351
+ item_count?: number | null;
352
+ items?: SdkMenuItem[];
353
+ }
354
+ interface SdkCheckoutResult {
355
+ success: boolean;
356
+ message?: string;
357
+ /**
358
+ * The backend's machine-readable refusal reason, present only on failures
359
+ * that name one. It is the server's FULL i18n key —
360
+ * `errors.checkout.affiliate_code_not_applicable`,
361
+ * `errors.checkout.coupon_no_longer_valid`,
362
+ * `errors.checkout.price_increased_since_quote`, … — because `from-cart`
363
+ * sends exactly that as `error_code` and the SDK copies it verbatim. The
364
+ * short reason names in the server's own code never cross the wire.
365
+ *
366
+ * The quote-proof family — `errors.checkout.quote_token_invalid`,
367
+ * `…_expired`, `…_stale`, and `errors.checkout.price_increased_since_quote`
368
+ * — means the proof this hand-off submitted could not be honoured (not ours,
369
+ * past its window, priced for a cart that has since moved, or the ceiling
370
+ * refusing an invoice that would price ABOVE the approved total). They are
371
+ * all cleared the same way: re-quote and let the buyer approve the current
372
+ * total before retrying.
373
+ *
374
+ * `message` is already localized prose meant for the buyer; it is NOT a
375
+ * branchable value — matching on its text breaks the moment the copy or the
376
+ * locale changes. Branch on this instead, and only on this.
377
+ */
378
+ code?: string;
379
+ redirectUrl?: string;
380
+ invoiceId?: string;
381
+ }
382
+ interface SdkRecentSaleEntry {
383
+ product_title: string;
384
+ seconds_ago: number;
385
+ }
386
+ interface SdkInitOptions {
387
+ shopId?: string;
388
+ apiBaseUrl?: string;
389
+ checkoutBaseUrl?: string;
390
+ currency?: string;
391
+ locale?: string;
392
+ }
393
+ /** The envelope every customer-account call answers with. */
394
+ interface SdkResponse<T> {
395
+ success: boolean;
396
+ data?: T;
397
+ /** Buyer-safe sentence from the server. Display copy, never a branch value. */
398
+ message?: string;
399
+ /**
400
+ * The TRANSPORT status, absent when the request never reached a server.
401
+ *
402
+ * This is the field that separates "the buyer is signed out" (401) from "the
403
+ * API is broken" (5xx) or "the request never left the browser" (undefined).
404
+ * Branch on it before deciding a buyer has no session: signing someone out
405
+ * because an endpoint returned 500 throws away a session that is still valid.
406
+ */
407
+ status?: number;
408
+ /** Machine-readable refusal identifier, when the server emitted one. */
409
+ code?: string;
410
+ errorParams?: Record<string, unknown>;
411
+ }
412
+ /** One order row inside the dashboard payload. snake_case, unlike the list. */
413
+ interface SdkCustomerDashboardOrder {
414
+ uniqid: string;
415
+ status: string;
416
+ /**
417
+ * USD-normalized total. `currency` is the BUYER display currency, so this
418
+ * number and that currency are not a pair — see `total_display`.
419
+ */
420
+ total: string;
421
+ /** The total in `currency`. Null when the invoice has no display amount. */
422
+ total_display: string | null;
423
+ currency: string;
424
+ created_at: string;
425
+ line_items: Array<{
426
+ product_title: string | null;
427
+ quantity: number;
428
+ }>;
429
+ }
430
+ /**
431
+ * One row of the buyer's purchased licenses.
432
+ *
433
+ * `expires_at` is EPOCH SECONDS, not milliseconds — the producer emits
434
+ * `EXTRACT(EPOCH FROM ...)::bigint` — and `null` means the license has no end
435
+ * date at all, which is a different thing from an expiry the client failed to
436
+ * read. `invoice_id` carries the ORDER's `uniqid`, so it addresses the same
437
+ * order-detail route as an invoice row.
438
+ *
439
+ * `renewable` is decided server-side: only the backend can see whether the
440
+ * product still exists, still sells and still licenses. A storefront must never
441
+ * derive it.
442
+ */
443
+ interface SdkCustomerLicense {
444
+ uniqid: string;
445
+ invoice_id: string;
446
+ status: string;
447
+ hardware_id: string | null;
448
+ expires_at: number | null;
449
+ renewable: boolean;
450
+ }
451
+ /**
452
+ * One thing the shop has delivered: a license key, a file, or a code/link.
453
+ *
454
+ * A `license` deliverable's `id` is `license:<license uniqid>` and that is the
455
+ * only join between this list and {@link SdkCustomerLicense} — the license row
456
+ * carries the expiry, the hardware lock and the renewal flag, the deliverable
457
+ * carries the key itself.
458
+ *
459
+ * `href` on a `file` is a same-origin `/api/customer/invoice/:uniqid/download`
460
+ * path. The session cookie authorizes it, so it is followed as a plain link;
461
+ * nothing here attaches a token.
462
+ */
463
+ interface SdkCustomerDeliverable {
464
+ id: string;
465
+ invoice_uniqid: string;
466
+ product_title: string;
467
+ kind: 'file' | 'serial' | 'license';
468
+ label: string;
469
+ value: string;
470
+ href: string | null;
471
+ created_at: string;
472
+ }
473
+ /**
474
+ * One subscription, with the actions the SERVER says are possible on it.
475
+ *
476
+ * `actions` is not advice: pause and resume are Stripe-only and status-bound,
477
+ * and a storefront that renders a control the flag denies is offering a button
478
+ * the backend will refuse. There is deliberately no payment-method action here
479
+ * — that endpoint hands out a signed redirect into the provider's portal and is
480
+ * not open to storefront code (ADR-0064).
481
+ */
482
+ interface SdkCustomerSubscription {
483
+ uniqid: string;
484
+ status: string;
485
+ gateway: string;
486
+ current_period_end: string;
487
+ trial_end: string | null;
488
+ cancel_at_period_end: boolean;
489
+ canceled_at: string | null;
490
+ created_at: string;
491
+ plan: {
492
+ name: string;
493
+ price: string;
494
+ currency: string;
495
+ /** 'day' | 'week' | 'month' | 'year' as the gateway spells it. */
496
+ interval: string;
497
+ interval_count: number;
498
+ } | null;
499
+ payment_method: {
500
+ type: string;
501
+ card_brand: string | null;
502
+ card_last4: string | null;
503
+ card_exp_month: number | null;
504
+ card_exp_year: number | null;
505
+ } | null;
506
+ actions: {
507
+ can_pause: boolean;
508
+ can_resume: boolean;
509
+ can_cancel: boolean;
510
+ can_manage_payment_method: boolean;
511
+ };
512
+ }
513
+ /** One earned reward. `created_at` and `expires_at` are epoch SECONDS. */
514
+ interface SdkCustomerRewardActivity {
515
+ id: string;
516
+ type: string;
517
+ status: string;
518
+ amount: string | null;
519
+ currency: string | null;
520
+ coupon_code: string | null;
521
+ reason: string;
522
+ created_at: number;
523
+ expires_at: number | null;
524
+ }
525
+ interface SdkCustomerRewards {
526
+ summary: {
527
+ available: string;
528
+ pending: string;
529
+ lifetime_earned: string;
530
+ currency: string;
531
+ };
532
+ activity: SdkCustomerRewardActivity[];
533
+ }
534
+ interface SdkCustomerAffiliateLink {
535
+ id: string;
536
+ label: string | null;
537
+ code: string;
538
+ url: string | null;
539
+ status: 'pending' | 'active' | 'inactive' | 'rejected';
540
+ is_primary: boolean;
541
+ total_clicks: number;
542
+ total_sales: number;
543
+ total_revenue: string;
544
+ effective_commission_percent: number;
545
+ effective_customer_discount_percent: number;
546
+ }
547
+ /**
548
+ * The buyer's standing in the shop's referral programme.
549
+ *
550
+ * Read-only on this lane. Converting a balance and requesting a payout move
551
+ * money toward the buyer and are not open to storefront code (ADR-0064, "money
552
+ * paths do not belong on the account API"), so this template renders what the
553
+ * buyer has earned and says where the payout lives.
554
+ */
555
+ interface SdkCustomerAffiliateSummary {
556
+ /** `not_enrolled` is the availability gate for the whole referrals panel. */
557
+ state: string;
558
+ affiliate_codes_accepted_at_checkout: boolean;
559
+ balance_currency: string;
560
+ balances: {
561
+ pending: string;
562
+ available: string;
563
+ requested: string;
564
+ converted: string;
565
+ paid: string;
566
+ };
567
+ links: SdkCustomerAffiliateLink[];
568
+ commissions: Array<{
569
+ id: string;
570
+ invoice_id: string;
571
+ commission_amount: string;
572
+ status: string;
573
+ created_at: string | null;
574
+ }>;
575
+ claims: Array<{
576
+ id: string;
577
+ type: string;
578
+ status: string;
579
+ amount: string;
580
+ currency: string;
581
+ created_at: string | null;
582
+ }>;
583
+ }
584
+ /** One support ticket as the dashboard lists it. The thread is a second call. */
585
+ interface SdkCustomerTicketSummary {
586
+ uniqid: string;
587
+ title: string;
588
+ status: string;
589
+ updated_at: string;
590
+ created_at: string | null;
591
+ }
592
+ type SdkCustomerLoyaltyTransactionType = 'EARN_ORDER' | 'EARN_REVIEW' | 'REDEEM' | 'ADJUST';
593
+ interface SdkCustomerLoyalty {
594
+ enabled: boolean;
595
+ account: {
596
+ points_balance: number;
597
+ lifetime_points: number;
598
+ } | null;
599
+ settings: {
600
+ earn_rate_per_usd: number;
601
+ redeem_value_per_point: number;
602
+ min_redeem_points: number;
603
+ } | null;
604
+ transactions: Array<{
605
+ type: SdkCustomerLoyaltyTransactionType;
606
+ points: number;
607
+ balance_after: number;
608
+ description: string | null;
609
+ created_at: string;
610
+ }>;
611
+ }
612
+ interface SdkCustomerLoyaltyRedeem {
613
+ redeemed_points: number;
614
+ wallet_amount: string;
615
+ currency: string;
616
+ points_balance: number;
617
+ replayed: boolean;
618
+ }
619
+ type SdkCustomerWarrantyStatus = 'ACTIVE' | 'EXPIRED' | 'VOIDED';
620
+ type SdkCustomerWarrantyClaimStatus = 'REQUESTED' | 'APPROVED' | 'DENIED';
621
+ interface SdkCustomerWarranty {
622
+ uniqid: string;
623
+ invoice_uniqid: string;
624
+ product_title: string;
625
+ period_days: number;
626
+ starts_at: string;
627
+ expires_at: string;
628
+ status: SdkCustomerWarrantyStatus;
629
+ claim_status: SdkCustomerWarrantyClaimStatus | null;
630
+ created_at: string;
631
+ }
632
+ interface SdkCustomerWarrantyList {
633
+ warranties: SdkCustomerWarranty[];
634
+ }
635
+ interface SdkCustomerWarrantyClaim {
636
+ warranty: Omit<SdkCustomerWarranty, 'claim_status'> & {
637
+ claim_status: 'REQUESTED';
638
+ };
639
+ }
640
+ interface SdkCustomerDashboard {
641
+ shop: {
642
+ name: string;
643
+ currency: string;
644
+ };
645
+ customer: {
646
+ email: string;
647
+ name: string;
648
+ };
649
+ stats: {
650
+ invoices_count: number;
651
+ licenses_count: number;
652
+ deliverables_count: number;
653
+ tickets_count: number;
654
+ };
655
+ /**
656
+ * Null when the shop runs no wallet. That null IS the availability gate: a
657
+ * storefront must hide the wallet section rather than render a zero balance.
658
+ */
659
+ wallet: {
660
+ available: string;
661
+ total: string;
662
+ hold: string;
663
+ currency: string;
664
+ enabled: boolean;
665
+ } | null;
666
+ invoices: SdkCustomerDashboardOrder[];
667
+ rewards: SdkCustomerRewards;
668
+ affiliate: SdkCustomerAffiliateSummary;
669
+ subscriptions: SdkCustomerSubscription[];
670
+ licenses: SdkCustomerLicense[];
671
+ deliverables: SdkCustomerDeliverable[];
672
+ tickets: SdkCustomerTicketSummary[];
673
+ }
674
+ /** One order row in the paginated list. camelCase, unlike the dashboard. */
675
+ interface SdkCustomerOrderSummary {
676
+ uniqid: string;
677
+ status: string;
678
+ /** USD-normalized, like the dashboard's `total`. Render `totalDisplay` first. */
679
+ total: string;
680
+ totalDisplay: string | null;
681
+ currency: string;
682
+ createdAt: string;
683
+ lineItems: Array<{
684
+ productTitle: string | null;
685
+ quantity: number;
686
+ }>;
687
+ }
688
+ /**
689
+ * Page-based, not cursor-based. The producer reads `page` and `limit` and
690
+ * answers with `has_more`; a cursor parameter is ignored upstream and returns
691
+ * page one forever.
692
+ */
693
+ interface SdkCustomerOrderPage {
694
+ invoices: SdkCustomerOrderSummary[];
695
+ pagination: {
696
+ page: number;
697
+ limit: number;
698
+ total: number;
699
+ has_more: boolean;
700
+ };
701
+ }
702
+ /** Per-line fulfillment state, decided server-side and independent of payment. */
703
+ type SdkCustomerDeliveryStatus = 'PENDING' | 'AWAITING_FULFILLMENT' | 'DELIVERED' | 'FAILED';
704
+ /** What the shop delivered for one line. Null while nothing was delivered. */
705
+ interface SdkCustomerDeliverySummary {
706
+ codes: string[];
707
+ links: Array<{
708
+ label: string;
709
+ value: string;
710
+ href?: string | null;
711
+ }>;
712
+ notes: string[];
713
+ serials: Array<{
714
+ value: string;
715
+ is_replacement: boolean;
716
+ is_replaced: boolean;
717
+ }>;
718
+ }
719
+ interface SdkCustomerOrderDetail {
720
+ uniqid: string;
721
+ status: string;
722
+ statusDetails: string | null;
723
+ total: string;
724
+ totalDisplay: string | null;
725
+ subtotal: string;
726
+ discount: string;
727
+ discountDisplay: string | null;
728
+ tax: string;
729
+ taxPercentage: string;
730
+ currency: string;
731
+ createdAt: string;
732
+ lineItems: Array<{
733
+ id: string;
734
+ productTitle: string;
735
+ variantTitle: string | null;
736
+ unitPrice: string;
737
+ quantity: number;
738
+ total: string;
739
+ deliveryText: string | null;
740
+ deliveryStatus: SdkCustomerDeliveryStatus;
741
+ deliverySummary: SdkCustomerDeliverySummary | null;
742
+ }>;
743
+ currentPayment: {
744
+ /** Internal rail — NATIVE_CRYPTO, NOWPAYMENTS, STRIPE… Not buyer-facing. */
745
+ gateway: string;
746
+ displayName: string | null;
747
+ /** Concrete coin, e.g. USDT_TRC20. Set for crypto rails. */
748
+ cryptoGateway: string | null;
749
+ blockchain: string | null;
750
+ /** Alternative method the buyer chose, e.g. 'Card', 'iDEAL'. */
751
+ apmMethod: string | null;
752
+ } | null;
753
+ }
754
+ interface ShoppexSdk {
755
+ init(storeSlug: string, options?: SdkInitOptions): unknown;
756
+ isInitialized(): boolean;
757
+ getConfig(): {
758
+ storeSlug: string;
759
+ };
760
+ getCart(): SdkCartItem[];
761
+ getCartItemCount(): number;
762
+ addToCart(productId: string, variantId: string, quantity?: number, options?: SdkCartAddOptions): void;
763
+ /**
764
+ * Patches a cart line. The second argument is an update object — passing a
765
+ * bare number silently does nothing, because the SDK reads named keys off it.
766
+ *
767
+ * `custom_fields` is part of the line's IDENTITY, so writing it makes the SDK
768
+ * recompute `line_id` and merge the line into any existing line that now has
769
+ * the same identity. A caller holding a list of line ids has to tolerate one
770
+ * of them no longer existing after a write — and, because the merge SUMS the
771
+ * two lines' quantities, has to treat it as a repricing: quantities are bound
772
+ * into the signed quote proof, `custom_fields` is not. See
773
+ * `checkoutFieldWriteRepriced` in `checkout-fields.ts`.
774
+ *
775
+ * `min_quantity` and `max_quantity` are TRI-STATE: an absent key leaves the
776
+ * stored bound alone, a number sets it, and `null` DELETES it. The null
777
+ * spelling is what a healed line needs — an `undefined` reads as "not
778
+ * mentioned", so a ceiling the merchant removed would survive every heal that
779
+ * tried to remove it. The SDK re-clamps the line's quantity after the write.
780
+ */
781
+ updateCartItem(lineId: string, updates: {
782
+ quantity?: number;
783
+ custom_fields?: Record<string, string>;
784
+ min_quantity?: number | null;
785
+ max_quantity?: number | null;
786
+ }): void;
787
+ removeFromCart(lineId: string): void;
788
+ clearCart(): void;
789
+ /**
790
+ * `currency` is not optional in practice: omitted, the SDK reads `?currency=`
791
+ * off window.location, which a client-side navigation has already dropped.
792
+ */
793
+ quoteCart(coupon?: string, currency?: string): Promise<{
794
+ success: boolean;
795
+ data?: SdkCartQuote;
796
+ message?: string;
797
+ }>;
798
+ getCartCoupon(): string | null;
799
+ /** The stored code's classification, or null when nothing is stored. */
800
+ getCartCouponSource(): SdkCartCodeSource | null;
801
+ /**
802
+ * Stores a code AND what kind of code it is. The source defaults to `'COUPON'`
803
+ * in the SDK, so an affiliate referral saved without it is later submitted as
804
+ * a coupon and the affiliate is never credited — pass the source the QUOTE
805
+ * reported, never a guess.
806
+ */
807
+ setCartCoupon(coupon: string, source?: SdkCartCodeSource): void;
808
+ clearCartCoupon(): void;
809
+ /**
810
+ * Reads `?ref=<code>` from the current URL, validates it and stores it for 30
811
+ * days (last-click). `checkout()` then sends the stored code on its own, so
812
+ * calling this once at boot is all a storefront has to do for referrals to
813
+ * pay out.
814
+ */
815
+ captureAffiliateFromUrl(param?: string): Promise<string | null>;
816
+ getAffiliateCode(): string | null;
817
+ /**
818
+ * Forgets the stored ambient referral. The storage key belongs to the SDK —
819
+ * this is the only supported way to drop the attribution, and the exit a
820
+ * storefront needs when the backend refuses the code the buyer is carrying.
821
+ */
822
+ clearAffiliateCode(): void;
823
+ /** Merchant-authored custom pages (About, Delivery info, …). */
824
+ getPages(): Promise<{
825
+ success: boolean;
826
+ data?: SdkPage[];
827
+ message?: string;
828
+ }>;
829
+ getPage(slug: string): Promise<{
830
+ success: boolean;
831
+ data?: SdkPage;
832
+ message?: string;
833
+ }>;
834
+ /** Merchant-configured navigation menus, keyed by slot (header/footer/…). */
835
+ getMenus(): Promise<{
836
+ success: boolean;
837
+ data?: SdkMenu[];
838
+ message?: string;
839
+ }>;
840
+ checkout(options?: {
841
+ email?: string;
842
+ /**
843
+ * Submitted as `coupon`. An OMITTED value is not "no coupon": the SDK then
844
+ * falls back to whatever is in cart storage (`resolveCheckoutCodes`), so
845
+ * suppressing a stored code takes an explicit empty string.
846
+ */
847
+ coupon?: string;
848
+ /**
849
+ * Submitted as `affiliate_code` — the field the payout is attributed on.
850
+ * A referral sent under `coupon` instead credits nobody.
851
+ *
852
+ * A tri-state, exactly like `coupon` (`normalizeAffiliateCode`):
853
+ * - OMITTED — the SDK reads the ambient `?ref=` code out of its own storage
854
+ * at submit time, a SECOND read of storage the caller already looked at.
855
+ * - `''` — explicitly NONE: no referral is submitted and the storage read
856
+ * is suppressed. This is how a caller pins "quoted without a referral".
857
+ * - non-empty — pinned: submitted verbatim.
858
+ *
859
+ * So the quote and the invoice only agree if the code is NAMED here, in
860
+ * every lane including the empty one.
861
+ */
862
+ affiliateCode?: string;
863
+ autoRedirect?: boolean;
864
+ /** Same as quoteCart: pass it, or the cart and the page disagree. */
865
+ currency?: string;
866
+ }): Promise<SdkCheckoutResult>;
867
+ getStorefrontRecentSales(): Promise<{
868
+ success: boolean;
869
+ data?: {
870
+ items: SdkRecentSaleEntry[];
871
+ window_hours: number;
872
+ };
873
+ }>;
874
+ /**
875
+ * Visitors active on this storefront in the last 10 minutes. A real count of
876
+ * real sessions — the header pill hides itself at 0 rather than inventing a
877
+ * livelier shop than the merchant has.
878
+ */
879
+ getStorefrontOnlineUsers(): Promise<{
880
+ success: boolean;
881
+ data?: {
882
+ count: number;
883
+ window_seconds: number;
884
+ };
885
+ }>;
886
+ /**
887
+ * Shop-level feedback/reviews (Shoppex has shop feedback, not per-product
888
+ * reviews). Returns [] when the shop has none — the caller must hide the
889
+ * reviews UI rather than render an empty block or fake entries.
890
+ */
891
+ getShopReviews(): Promise<{
892
+ success: boolean;
893
+ data?: SdkShopReview[];
894
+ }>;
895
+ /** Starts an in-page sign-in. Sends the email and nothing else. */
896
+ requestOtp(email: string): Promise<SdkResponse<unknown>>;
897
+ /**
898
+ * Finishes sign-in. The second argument is submitted as `otp`; the producer
899
+ * rejects a body that names it `code` with a 400.
900
+ *
901
+ * On success the WORKER sets the session cookie from the response it saw.
902
+ * Nothing here returns a token, and no storefront code may store one.
903
+ */
904
+ verifyOtp(email: string, otp: string): Promise<SdkResponse<unknown>>;
905
+ /** Ends the session. The worker clears the cookie on the way back. */
906
+ logout(): Promise<SdkResponse<unknown>>;
907
+ /**
908
+ * The signed-in customer, or a failure when there is no session.
909
+ *
910
+ * Only a **401** means "not signed in". Any other failure — 403, 5xx, a
911
+ * contract mismatch, a network error with no `status` at all — means the
912
+ * answer is unknown, and a storefront that renders the sign-in form there
913
+ * tells a buyer with a live session that they are signed out. Show an error
914
+ * and let them retry instead.
915
+ *
916
+ * Never treat a failure as a signed-in customer either: that renders an
917
+ * account shell around data nobody fetched.
918
+ */
919
+ me(): Promise<SdkResponse<unknown>>;
920
+ /**
921
+ * The overview payload — and the collection route for five other panels.
922
+ *
923
+ * Shop, customer, counters, the wallet gate, recent orders, and the lists
924
+ * that have no route of their own: licenses, deliverables, subscriptions,
925
+ * tickets, rewards and the affiliate summary.
926
+ */
927
+ dashboard(): Promise<SdkResponse<SdkCustomerDashboard>>;
928
+ /** Order history, page-based. */
929
+ /**
930
+ * `status` and `search` filter SERVER-side. The list is paged, so a filter
931
+ * applied to the page in hand would filter only the rows it happens to hold.
932
+ */
933
+ orders(options?: {
934
+ page?: number;
935
+ limit?: number;
936
+ status?: string;
937
+ search?: string;
938
+ }): Promise<SdkResponse<SdkCustomerOrderPage>>;
939
+ /** One order, by the `uniqid` the list rows carry. */
940
+ order(id: string): Promise<SdkResponse<SdkCustomerOrderDetail>>;
941
+ /** Loyalty settings, balance and the latest points activity. */
942
+ loyalty(): Promise<SdkResponse<SdkCustomerLoyalty>>;
943
+ /** Exchanges points for store credit. Keep one idempotency key per retryable submission. */
944
+ redeemLoyaltyPoints(input: {
945
+ points: number;
946
+ idempotencyKey: string;
947
+ }): Promise<SdkResponse<SdkCustomerLoyaltyRedeem>>;
948
+ /**
949
+ * The warranties this buyer holds. Given an order's `uniqid`, only that
950
+ * order's — the route filters on `invoices.uniqid`, which is the same id.
951
+ */
952
+ warranties(invoiceUniqid?: string): Promise<SdkResponse<SdkCustomerWarrantyList>>;
953
+ claimWarranty(uniqid: string, message?: string): Promise<SdkResponse<SdkCustomerWarrantyClaim>>;
954
+ /** Clears a license's hardware lock so the next activation claims it. */
955
+ resetLicenseHwid(uniqid: string): Promise<SdkResponse<unknown>>;
956
+ /** Renewal invoices for one subscription. */
957
+ subscriptionBillingHistory(uniqid: string): Promise<SdkResponse<unknown>>;
958
+ /**
959
+ * Ends a subscription. `cancel_at_period_end` decides whether the buyer keeps
960
+ * the time they already paid for — PayPal cannot, every other gateway can.
961
+ */
962
+ cancelSubscription(uniqid: string, options?: {
963
+ cancel_at_period_end?: boolean;
964
+ reason?: string | null;
965
+ }): Promise<SdkResponse<unknown>>;
966
+ pauseSubscription(uniqid: string): Promise<SdkResponse<unknown>>;
967
+ resumeSubscription(uniqid: string): Promise<SdkResponse<unknown>>;
968
+ /** The buyer's saved products. */
969
+ favorites(): Promise<SdkResponse<unknown>>;
970
+ /** Idempotent upstream, and a PUT — `customer-favorites.ts` has no POST route. */
971
+ addFavorite(productUniqid: string): Promise<SdkResponse<unknown>>;
972
+ removeFavorite(productUniqid: string): Promise<SdkResponse<unknown>>;
973
+ /**
974
+ * The affiliate summary as its own read. The Referrals panel takes it from
975
+ * `dashboard()` instead, but a merchant extending this template should not
976
+ * have to discover that the runtime has a method the types deny.
977
+ */
978
+ affiliate(): Promise<SdkResponse<unknown>>;
979
+ /** Opens a ticket. `title` is optional; the producer names it "Support Request". */
980
+ createTicket(payload: {
981
+ title?: string;
982
+ message: string;
983
+ invoice_id?: string;
984
+ }): Promise<SdkResponse<unknown>>;
985
+ /** One ticket with its whole message thread. */
986
+ ticket(uniqid: string): Promise<SdkResponse<unknown>>;
987
+ replyToTicket(uniqid: string, message: string): Promise<SdkResponse<unknown>>;
988
+ /**
989
+ * Renames the buyer. The email is NOT editable here: changing it is gated
990
+ * behind a fresh code in the backend and has no allowlisted path.
991
+ */
992
+ updateProfile(patch: {
993
+ name: string;
994
+ }): Promise<SdkResponse<unknown>>;
995
+ updateAvatar(file: File): Promise<SdkResponse<unknown>>;
996
+ removeAvatar(): Promise<SdkResponse<unknown>>;
997
+ /** Marketing consent: the shop's lists plus the global unsubscribe. */
998
+ emailPreferences(): Promise<SdkResponse<unknown>>;
999
+ updateEmailPreferences(patch: {
1000
+ global_unsubscribed?: boolean;
1001
+ list_subscriptions?: Array<{
1002
+ list_id: string;
1003
+ subscribed: boolean;
1004
+ }>;
1005
+ }): Promise<SdkResponse<unknown>>;
1006
+ /** Signed-in devices. Never returns a token value, only a description. */
1007
+ sessions(): Promise<SdkResponse<unknown>>;
1008
+ revokeSession(id: string): Promise<SdkResponse<unknown>>;
1009
+ /** Ends every session except the one making the call. */
1010
+ revokeAllSessions(): Promise<SdkResponse<unknown>>;
1011
+ /** Program configuration and this buyer's reseller relationship, if any. */
1012
+ reseller(): Promise<SdkResponse<unknown>>;
1013
+ /** APPLICATION mode: submits the producer's optional/required note. */
1014
+ applyForReseller(note?: string): Promise<SdkResponse<unknown>>;
1015
+ /** OPEN mode: enrolls the signed-in buyer immediately. */
1016
+ enrollAsReseller(): Promise<SdkResponse<unknown>>;
1017
+ /** MANUAL mode: redeems the merchant-issued invitation token. */
1018
+ acceptResellerInvite(token: string): Promise<SdkResponse<unknown>>;
1019
+ /** Wholesale catalog at this buyer's tier prices. */
1020
+ resellerCatalog(options?: {
1021
+ search?: string;
1022
+ page?: number;
1023
+ per_page?: number;
1024
+ }): Promise<SdkResponse<unknown>>;
1025
+ /** Server-prices a basket, including volume discounts, without writing. */
1026
+ quoteResellerOrder(items: Array<{
1027
+ product_id: string;
1028
+ variant_id?: string;
1029
+ quantity: number;
1030
+ }>): Promise<SdkResponse<unknown>>;
1031
+ /** Page-based wholesale order history. */
1032
+ resellerOrders(options?: {
1033
+ page?: number;
1034
+ per_page?: number;
1035
+ }): Promise<SdkResponse<unknown>>;
1036
+ /** One wholesale order, including delivery state and serials. */
1037
+ resellerOrder(uniqid: string): Promise<SdkResponse<unknown>>;
1038
+ /** Wholesale prepaid balance, separate from the retail wallet panel. */
1039
+ resellerWallet(): Promise<SdkResponse<unknown>>;
1040
+ /** Lists the buyer's reseller API keys without plaintext secrets. */
1041
+ resellerApiKeys(): Promise<SdkResponse<unknown>>;
1042
+ }
1043
+ interface SdkShopReview {
1044
+ id: string;
1045
+ /** 1-5, normalized by the SDK from the shop-feedback score (5/3/1). */
1046
+ rating: number;
1047
+ /** Buyer names are anonymized by the API to "Customer Hidden". */
1048
+ author?: string;
1049
+ customer_name?: string;
1050
+ comment?: string;
1051
+ message?: string | null;
1052
+ product_title?: string | null;
1053
+ /** True when the review is tied to a real completed purchase. */
1054
+ verified?: boolean;
1055
+ is_automated?: boolean;
1056
+ created_at: string;
1057
+ }
1058
+ type AddonBarPreset = 'brand_blue' | 'emerald' | 'sunset' | 'rose' | 'charcoal' | 'custom';
1059
+ type StorefrontAddon = {
1060
+ id: string;
1061
+ type: 'announcement_bar';
1062
+ slot: string;
1063
+ component: 'announcement_bar';
1064
+ sort_order: number;
1065
+ props: {
1066
+ addon_id: string;
1067
+ text: string;
1068
+ link_label?: string | null;
1069
+ link_url?: string | null;
1070
+ dismissible: boolean;
1071
+ display_mode: 'static' | 'marquee';
1072
+ theme_preset: AddonBarPreset;
1073
+ animation_speed_seconds: number;
1074
+ background_color: string;
1075
+ text_color: string;
1076
+ };
1077
+ } | {
1078
+ id: string;
1079
+ type: 'countdown_bar';
1080
+ slot: string;
1081
+ component: 'countdown_bar';
1082
+ sort_order: number;
1083
+ props: {
1084
+ addon_id: string;
1085
+ text: string;
1086
+ end_at: string;
1087
+ link_label?: string | null;
1088
+ link_url?: string | null;
1089
+ dismissible: boolean;
1090
+ theme_preset: AddonBarPreset;
1091
+ expired_behavior: 'hide' | 'message';
1092
+ expired_message?: string | null;
1093
+ density: 'compact' | 'comfortable';
1094
+ cta_style: 'subtle' | 'outline' | 'solid';
1095
+ timer_style: 'minimal' | 'boxed';
1096
+ background_color: string;
1097
+ text_color: string;
1098
+ };
1099
+ } | {
1100
+ id: string;
1101
+ type: 'promo_info_card';
1102
+ slot: string;
1103
+ component: 'promo_info_card';
1104
+ sort_order: number;
1105
+ props: {
1106
+ addon_id: string;
1107
+ eyebrow?: string | null;
1108
+ title: string;
1109
+ body: string;
1110
+ link_label?: string | null;
1111
+ link_url?: string | null;
1112
+ theme_preset: 'indigo' | 'emerald' | 'amber' | 'rose' | 'slate' | 'custom';
1113
+ layout_style: 'compact' | 'feature' | 'alert';
1114
+ density: 'compact' | 'comfortable';
1115
+ cta_style: 'subtle' | 'outline' | 'solid';
1116
+ icon: 'sparkles' | 'megaphone' | 'gift' | 'truck' | 'shield' | 'support' | 'none';
1117
+ icon_visibility: 'show' | 'hide';
1118
+ background_color: string;
1119
+ text_color: string;
1120
+ accent_color: string;
1121
+ };
1122
+ } | {
1123
+ id: string;
1124
+ type: 'recent_purchase_popup';
1125
+ slot: string;
1126
+ component: 'recent_purchase_popup';
1127
+ sort_order: number;
1128
+ props: {
1129
+ addon_id: string;
1130
+ title: string;
1131
+ cooldown_seconds: number;
1132
+ items: Array<{
1133
+ customer_label: string;
1134
+ product_title: string;
1135
+ created_at: string;
1136
+ }>;
1137
+ };
1138
+ } | {
1139
+ id: string;
1140
+ type: 'coupon_popup_modal';
1141
+ slot: string;
1142
+ component: 'coupon_popup_modal';
1143
+ sort_order: number;
1144
+ props: {
1145
+ addon_id: string;
1146
+ eyebrow?: string | null;
1147
+ title: string;
1148
+ body: string;
1149
+ coupon_code: string;
1150
+ primary_button_label: string;
1151
+ secondary_button_label: string;
1152
+ disclaimer?: string | null;
1153
+ theme_preset: 'midnight' | 'ocean' | 'ember' | 'forest' | 'custom';
1154
+ trigger: 'delay' | 'exit_intent';
1155
+ delay_seconds: number;
1156
+ show_once_per_session: boolean;
1157
+ reminder_hours: number;
1158
+ background_color: string;
1159
+ text_color: string;
1160
+ accent_color: string;
1161
+ hero_image_url?: string | null;
1162
+ discount_display?: string | null;
1163
+ expires_at?: string | null;
1164
+ };
1165
+ };
1166
+ declare global {
1167
+ interface Window {
1168
+ shoppex?: ShoppexSdk;
1169
+ /** SDK bootstrap globals injected by the Shoppex storefront/preview worker. */
1170
+ shopSlug?: string;
1171
+ shopId?: string;
1172
+ apiBaseUrl?: string;
1173
+ checkoutBaseUrl?: string;
1174
+ defaultCurrency?: string;
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Merchant-authored product badges ("New!", "Limited", "Best value").
1180
+ *
1181
+ * Purely decorative merchandising the merchant attaches in the dashboard. They
1182
+ * never affect price, stock or fulfilment, and they never speak for the
1183
+ * storefront: a badge cannot say a product is available, on sale, or in stock —
1184
+ * those remain the theme's own signals, derived from real fields.
1185
+ *
1186
+ * Ported rather than imported, for the same reason `video-embed.ts` is: this
1187
+ * theme builds standalone from `templates/code-storefronts/`, in an offline
1188
+ * sandbox, with no workspace dependency on `@shoppex/builder-contracts`. The
1189
+ * canonical contract is `packages/builder-contracts/src/product-badges.ts` —
1190
+ * keep the wire field names, the caps and the contrast rule in step with it.
1191
+ *
1192
+ * Everything below reads UNTRUSTED JSON: the payload arrives over the network
1193
+ * in `window.__SHOPPEX_INITIAL__`. Entries that do not parse are dropped rather
1194
+ * than repaired — a badge with no usable label has no meaning, and inventing
1195
+ * one would be a fabricated value on a merchandising surface.
1196
+ */
1197
+ /**
1198
+ * The icon catalog the dashboard offers. A name outside this list is NOT an
1199
+ * error — the catalog is append-only upstream, so a storefront artifact built
1200
+ * before an icon was added must degrade to a text-only badge rather than throw
1201
+ * on a name it has never heard of.
1202
+ */
1203
+ declare const PRODUCT_BADGE_ICON_NAMES: readonly ["sparkles", "flame", "star", "zap", "tag", "percent", "trending-up", "megaphone", "party-popper", "crown", "gem", "trophy", "award", "medal", "badge-check", "thumbs-up", "heart", "shield", "shield-check", "lock", "handshake", "rocket", "truck", "clock", "timer", "infinity", "refresh-ccw", "download", "package", "send", "headphones", "message-circle", "life-buoy", "users", "gamepad-2", "monitor", "smartphone", "key", "file-text", "book-open", "music", "video", "image", "palette", "code", "wrench", "wallet", "credit-card", "globe", "gift", "bot"];
1204
+ type ProductBadgeIconName = (typeof PRODUCT_BADGE_ICON_NAMES)[number];
1205
+ /** One badge, in this theme's camelCase model. */
1206
+ interface ProductBadge {
1207
+ label: string;
1208
+ /** null for a text-only badge, and for any icon name this build cannot draw. */
1209
+ icon: ProductBadgeIconName | null;
1210
+ /** `#rrggbb` lowercase, or null when the theme owns the colour. */
1211
+ backgroundColor: string | null;
1212
+ /**
1213
+ * The readable foreground for `backgroundColor`, decided by WCAG contrast.
1214
+ * null exactly when `backgroundColor` is null — the theme then owns both.
1215
+ */
1216
+ foregroundColor: string | null;
1217
+ showOnProductCard: boolean;
1218
+ showOnProductPage: boolean;
1219
+ }
1220
+ /**
1221
+ * The readable foreground for a badge background.
1222
+ *
1223
+ * The merchant picks the fill from a colour well with no contrast check of its
1224
+ * own, so the label colour cannot also be theirs: a mid-yellow badge with white
1225
+ * text is unreadable and a navy badge with black text is worse. Both candidates
1226
+ * are scored by WCAG contrast ratio against the chosen fill and the higher one
1227
+ * wins, which is the same decision the other lane makes for the same badge.
1228
+ *
1229
+ * Returns null when there is no explicit background — the theme then owns both
1230
+ * colours, and substituting one here would override its palette.
1231
+ */
1232
+ declare function resolveBadgeForeground(backgroundColor: string | null): string | null;
1233
+ /**
1234
+ * The `badges` array off the product wire. Missing, malformed or empty all
1235
+ * produce `[]` — a product without badges is the overwhelmingly common case and
1236
+ * is not an error.
1237
+ */
1238
+ declare function parseProductBadges(value: unknown): ProductBadge[];
1239
+ /** Badges the catalog card shows, in the merchant's order. */
1240
+ declare function badgesForCard(badges: readonly ProductBadge[]): ProductBadge[];
1241
+ /** Badges the product page shows, in the merchant's order. */
1242
+ declare function badgesForProductPage(badges: readonly ProductBadge[]): ProductBadge[];
1243
+
1244
+ /**
1245
+ * The `window.__SHOPPEX_INITIAL__` contract: catalog, store switches and addon
1246
+ * slots, injected by the Shoppex storefront (or preview) worker immediately
1247
+ * before `</head>` per
1248
+ * `packages/builder-contracts/src/storefront-initial-data-html.ts`.
1249
+ *
1250
+ * This is PLATFORM data, not merchant content — the theme never authors it and
1251
+ * must never invent it. Merchant-authored content lives in
1252
+ * `src/config/site-config.ts` and is read from the same object under
1253
+ * `store.builder_settings.theme.content`.
1254
+ */
1255
+
1256
+ interface StorefrontPriceVariant {
1257
+ id: string;
1258
+ label: string;
1259
+ /** What this option states about its term; `inherit` defers to the product. */
1260
+ license: LicenseStatement;
1261
+ /** Display unit price with the product percent discount already applied. */
1262
+ price: number;
1263
+ /**
1264
+ * Undiscounted display-currency unit price — the input for line-level
1265
+ * pricing, where the percent is applied to the whole line like the quote
1266
+ * does, and the floor the server holds pay-what-you-want amounts to.
1267
+ */
1268
+ baseUnitPrice: number;
1269
+ stock: number | null;
1270
+ quantityMin: number | null;
1271
+ quantityMax: number | null;
1272
+ imageUrl: string | null;
1273
+ isFeatured: boolean;
1274
+ }
1275
+ interface VolumeDiscountRule {
1276
+ quantity: number;
1277
+ type: 'PERCENTAGE' | 'FIXED';
1278
+ value: number;
1279
+ }
1280
+ type ProductBundleItem = {
1281
+ /** Cart id (uniqid) — what addToCart expects. */
1282
+ id: string;
1283
+ title: string;
1284
+ variantId: string;
1285
+ unitPrice: number;
1286
+ imageUrl: string | null;
1287
+ variantTitle: string | null;
1288
+ };
1289
+ /**
1290
+ * A "buy these together" offer the merchant configured. The discount is applied
1291
+ * by the backend when the cart is quoted — the theme only advertises it, it
1292
+ * never computes the charged price itself.
1293
+ */
1294
+ type ProductBundle = {
1295
+ id: string;
1296
+ title: string;
1297
+ discountType: 'PERCENTAGE' | 'FIXED';
1298
+ discountAmount: number;
1299
+ items: ProductBundleItem[];
1300
+ };
1301
+ type ProductContentTab = {
1302
+ title: string;
1303
+ html: string;
1304
+ };
1305
+ /**
1306
+ * A paid extra the buyer may add to the line. The price is a per-unit surcharge
1307
+ * on top of the resolved variant price, and `required` options are locked on —
1308
+ * the buy box shows them checked and refuses to clear them, because the backend
1309
+ * charges them either way.
1310
+ */
1311
+ type ProductAddon = {
1312
+ id: string;
1313
+ title: string;
1314
+ price: number;
1315
+ required: boolean;
1316
+ };
1317
+ type ProductFaq = {
1318
+ question: string;
1319
+ answer: string;
1320
+ };
1321
+ /**
1322
+ * One ACTIVE "buy N, get M" deal on this product.
1323
+ *
1324
+ * The server filters the list to the currently live window before it emits it,
1325
+ * so the storefront advertises what `CartQuoteService` would actually apply —
1326
+ * it never computes the discount itself. The discounted units sit WITHIN the
1327
+ * required quantity (they are not extra items on top), and
1328
+ * `maxApplicationsPerCart` caps how often the reward repeats in one cart; both
1329
+ * are `applyAutomaticProductDeals` semantics and the wording depends on them.
1330
+ *
1331
+ * `endsAt` is what makes a row time-limited. Once it passes the backend stops
1332
+ * applying the deal, so the WHOLE row has to disappear — not just its
1333
+ * countdown — which is why the panel drops expired rows rather than freezing
1334
+ * them at 00:00:00.
1335
+ */
1336
+ type ProductAutomaticDeal = {
1337
+ id: string;
1338
+ /** Merchant title. Empty when they named the rule nothing. */
1339
+ title: string;
1340
+ requiredQuantity: number;
1341
+ discountedQuantity: number;
1342
+ /** FREE gives the units away; PERCENTAGE takes `discountValue` percent off. */
1343
+ discountType: 'FREE' | 'PERCENTAGE';
1344
+ /** Percent off the discounted units. 0 for a FREE deal. */
1345
+ discountValue: number;
1346
+ /** How often the reward repeats per cart; null when uncapped. */
1347
+ maxApplicationsPerCart: number | null;
1348
+ /** ISO timestamp the deal stops applying, or null when it does not expire. */
1349
+ endsAt: string | null;
1350
+ };
1351
+ /**
1352
+ * One detail the merchant requires from the buyer for this product — a Steam
1353
+ * id, a server name, a licence holder.
1354
+ *
1355
+ * PAYMENT PATH. These are not decoration: the backend validates them when the
1356
+ * invoice is built (`custom_fields_validation_failed`), so a product with a
1357
+ * REQUIRED field cannot be sold at all until its value reaches the cart line.
1358
+ * The values ride on the cart line's `custom_fields`, which is also part of the
1359
+ * SDK's line identity — see `checkout-fields.ts`.
1360
+ *
1361
+ * The shape mirrors the SDK's own `StorefrontCustomField`
1362
+ * (`packages/sdk/src/utils/storefront-custom-fields.ts`), because that is what
1363
+ * the Easy lane validates against and the two lanes must agree about which
1364
+ * value a merchant's definition accepts.
1365
+ */
1366
+ type ProductCustomField = {
1367
+ name: string;
1368
+ /** 'text' | 'textarea' | 'checkbox' | … — anything else renders as text. */
1369
+ type: string;
1370
+ required: boolean;
1371
+ defaultValue: string;
1372
+ placeholder: string;
1373
+ /** Merchant-authored validation pattern. Absent when they set none. */
1374
+ regex: string | null;
1375
+ };
1376
+ /** A category a product belongs to. The id is what the filter chips match on. */
1377
+ type ProductCategoryRef = {
1378
+ id: string;
1379
+ title: string;
1380
+ };
1381
+ /**
1382
+ * Product kinds the catalog renders differently. `SUBSCRIPTION` earns a badge,
1383
+ * `INFO_CARD` is not buyable at all, `SERIALS` reads its stock straight off
1384
+ * the product row instead of aggregating variants, and `PHYSICAL` ships to an
1385
+ * address the hosted checkout collects, so it is never "instant".
1386
+ */
1387
+ type ProductType = 'PRODUCT' | 'SUBSCRIPTION' | 'INFO_CARD' | 'SERIALS' | 'PHYSICAL';
1388
+ /**
1389
+ * What a product promises about delivery.
1390
+ *
1391
+ * - `shipped`: a PHYSICAL product. Its digital `delivery_time` is null, which
1392
+ * would otherwise read as instant, so the type decides first.
1393
+ * - `timed`: the merchant set a delivery time.
1394
+ * - `instant`: digital with no delivery time.
1395
+ *
1396
+ * Parity with the hosted checkout's `apps/checkout/lib/delivery-promise.ts`.
1397
+ */
1398
+ type DeliveryPromise = {
1399
+ kind: 'shipped';
1400
+ } | {
1401
+ kind: 'timed';
1402
+ count: number;
1403
+ unit: DeliveryTimeUnit;
1404
+ } | {
1405
+ kind: 'instant';
1406
+ };
1407
+ declare function resolveDeliveryPromise(product: Pick<StorefrontProduct, 'productType' | 'deliveryTimeCount' | 'deliveryTimeUnit'>): DeliveryPromise;
1408
+ /**
1409
+ * How often a subscription product is charged, and how long it is free first.
1410
+ *
1411
+ * PARITY CONTRACT. The Easy lane derives the same three facts from the same
1412
+ * flat wire fields in
1413
+ * `packages/storefront-render-core/src/subscription-billing.ts`, and the
1414
+ * wordings below are that module's wordings. A change on either side without
1415
+ * the other makes the two lanes describe one product differently, which is the
1416
+ * one thing this template may not do.
1417
+ */
1418
+ type SubscriptionIntervalUnit = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
1419
+ interface ProductSubscription {
1420
+ /**
1421
+ * The billing interval, or null when the merchant configured none. The
1422
+ * product contract allows a SUBSCRIPTION product without an interval, so the
1423
+ * buy box then prints no cadence rather than inventing one.
1424
+ */
1425
+ intervalUnit: SubscriptionIntervalUnit | null;
1426
+ /** Intervals per charge, at least 1. */
1427
+ intervalCount: number;
1428
+ /** Free-trial length in days; 0 when the merchant set none. */
1429
+ trialDays: number;
1430
+ }
1431
+ /** The cadence as it reads next to a price: "per month", "every 3 months". */
1432
+ declare function formatSubscriptionCadence(subscription: ProductSubscription): string;
1433
+ /** "7-day free trial" — the plan charges nothing for those days. */
1434
+ declare function formatSubscriptionTrial(subscription: ProductSubscription): string;
1435
+ interface StorefrontProduct {
1436
+ /** uniqid — the identifier the cart/SDK use for this product. */
1437
+ id: string;
1438
+ /** Drives the catalog badge, the buy controls and the stock aggregation. */
1439
+ productType: ProductType;
1440
+ /**
1441
+ * The product's real database id. Distinct from `id` (uniqid); shop feedback
1442
+ * references products by this id, so review matching must use it.
1443
+ */
1444
+ productId: string;
1445
+ name: string;
1446
+ price: number;
1447
+ currency: string;
1448
+ description: string | null;
1449
+ slug: string | null;
1450
+ imageUrl: string | null;
1451
+ /**
1452
+ * The product's own image attachments, in the merchant's order — the product
1453
+ * page gallery. Empty when the product has none; the gallery then falls back
1454
+ * to `imageUrl`, the same resolution the Easy lane's gallery producer applies.
1455
+ * The wire carries no per-image alt text, so the alt is the product title.
1456
+ */
1457
+ imageUrls: string[];
1458
+ stock: number | null;
1459
+ /**
1460
+ * Merchant-set "Low Stock Warning" threshold (`quantity_warning` on the
1461
+ * wire), null when disabled. NOT `stock_delimiter` — that field is the text
1462
+ * delimiter splitting the serial-key blob, never a number.
1463
+ */
1464
+ lowStockThreshold: number | null;
1465
+ /** Per-product stock-counter override; null inherits the shop-wide setting. */
1466
+ hideStockCounter: boolean | null;
1467
+ /**
1468
+ * Quantity-tier discounts, sorted ascending by quantity. The backend applies
1469
+ * them at charge time, so the displayed line total must apply them too or
1470
+ * the buyer sees a higher number than they pay.
1471
+ */
1472
+ volumeDiscounts: VolumeDiscountRule[];
1473
+ /**
1474
+ * Buyers may choose their own unit price at or above the resolved
1475
+ * product/variant price. Without an amount input the checkout silently
1476
+ * charges the minimum, so the buy box must render one when this is set.
1477
+ */
1478
+ payWhatYouWant: boolean;
1479
+ /** Normalized product percent discount (0 when none, and always 0 for PWYW). */
1480
+ discountPercent: number;
1481
+ /**
1482
+ * Undiscounted starting unit price (min variant, or the base for
1483
+ * variant-less products) — the line-pricing input for the no-variant case.
1484
+ */
1485
+ baseUnitPrice: number;
1486
+ /**
1487
+ * The product ROW's own undiscounted price, before variants are considered.
1488
+ * Distinct from `baseUnitPrice`, which already falls back to the cheapest
1489
+ * variant: a variant-led product carries 0 here, and that zero is what marks
1490
+ * its catalog price as a range ("$7.00 - $50.00") rather than a single
1491
+ * amount. Collapsing the two would price every variant product from its
1492
+ * minimum and silently drop the range.
1493
+ */
1494
+ basePrice: number;
1495
+ priceVariants: StorefrontPriceVariant[];
1496
+ quantityMin: number;
1497
+ quantityMax: number | null;
1498
+ /** Units sold, for social proof. 0 when the shop has no sales for it. */
1499
+ soldCount: number;
1500
+ /** Merchant flagged this product as a bestseller. */
1501
+ bestseller: boolean;
1502
+ /**
1503
+ * Merchant-authored decorative badges, in their order, at most four. Empty
1504
+ * on almost every product. Purely merchandising — see `product-badges.ts`:
1505
+ * they never speak for availability, price or fulfilment, so they are read
1506
+ * alongside the theme's own signals rather than instead of them.
1507
+ */
1508
+ badges: ProductBadge[];
1509
+ /** Merchant-assigned listing order; lower sorts first. */
1510
+ sortPriority: number;
1511
+ /** ISO timestamp the product was created, or null when the payload omits it. */
1512
+ createdAt: string | null;
1513
+ /** Categories this product belongs to. Empty when uncategorised. */
1514
+ categories: ProductCategoryRef[];
1515
+ /** Bundle offers this product takes part in. Empty when it is in none. */
1516
+ bundles: ProductBundle[];
1517
+ /**
1518
+ * Active quantity deals on this product, in the merchant's order. Empty when
1519
+ * the product has none or every rule is malformed — see `ProductAutomaticDeal`.
1520
+ */
1521
+ automaticDeals: ProductAutomaticDeal[];
1522
+ /**
1523
+ * The merchant's product video as an embeddable, allowlisted `src`. null when
1524
+ * they set none OR when the host is not one the storefront frames — see
1525
+ * `video-embed.ts`.
1526
+ */
1527
+ videoEmbedSrc: string | null;
1528
+ /** Paid extras offered alongside this product. Empty when it has none. */
1529
+ addons: ProductAddon[];
1530
+ /**
1531
+ * Details the merchant collects from the buyer for this product. Empty when
1532
+ * they configured none. A REQUIRED entry makes the product unsellable until
1533
+ * its value is on the cart line — see `ProductCustomField`.
1534
+ */
1535
+ customFields: ProductCustomField[];
1536
+ /**
1537
+ * The merchant PAUSED this product ("Coming soon"). It is not a supply state
1538
+ * and has nothing to do with stock or `orderable`: the backend refuses to
1539
+ * sell it at any inventory level, so the storefront must not offer it. Same
1540
+ * precedence the document lane applies (`isProductOutOfStock` checks it
1541
+ * before it looks at stock at all).
1542
+ */
1543
+ onHold: boolean;
1544
+ /**
1545
+ * Original price when a discount applies (price is the discounted one).
1546
+ * null when there is no discount — never a fabricated "was" price.
1547
+ */
1548
+ compareAtPrice: number | null;
1549
+ /**
1550
+ * Average star rating (1-5) derived from REAL feedback only. null when the
1551
+ * product has zero feedback — the UI then renders no stars rather than a
1552
+ * fake score.
1553
+ */
1554
+ rating: number | null;
1555
+ /** Number of feedback entries the rating is based on (0 when none). */
1556
+ ratingCount: number;
1557
+ /**
1558
+ * The feedback tally as the payload states it: how many buyers were
1559
+ * positive, neutral or negative. null exactly when `rating` is null.
1560
+ */
1561
+ ratingTally: {
1562
+ positive: number;
1563
+ neutral: number;
1564
+ negative: number;
1565
+ } | null;
1566
+ /** Bulleted selling points shown in the buy box. */
1567
+ highlights: string[];
1568
+ /**
1569
+ * Merchant-defined key/value pairs, set per product in the dashboard under
1570
+ * Integrations -> Metadata. Always present ({} when the merchant set none)
1571
+ * and every value is a string, so `product.metadata.status` is safe to read
1572
+ * without a guard.
1573
+ *
1574
+ * This is your extension point: values you need that Shoppex has no field for
1575
+ * — a release status, an accent colour, a grouping tag. The merchant edits
1576
+ * them in the dashboard, so changing one does NOT require rebuilding this
1577
+ * storefront. Nothing here is rendered for you; read the keys your shop uses
1578
+ * and render them yourself.
1579
+ *
1580
+ * Public: everything in here is readable by anyone who opens the store.
1581
+ */
1582
+ metadata: Record<string, string>;
1583
+ /** Extra merchant-authored content tabs ({title, html}). */
1584
+ descriptionTabs: ProductContentTab[];
1585
+ /** Per-product FAQ entries for the details accordion. */
1586
+ faqs: ProductFaq[];
1587
+ /** What this product states about its licence term. Resolve with resolveLicenseTerm. */
1588
+ license: LicenseStatement;
1589
+ /** Whether licensing is on at all; a stale term must not be advertised when it is off. */
1590
+ licensingEnabled: boolean;
1591
+ /** Estimated delivery time as a bare count (null when unset/instant). */
1592
+ deliveryTimeCount: number | null;
1593
+ /** Unit `deliveryTimeCount` is counted in: 'minutes' | 'hours' | 'days'. */
1594
+ deliveryTimeUnit: DeliveryTimeUnit;
1595
+ /**
1596
+ * Recurring billing, present only on a product the merchant sells as a
1597
+ * subscription. null on every other product.
1598
+ */
1599
+ subscription: ProductSubscription | null;
1600
+ }
1601
+ interface StorefrontSettings {
1602
+ cartEnabled: boolean;
1603
+ hideOutOfStock: boolean;
1604
+ hideStockCounter: boolean;
1605
+ /**
1606
+ * Buyer-facing switches the MERCHANT owns, not the theme.
1607
+ *
1608
+ * A storefront that moves to the Advanced lane must not quietly re-enable
1609
+ * what its owner turned off, or offer a contact form the backend will refuse.
1610
+ * The document lane has honoured all three from the start.
1611
+ */
1612
+ searchEnabled: boolean;
1613
+ contactFormEnabled: boolean;
1614
+ requireInvoiceForContact: boolean;
1615
+ /**
1616
+ * The merchant centres bundle cards. A shop-level presentation switch, not a
1617
+ * theme setting: the Easy lane reads `ctx.shop.center_group_titles` and
1618
+ * re-lays the GROUP card out — product cards are untouched.
1619
+ */
1620
+ centerGroupTitles: boolean;
1621
+ /**
1622
+ * The merchant does not want the product's own picture used as its gallery.
1623
+ * The Easy lane's gallery producer empties the image list outright when this
1624
+ * is on, so the product page shows its placeholder frame instead.
1625
+ */
1626
+ descriptionSkipDefaultImage: boolean;
1627
+ /**
1628
+ * Whether pictures INSIDE a product description are shown. Off strips every
1629
+ * `<img>` from the description and from each content tab, the same rewrite
1630
+ * `stripDescriptionImages` performs on the document lane. It does not touch
1631
+ * the gallery, which is `descriptionSkipDefaultImage`'s job.
1632
+ */
1633
+ descriptionImage: boolean;
1634
+ /**
1635
+ * The merchant let buyers re-sort the catalog. Off hides the control on every
1636
+ * listing surface and pins the order to `defaultSort` — the same AND the Easy
1637
+ * lane applies (`enrichPageAllProductsBlockSettings`: the block setting and
1638
+ * `shop.sort_enabled` both have to be on).
1639
+ */
1640
+ sortEnabled: boolean;
1641
+ /**
1642
+ * The order the merchant wants their catalog listed in, normalized to the
1643
+ * four keys every lane sorts by. It is the INITIAL sort of a listing and the
1644
+ * only sort when `sortEnabled` is false.
1645
+ */
1646
+ defaultSort: StorefrontSortKey;
1647
+ /**
1648
+ * Every currency this shop sells in, the shop's own default first, uppercased
1649
+ * and de-duplicated. One entry (or none) means the shop has nothing to switch
1650
+ * between and the header renders no control.
1651
+ */
1652
+ availableCurrencies: string[];
1653
+ /** The currency the merchant configured — the one served without a choice. */
1654
+ defaultCurrency: string;
1655
+ /**
1656
+ * The currency THIS page was priced in. It is the buyer's choice, their
1657
+ * country's match, or the shop default, resolved at the edge before the
1658
+ * catalog was fetched — so it is the only currency the amounts on screen are
1659
+ * actually in.
1660
+ */
1661
+ activeCurrency: string;
1662
+ /**
1663
+ * Payment gateway codes the shop has connected. The cart turns them into the
1664
+ * brand marks under its checkout button — merchant fact, so an empty list
1665
+ * renders no row rather than a reassuring default.
1666
+ */
1667
+ availableGateways: string[];
1668
+ }
1669
+ /**
1670
+ * The shop itself: the identity and the aggregate metrics the storefront
1671
+ * chrome renders. Merchant-owned facts, never theme content — a theme that
1672
+ * authored its own store name or sales count would be publishing a claim the
1673
+ * merchant never made.
1674
+ */
1675
+ interface StorefrontShop {
1676
+ /** The shop's public display name. */
1677
+ name: string;
1678
+ /** Branding/domain logo, falling back to the profile picture for logo surfaces. */
1679
+ logoUrl: string | null;
1680
+ /** Shop profile picture, falling back to the logo only when no avatar exists. */
1681
+ avatarUrl: string | null;
1682
+ /** Merchant-configured social profiles, in the platform's presentation order. */
1683
+ socialLinks: SocialLink[];
1684
+ /**
1685
+ * The merchant's published terms of service, as authored HTML — null when
1686
+ * the shop has no terms or turned them off. `/terms` renders it through
1687
+ * `sanitizeMerchantHtml`, never raw.
1688
+ */
1689
+ termsHtml: string | null;
1690
+ /** True when the merchant published terms and the `/terms` route has content. */
1691
+ hasPublishedTerms: boolean;
1692
+ /**
1693
+ * The shop's reward program, or null when it is off / has no perks. Merchant
1694
+ * fact: the headline and the perk labels are the merchant's wording, and a
1695
+ * theme that supplied its own would advertise a program nobody configured.
1696
+ */
1697
+ rewardProgram: RewardProgramSummary | null;
1698
+ /**
1699
+ * The shop's wholesale reseller program, or null when the shop runs none.
1700
+ * Merchant fact: only a shop that actually accepts resellers may advertise
1701
+ * one, so the footer's "Become a reseller" link is gated on this and not on
1702
+ * theme content.
1703
+ */
1704
+ resellerProgram: ResellerProgramSummary | null;
1705
+ stats: StorefrontShopStats;
1706
+ }
1707
+ /**
1708
+ * The reseller program off the wire, gated the way the Easy lane's footer link
1709
+ * gates it (`blocks/reseller-program-link.ts`): a summary is usable only when
1710
+ * it names an enrolment mode the portal can serve. Both modes render the SAME
1711
+ * link — the portal decides between one-click enrolment and the application
1712
+ * form.
1713
+ */
1714
+ type ResellerProgramSummary = {
1715
+ enrollmentMode: 'OPEN' | 'APPLICATION';
1716
+ };
1717
+ /**
1718
+ * The reward-program teaser's data, gated exactly as the Easy lane gates it
1719
+ * (`blocks/reward-program-teaser.ts` + `mapRewardProgramSummary`): the program
1720
+ * must be enabled, it must carry a headline, and it must have at least one
1721
+ * usable perk. Anything short of that is not a program to advertise, so the
1722
+ * whole summary is null and every teaser renders nothing.
1723
+ */
1724
+ type RewardProgramSummary = {
1725
+ headline: string;
1726
+ items: RewardProgramItem[];
1727
+ };
1728
+ /**
1729
+ * One perk. `triggerType` and `actionType` are required by the platform's own
1730
+ * mapper — a row missing either is a half-configured rule, not a perk — even
1731
+ * though only the label is rendered.
1732
+ */
1733
+ type RewardProgramItem = {
1734
+ triggerType: string;
1735
+ actionType: string;
1736
+ label: string;
1737
+ };
1738
+ type SocialNetwork = 'discord' | 'telegram' | 'youtube' | 'twitter' | 'instagram' | 'tiktok' | 'facebook' | 'reddit' | 'email';
1739
+ type SocialLink = {
1740
+ network: SocialNetwork;
1741
+ href: string;
1742
+ };
1743
+ /**
1744
+ * Aggregate shop metrics for the store-stats strip.
1745
+ *
1746
+ * Counts are raw numbers, not display strings: formatting belongs to the
1747
+ * component. `averageRating` and `reviewCount` are independent on purpose —
1748
+ * a shop can carry a rating from transferred feedback without a countable
1749
+ * review, and rounding that to "no reviews" would drop a real number.
1750
+ */
1751
+ interface StorefrontShopStats {
1752
+ productsSold: number;
1753
+ customers: number;
1754
+ reviewCount: number;
1755
+ /** 1-5, or null when the shop has no rating at all. */
1756
+ averageRating: number | null;
1757
+ /** Products the storefront lists — the theme counts them, the API does not. */
1758
+ productCount: number;
1759
+ }
1760
+ /**
1761
+ * A merchant-curated bundle of products. The wire payload references its
1762
+ * members by uniqid; the members themselves are ordinary catalog products, so
1763
+ * they arrive in `products` and are resolved from there.
1764
+ */
1765
+ interface StorefrontGroup {
1766
+ id: string;
1767
+ title: string;
1768
+ description: string | null;
1769
+ imageUrl: string | null;
1770
+ /** Category the merchant filed the bundle under, if any. */
1771
+ categoryId: string | null;
1772
+ /** Member product uniqids, in the merchant's order. */
1773
+ productIds: string[];
1774
+ /** Member count as the API counts it — may exceed the resolvable members. */
1775
+ productsCount: number;
1776
+ sortPriority: number;
1777
+ }
1778
+ interface StorefrontCategory {
1779
+ id: string;
1780
+ slug: string;
1781
+ title: string;
1782
+ /** The merchant's category picture. Null leaves the filter chip text-only. */
1783
+ imageUrl: string | null;
1784
+ productsCount: number;
1785
+ groupsCount: number;
1786
+ }
1787
+ /**
1788
+ * One entry of the storefront's listing order.
1789
+ *
1790
+ * `items` is a pure display-order reference list: it decides WHICH products and
1791
+ * groups the storefront lists and in what order, and the referenced records are
1792
+ * delivered exactly once in `products` / `groups`. A storefront that listed
1793
+ * `products` directly would show unlisted rows (bundle members, hidden
1794
+ * products) the merchant never put on the shelf.
1795
+ */
1796
+ interface StorefrontListingItem {
1797
+ type: 'product' | 'group';
1798
+ id: string;
1799
+ sortPriority: number;
1800
+ }
1801
+ interface ShoppexInitialData {
1802
+ store?: Record<string, unknown> & {
1803
+ builder_settings?: {
1804
+ theme?: {
1805
+ content?: unknown;
1806
+ };
1807
+ };
1808
+ };
1809
+ products?: unknown[];
1810
+ groups?: unknown[];
1811
+ items?: unknown[];
1812
+ categories?: unknown[];
1813
+ addons?: {
1814
+ items?: StorefrontAddon[];
1815
+ };
1816
+ }
1817
+ declare global {
1818
+ interface Window {
1819
+ __SHOPPEX_INITIAL__?: ShoppexInitialData;
1820
+ }
1821
+ }
1822
+ /**
1823
+ * Whether the page carries an injected payload AT ALL.
1824
+ *
1825
+ * Not a validity check — an empty object passes. It separates "nothing injected
1826
+ * this document" from "something was injected and is wrong", which are two
1827
+ * different faults with two different fixes. See `boot` in `App.tsx`.
1828
+ */
1829
+ declare function hasInjectedStorefrontData(): boolean;
1830
+ /** Merchant-authored content, or undefined when the store has none yet. */
1831
+ declare function getAuthoredContent(): unknown;
1832
+ declare function getProducts(): StorefrontProduct[];
1833
+ declare function getProductGroups(): StorefrontGroup[];
1834
+ declare function getStorefrontCategories(): StorefrontCategory[];
1835
+ declare function getStorefrontListing(): StorefrontListingItem[];
1836
+ declare function getStorefrontShop(productCount: number): StorefrontShop;
1837
+ /** The four orders every Shoppex lane sorts a catalog listing by. */
1838
+ declare const STOREFRONT_SORT_KEYS: readonly ["featured", "newest", "price-asc", "price-desc"];
1839
+ type StorefrontSortKey = (typeof STOREFRONT_SORT_KEYS)[number];
1840
+ /**
1841
+ * `store.default_sort` off the wire, normalized to one of the four keys.
1842
+ *
1843
+ * Field-for-field port of `normalizeStorefrontSortKey`
1844
+ * (`packages/storefront-render-core/src/normalize-storefront-sort-key.ts`),
1845
+ * because the column does NOT hold those keys: the response contract emits the
1846
+ * dashboard's enum (`DEFAULT`, `NEWEST`, `PRICE`, `PRICE_DESC`, `BESTSELLER`,
1847
+ * `NAME`, …). Reading it literally would leave every shop on 'featured' and the
1848
+ * merchant's chosen order would never reach either lane. An order this theme
1849
+ * cannot express — name, stock, type — degrades to the merchant's own shelf
1850
+ * order rather than to a different one.
1851
+ */
1852
+ declare function parseStorefrontSortKey(value: unknown): StorefrontSortKey;
1853
+ declare function getStorefrontSettings(): StorefrontSettings;
1854
+ declare function getStorefrontAddons(): StorefrontAddon[];
1855
+ /**
1856
+ * The stock a buyer can actually reach, in ONE number: null is unlimited.
1857
+ *
1858
+ * Variants are the trap. A variant product's own `stock` column is not the
1859
+ * inventory anyone buys — the variants hold it — so a product with two
1860
+ * unbounded variants and a `stock` of 0 is not sold out. Serial products are
1861
+ * the exception: their stock IS the key pool on the product row.
1862
+ */
1863
+ declare function resolveDisplayStock(product: StorefrontProduct): number | null;
1864
+ declare function isProductSoldOut(product: StorefrontProduct): boolean;
1865
+ /**
1866
+ * The stock behind ONE selected option — null is unlimited.
1867
+ *
1868
+ * A variant without its own counter inherits the product's, and the whole
1869
+ * product falls back to the product row when EVERY variant reports zero while
1870
+ * the product itself has units — that combination is a catalog that never
1871
+ * populated per-variant stock, not a sold-out product.
1872
+ */
1873
+ declare function resolveVariantStock(product: StorefrontProduct, variant: StorefrontPriceVariant): number | null;
1874
+ /**
1875
+ * The one sold-out predicate every add path passes.
1876
+ *
1877
+ * PAYMENT PATH. A product-level read is not enough: an option of an otherwise
1878
+ * stocked product can be sold out on its own, and the backend refuses that line
1879
+ * at invoice creation (`errors.checkout.insufficient_stock`). Adding it anyway
1880
+ * only moves the dead end to checkout.
1881
+ */
1882
+ declare function isSelectionSoldOut(product: StorefrontProduct, variant: StorefrontPriceVariant | null): boolean;
1883
+ /**
1884
+ * The stock counter resolves per product: a product's own hide_stock_counter
1885
+ * wins, null inherits the shop-wide setting — same rule the document lane's
1886
+ * readHideStockCounterForProduct applies.
1887
+ */
1888
+ declare function resolveHideStockCounter(product: StorefrontProduct, settings: StorefrontSettings): boolean;
1889
+ /**
1890
+ * How many units the buyer may put on ONE line, for the selected option.
1891
+ *
1892
+ * A variant overrides the product's own bounds, and the stock caps whatever the
1893
+ * merchant configured: offering a stepper that runs past the inventory produces
1894
+ * a cart the quote then rejects. `max: null` is unlimited, never zero.
1895
+ */
1896
+ declare function resolveQuantityBounds(product: StorefrontProduct, variant: StorefrontPriceVariant | null): {
1897
+ min: number;
1898
+ max: number | null;
1899
+ };
1900
+ type LicenseTerm = {
1901
+ kind: 'lifetime';
1902
+ } | {
1903
+ kind: 'period';
1904
+ days: number;
1905
+ };
1906
+ /**
1907
+ * What ONE row — a product or one of its options — states about its own term.
1908
+ *
1909
+ * `inherit` is an option saying the product answers for it; only an option may say it, because the
1910
+ * product is the root of the term. `unresolved` is a row stating a term nobody can read: an unknown
1911
+ * mode, a PERIOD carrying no days, a legacy `'90d'` that was never validated.
1912
+ *
1913
+ * These three are separate states on purpose. They used to share one nullable column — `NULL` meant
1914
+ * "inherit" on an option and "nothing" on a product — and on 2026-08-18 the unnamed state was
1915
+ * rendered as "Lifetime": 5,333 licences were sold as perpetual and issued with a 30-day expiry.
1916
+ * Neither `inherit` nor `unresolved` may become a term here.
1917
+ */
1918
+ type LicenseStatement = LicenseTerm | {
1919
+ kind: 'inherit';
1920
+ } | {
1921
+ kind: 'unresolved';
1922
+ };
1923
+ /**
1924
+ * The term the buyer is being sold for the option they have selected, or `null` when nothing about
1925
+ * it is resolvable — licensing off, a mode nobody can read, a PERIOD with no days, an option
1926
+ * inheriting from a product that states nothing itself.
1927
+ *
1928
+ * `null` means the page renders NO licence row. It never means "Lifetime".
1929
+ */
1930
+ declare function resolveLicenseTerm(product: StorefrontProduct, variant: StorefrontPriceVariant | null): LicenseTerm | null;
1931
+ /** "Lifetime" / "30 days" — the same wording the Easy lane prints for the same product. */
1932
+ declare function formatLicenseTerm(term: LicenseTerm): string;
1933
+ /** Half-up rounding to cents. Every displayed money amount rounds through here. */
1934
+ declare function roundCurrency(value: number): number;
1935
+ /**
1936
+ * Line total the way the quote computes it: undiscounted subtotal, percent
1937
+ * discount applied at LINE level (not per rounded unit — $9.99 at 15% × 10 is
1938
+ * $84.92, not 10 × $8.49), then the volume tier. Intermediate values stay
1939
+ * unrounded like the quote's Decimal chain; only the final amount rounds —
1940
+ * rounding between the two percent stages shifts the cent ($76.43 vs the
1941
+ * quoted $76.42 for 15% + 10% on a $99.90 line). Every displayed quantity
1942
+ * price must come from here or it disagrees with checkout.
1943
+ */
1944
+ declare function calculateDisplayLineTotal(baseUnitPrice: number, quantity: number, discountPercent: number, rules: VolumeDiscountRule[]): number;
1945
+ /**
1946
+ * Delivery units, mirroring the `products.delivery_time_unit` CHECK constraint.
1947
+ * The column is NOT NULL DEFAULT 'days', so an absent value only comes from a
1948
+ * payload minted before the unit existed — and days is what it meant.
1949
+ */
1950
+ declare const DELIVERY_TIME_UNITS: readonly ["minutes", "hours", "days"];
1951
+ type DeliveryTimeUnit = (typeof DELIVERY_TIME_UNITS)[number];
1952
+ /** "1 hour" / "3 days" — the count with its unit, singular when the count is one. */
1953
+ declare function formatDeliveryTime(count: number, unit: DeliveryTimeUnit): string;
1954
+
1955
+ /**
1956
+ * The per-product details a buyer has to supply before an invoice can exist.
1957
+ *
1958
+ * PAYMENT PATH, and the reason this module exists at all: a merchant who marks
1959
+ * one product custom field REQUIRED cannot sell that product until its value is
1960
+ * on the cart line. `apps/backend/src/services/storefront/invoices/from-cart.ts`
1961
+ * validates every line's `custom_fields` and rejects the whole request with
1962
+ * `custom_fields_validation_failed` — no invoice is created, so the buyer gets a
1963
+ * dead checkout button and the merchant gets no order.
1964
+ *
1965
+ * WHERE THE VALUES GO. Not into the checkout call: the SDK's `checkout()` has no
1966
+ * custom-field option. `mapCartItemsForApi` (`packages/sdk/src/modules/checkout.ts`)
1967
+ * reads `custom_fields` off each CART LINE, so the values are written onto the
1968
+ * lines with `updateCartItem(lineId, { custom_fields })` BEFORE the hand-off.
1969
+ * That is exactly what the Easy lane does — see
1970
+ * `packages/storefront-commerce/src/checkout-custom-fields.ts`, whose
1971
+ * `writeCustomFieldsToCart` runs on the same contract.
1972
+ *
1973
+ * WHERE THE DEFINITIONS COME FROM. The Easy lane fetches each cart product
1974
+ * (`getProduct(uniqid)`) because it has no catalog in the page. This lane does:
1975
+ * `window.__SHOPPEX_INITIAL__` carries every product, listed or not, so the
1976
+ * definitions are read straight out of it. There is no request to fail — but
1977
+ * there is still a line whose product is not in the payload, and that case
1978
+ * FAILS CLOSED for the same reason the Easy lane's failed fetch does: an
1979
+ * unresolvable product may carry required fields, and treating "unknown" as
1980
+ * "none" is how a checkout submits without them.
1981
+ *
1982
+ * VALUES ARE PER PRODUCT, not per cart line. Two lines of the same product (a
1983
+ * different option, a different extra) describe the same thing being bought
1984
+ * twice, and the merchant asked their question once — so both lines receive the
1985
+ * same answers, which is also what keeps them from splitting into two identities
1986
+ * the buyer never chose. It differs from the Easy lane, which dedupes by field
1987
+ * NAME across the whole cart: two products that both ask "Steam ID" are two
1988
+ * questions here, and one there.
1989
+ */
1990
+
1991
+ /** One product's questions, and the product they belong to. */
1992
+ interface CheckoutFieldGroup {
1993
+ productId: string;
1994
+ /** The product's own title — the section heading the buyer reads. */
1995
+ productName: string;
1996
+ fields: ProductCustomField[];
1997
+ }
1998
+ /**
1999
+ * What the cart needs before it can be checked out.
2000
+ *
2001
+ * `resolved: false` is the fail-closed state: at least one line names a product
2002
+ * the payload does not carry, so nothing can be said about what it requires.
2003
+ */
2004
+ type CheckoutFields = {
2005
+ resolved: true;
2006
+ groups: CheckoutFieldGroup[];
2007
+ hasRequired: boolean;
2008
+ } | {
2009
+ resolved: false;
2010
+ unresolvedProductIds: string[];
2011
+ };
2012
+ /** Answers, keyed product id → field name → value. */
2013
+ type CheckoutFieldValues = Record<string, Record<string, string>>;
2014
+ /**
2015
+ * The questions the current cart raises, grouped by product and in cart order.
2016
+ *
2017
+ * Products with no custom fields produce no group — they are resolved, they
2018
+ * simply ask nothing.
2019
+ */
2020
+ declare function resolveCheckoutFields(lines: readonly SdkCartItem[]): CheckoutFields;
2021
+ /**
2022
+ * Whether the checkout CTA has to route through the details form instead of
2023
+ * handing off straight away.
2024
+ *
2025
+ * True for the fail-closed state as well: a cart whose products cannot be
2026
+ * resolved must not be handed to `checkout()` on the assumption that it asks
2027
+ * nothing. The form is where that is said out loud.
2028
+ *
2029
+ * Optional-only fields do NOT gate the hand-off — nothing about the invoice
2030
+ * depends on them, and stopping a buyer for a question they may leave blank
2031
+ * would be this theme inventing a step. They are still collected whenever the
2032
+ * form does open, so a cart that has to stop for one product's required field
2033
+ * gathers every other answer in the same pass.
2034
+ */
2035
+ declare function checkoutNeedsDetails(fields: CheckoutFields): boolean;
2036
+ /**
2037
+ * The form's starting values: whatever the cart lines already carry, else the
2038
+ * merchant's own default for the field.
2039
+ *
2040
+ * Pre-filling from the line is what makes the form survive a buyer going back to
2041
+ * the cart and returning — the answers are stored on the line, not in React.
2042
+ */
2043
+ declare function buildInitialCheckoutFieldValues(groups: readonly CheckoutFieldGroup[], lines: readonly SdkCartItem[]): CheckoutFieldValues;
2044
+ /**
2045
+ * Whether a checkbox answer counts as ticked.
2046
+ *
2047
+ * The stored value is a string, and the four spellings below are the ones the
2048
+ * SDK's `isStorefrontCheckboxCustomFieldValueChecked` accepts — a checkbox the
2049
+ * two lanes read differently is a required tick one lane honours and the other
2050
+ * silently drops.
2051
+ */
2052
+ declare function isCheckboxValueChecked(value: string | undefined): boolean;
2053
+ /**
2054
+ * The buyer-facing problem with one answer, or null when it is acceptable.
2055
+ *
2056
+ * A port of the SDK's `validateStorefrontCustomFieldValue`, down to the wording:
2057
+ * the Easy lane refuses the same values with the same sentences, and the backend
2058
+ * refuses them again when the invoice is built. A malformed merchant regex
2059
+ * validates NOTHING rather than blocking every buyer on a pattern that cannot
2060
+ * compile — the merchant's mistake must not become the buyer's dead end, and the
2061
+ * backend still holds the line.
2062
+ */
2063
+ declare function validateCheckoutFieldValue(field: ProductCustomField, value: string): string | null;
2064
+ /**
2065
+ * Every problem the form has to show, keyed `<productId>.<fieldName>` so two
2066
+ * products asking the same question keep their own message.
2067
+ */
2068
+ declare function validateCheckoutFields(groups: readonly CheckoutFieldGroup[], values: CheckoutFieldValues): Record<string, string>;
2069
+ declare function checkoutFieldKey(productId: string, fieldName: string): string;
2070
+ /**
2071
+ * One product's answers in the shape a cart line stores.
2072
+ *
2073
+ * A port of the SDK's `buildStorefrontCustomFieldPayload`: an unticked checkbox
2074
+ * and an empty answer are OMITTED rather than stored blank, which is what keeps
2075
+ * an optional field the buyer skipped from becoming an empty string the backend
2076
+ * has to interpret. A ticked checkbox is always the literal `'true'`.
2077
+ */
2078
+ declare function buildCustomFieldPayload(fields: readonly ProductCustomField[], values: Record<string, string> | undefined): Record<string, string>;
2079
+ /**
2080
+ * The required answers that are NOT on the cart, read back out of the SDK.
2081
+ *
2082
+ * PAYMENT PATH. The write-back is `updateCartItem`, which the SDK can drop the
2083
+ * same way it drops an add — its storage wrapper catches a quota or blocked
2084
+ * write and only logs it. A hand-off that trusted the write would create no
2085
+ * invoice at all (the backend rejects the cart) after telling the buyer their
2086
+ * details were taken. So the cart is read back and every required field is
2087
+ * checked against the value that actually landed, with the same validator the
2088
+ * form used.
2089
+ *
2090
+ * Returns the buyer-facing messages, empty when the cart is ready to submit.
2091
+ */
2092
+ declare function missingRequiredCheckoutValues(groups: readonly CheckoutFieldGroup[], lines: readonly SdkCartItem[]): string[];
2093
+ /**
2094
+ * Whether a details write changed what the cart PRICES.
2095
+ *
2096
+ * PAYMENT PATH, and the one consequence of the identity merge that is not
2097
+ * harmless. Two lines of the same product and option can exist while their
2098
+ * `custom_fields` differ — the only way this theme's `add` produces one is a
2099
+ * buyer who answered once, came back from a checkout that did not complete, and
2100
+ * added the product again from the PDP or through Buy Now, because `add` sends
2101
+ * no answers at all. Giving both the SAME answers collapses them into one line
2102
+ * whose quantity is the SUM, and the quote proof the SDK is holding is bound to
2103
+ * a hash of the cart's products, options and QUANTITIES (`custom_fields` and
2104
+ * `delivery_instructions` are deliberately NOT hashed — see
2105
+ * `apps/backend/src/services/storefront/cart/cartQuoteToken.ts`). So the answers
2106
+ * alone never stale a proof, and a merge always does.
2107
+ *
2108
+ * Handing off anyway is refused by `/from-cart` as `quote_token_stale`. The
2109
+ * hand-off already recovers from that refusal by re-quoting, so nothing is
2110
+ * mispriced — but the buyer spends a click and a server round trip on a message
2111
+ * about a cart change they did not make. Answering it here instead re-quotes
2112
+ * straight away and names what actually happened.
2113
+ *
2114
+ * Compared on the priced shape rather than on `line_id`s, because every id in
2115
+ * the cart changes when the answers are written — that is the identity rehash
2116
+ * doing its job, not a repricing. A merge is the only thing this write can do
2117
+ * to that shape, and it always removes a line, so the two multisets can never
2118
+ * coincide.
2119
+ */
2120
+ declare function checkoutFieldWriteRepriced(before: readonly SdkCartItem[], after: readonly SdkCartItem[]): boolean;
2121
+
2122
+ type CartContextValue = {
2123
+ lines: SdkCartItem[];
2124
+ count: number;
2125
+ quote: SdkCartQuote | null;
2126
+ /** The server could not price the cart. Written by the refresh, only. */
2127
+ quoteError: string | null;
2128
+ /**
2129
+ * A cart WRITE failed — an add the SDK refused or dropped, a bundle that had
2130
+ * to be rolled back. Held apart from `quoteError` on purpose: a refresh must
2131
+ * never be able to clear it. See `mutate`.
2132
+ */
2133
+ mutationError: string | null;
2134
+ /**
2135
+ * The last code the buyer applied was refused by the quote.
2136
+ *
2137
+ * Written only by `applyCoupon` and cleared only by the buyer's next cart
2138
+ * action (see `mutate`). It cannot be derived from the quote any more,
2139
+ * because a refused code is REMOVED from storage the moment it is refused —
2140
+ * the very next quote therefore carries no code and would erase the message
2141
+ * that explains why.
2142
+ */
2143
+ couponRejected: boolean;
2144
+ /**
2145
+ * True when the code the CURRENT quote was priced with is the ambient
2146
+ * affiliate referral rather than one the buyer applied.
2147
+ *
2148
+ * The promo field decides between its entry row and its "applied" chip from
2149
+ * `quote.coupon_applied`, which was a complete answer while the only code
2150
+ * that could ever reach a quote was one the buyer typed. It is not one any
2151
+ * more: an accepted ambient referral sets that same flag, and the chip would
2152
+ * then congratulate the buyer on a code they never entered, print it blank
2153
+ * (the field is empty, because nothing was applied), and offer a Remove
2154
+ * button wired to cart-coupon storage the referral does not live in — so it
2155
+ * would reappear on the next quote. Kept next to the quote it describes and
2156
+ * written by the same statements, so the two can never disagree.
2157
+ */
2158
+ quotedAmbientAffiliate: boolean;
2159
+ /**
2160
+ * The shop's checkout maximum, stated once, while the CURRENT quote says this
2161
+ * cart is over it. Null while it is within the limit or while no quote has
2162
+ * settled.
2163
+ *
2164
+ * PAYMENT PATH. The summary prints it beside the total — an over-limit cart
2165
+ * is a cart the checkout refuses, and the buyer cannot act on a limit nobody
2166
+ * names — and `checkout` refuses the hand-off on the same fact, exactly as it
2167
+ * refuses an unsellable line. See `resolveCartMaximumWarning`.
2168
+ */
2169
+ cartMaxWarning: string | null;
2170
+ /**
2171
+ * The settled quote contains a PHYSICAL line, so its total excludes shipping,
2172
+ * which the hosted checkout prices from the buyer's address. False while no
2173
+ * quote has settled. The summary says so under the total.
2174
+ */
2175
+ requiresShipping: boolean;
2176
+ checkoutError: string | null;
2177
+ /**
2178
+ * True when the LAST hand-off was refused because the referral on this cart
2179
+ * is the buyer's own (`affiliate_code_not_applicable`).
2180
+ *
2181
+ * It needs its own flag rather than being read out of `checkoutError`,
2182
+ * because it is the one refusal the buyer cannot resolve by re-reading the
2183
+ * message: the code is ambient — nobody typed it, no field shows it, and no
2184
+ * control removes it — so without an explicit exit the cart refuses every
2185
+ * retry forever. The surface renders `clearBlockedReferral` next to the
2186
+ * message; the flag is what tells it to.
2187
+ */
2188
+ selfReferralBlocked: boolean;
2189
+ /**
2190
+ * The exit. Drops the referral attribution the SDK is carrying, re-quotes
2191
+ * without it, and lets the buyer check out again. See the implementation.
2192
+ */
2193
+ clearBlockedReferral: () => Promise<void>;
2194
+ busy: boolean;
2195
+ couponInput: string;
2196
+ setCouponInput: (value: string) => void;
2197
+ drawerOpen: boolean;
2198
+ setDrawerOpen: (open: boolean) => void;
2199
+ add: (product: StorefrontProduct, variant: StorefrontPriceVariant | null, quantity: number, options?: {
2200
+ /**
2201
+ * Buyer-chosen unit price for a pay-what-you-want product. Part of the
2202
+ * cart line identity — the server validates it against the minimum.
2203
+ */
2204
+ payWhatYouWantPrice?: number;
2205
+ /** Extras the buyer ticked in the buy box. Also part of line identity. */
2206
+ addons?: ProductAddon[];
2207
+ }) => Promise<void>;
2208
+ /**
2209
+ * Adds EVERY member of a bundle offer, as one operation.
2210
+ *
2211
+ * Bundle members arrive as a compact summary rather than a full product, so
2212
+ * they cannot go through `add` — their quantity bounds are resolved from the
2213
+ * member's own catalog product instead, and a member that cannot be resolved
2214
+ * is rejected rather than added. The set is all-or-nothing: see the
2215
+ * implementation for why a half-added bundle is worse than none of it.
2216
+ */
2217
+ addBundle: (items: readonly ProductBundleItem[]) => Promise<void>;
2218
+ update: (lineId: string, quantity: number) => Promise<void>;
2219
+ remove: (lineId: string) => Promise<void>;
2220
+ clear: () => Promise<void>;
2221
+ applyCoupon: () => Promise<void>;
2222
+ removeCoupon: () => Promise<void>;
2223
+ /**
2224
+ * The per-product details this cart's products require, resolved from the
2225
+ * injected catalog. `/checkout` renders the form from it.
2226
+ */
2227
+ checkoutFields: CheckoutFields;
2228
+ /**
2229
+ * True when the hand-off must go through `/checkout` to collect those details
2230
+ * first. Read it on CLICK rather than from a render closure after a cart
2231
+ * write — `readCheckoutNeedsDetails` exists for exactly that.
2232
+ */
2233
+ checkoutNeedsDetails: boolean;
2234
+ /**
2235
+ * Hands over to Hosted Checkout. `customFieldValues` writes the buyer's
2236
+ * answers onto the cart lines first and is what `/checkout` submits; every
2237
+ * other surface hands off with no values, and may only do so when
2238
+ * `checkoutNeedsDetails` is false.
2239
+ */
2240
+ checkout: (customFieldValues?: CheckoutFieldValues) => Promise<void>;
2241
+ };
2242
+ /**
2243
+ * Whether the checkout CTA has to stop for details, read from the SDK's cart
2244
+ * RIGHT NOW rather than from React state.
2245
+ *
2246
+ * PAYMENT PATH. Buy Now adds a line and then hands off in the same click. The
2247
+ * `checkoutNeedsDetails` on the context describes the cart as it was when the
2248
+ * component last rendered, which is the cart BEFORE that add — a product whose
2249
+ * required fields the add has just brought into the cart would be handed
2250
+ * straight to a checkout that cannot be created.
2251
+ */
2252
+ declare function readCheckoutNeedsDetails(): boolean;
2253
+ declare function CartProvider({ children }: {
2254
+ children: ReactNode;
2255
+ }): react_jsx_runtime.JSX.Element;
2256
+ declare function useCart(): CartContextValue;
2257
+ /**
2258
+ * Starts a cart mutation from a control that has nothing to chain onto it.
2259
+ *
2260
+ * Every mutation now REJECTS when the write fails, which is what lets Buy Now
2261
+ * stop before checkout. A stepper, a remove button or a bundle offer has no next
2262
+ * step to stop: its failure is already rendered from `mutationError` on the
2263
+ * drawer and `/cart`, the surfaces those controls live on. This states that at
2264
+ * the call site rather than leaving the rejection unhandled — the error is
2265
+ * displayed, not swallowed.
2266
+ */
2267
+ declare function startCartMutation(mutation: Promise<void>): void;
2268
+
2269
+ /**
2270
+ * Reading THE CART BOUNDS CONTRACT off a settled quote.
2271
+ *
2272
+ * PAYMENT PATH. Its own module, free of React and of the SDK singleton, so the
2273
+ * rules can be read and exercised on their own: the cart provider hands them the
2274
+ * quote's `lines` and the cart it holds, and they answer what has to change.
2275
+ *
2276
+ * The producer is the backend's `CartQuoteService` (`CartQuoteLine`), and its
2277
+ * field rules — which this module exists to honour exactly — are:
2278
+ *
2279
+ * `variant_id` the RESOLVED option id, or `null` for a product with no
2280
+ * variants, which the cart stores under the `'default'` marker.
2281
+ * The match has to bridge the two spellings.
2282
+ * `min_quantity` OMITTED when it is 1.
2283
+ * `max_quantity` OMITTED when nothing bounds the line. `-1` is NEVER emitted;
2284
+ * omission IS the unbounded sentinel, and a missing key must
2285
+ * never be read as 0 — that says "sold out". SHARED when
2286
+ * several cart lines name one product+option: they draw from
2287
+ * one shelf and the checkout sums them, so the value is that
2288
+ * line's SHARE of the option's ceiling.
2289
+ * `sellable` false = this line cannot be ordered as the cart stands:
2290
+ * either no quantity of the option can be ordered at all, or
2291
+ * the cart's lines of this option together ask for more units
2292
+ * than the option's ceiling, which the checkout refuses on the
2293
+ * SUM. The line is still priced and still counted — it is one
2294
+ * the buyer must reduce or remove. It answers for the OPTION
2295
+ * and for the whole cart's demand on it, so every line of one
2296
+ * option carries the same value.
2297
+ *
2298
+ * CONSUMER DUTY, from the same contract, and the reason this module exists in
2299
+ * the Advanced lane at all: cart lines hold an ADD-TIME SNAPSHOT of their
2300
+ * bounds, so a line goes stale the moment stock moves or the merchant edits the
2301
+ * limits — and the stepper is driven off those stored values. Heal them by
2302
+ * REPLACING, never merging: an absent `max_quantity` means the ceiling was
2303
+ * REMOVED, and a merge would keep a stale one forever. And refuse the checkout
2304
+ * hand-off while any line is unsellable, because that cart is exactly the one
2305
+ * `/from-cart` rejects.
2306
+ */
2307
+
2308
+ type CartQuoteLineBounds = {
2309
+ productId: string;
2310
+ /** `'default'` for the contract's `null`, so it compares against a cart line. */
2311
+ variantId: string;
2312
+ /** The quantity this row was quoted AT, after per-line summing. */
2313
+ quantity: number;
2314
+ /** `null` = unstated, which is the SDK's own default of 1. */
2315
+ minQuantity: number | null;
2316
+ /** `null` = unbounded. Never a negative sentinel. */
2317
+ maxQuantity: number | null;
2318
+ sellable: boolean;
2319
+ };
2320
+ /** One healing write: the line to patch, and the bounds to REPLACE it with. */
2321
+ type CartBoundsHeal = {
2322
+ lineId: string;
2323
+ /**
2324
+ * `null` DELETES the stored bound — the contract's "no bound" — in the SDK's
2325
+ * own tri-state (absent = leave alone, null = clear, number = set). It is not
2326
+ * `undefined`, because `undefined` is exactly what the SDK reads as "this
2327
+ * update did not mention the field", so a removed ceiling would stick to the
2328
+ * line forever.
2329
+ */
2330
+ minQuantity: number | null;
2331
+ maxQuantity: number | null;
2332
+ };
2333
+ /** Parse `lines` off a settled quote. An absent or malformed list yields none. */
2334
+ declare function parseCartQuoteLines(raw: SdkCartQuoteLine[] | undefined): CartQuoteLineBounds[];
2335
+ /**
2336
+ * The writes needed to bring stored line bounds back in line with the quote.
2337
+ *
2338
+ * Only lines whose stored bounds actually DIFFER are returned, which is what
2339
+ * keeps this from looping: writing the same values back would emit another
2340
+ * cart-changed, re-quote, and arrive here again. Cart lines the quote did not
2341
+ * mention are left untouched — the quote answers for the cart it was given, and
2342
+ * a line added after it was sent has no answer in it yet.
2343
+ */
2344
+ declare function resolveCartBoundsHeals(lines: readonly SdkCartItem[], quoted: readonly CartQuoteLineBounds[]): CartBoundsHeal[];
2345
+ /** The stored `line_id`s the quote says cannot be ordered right now. */
2346
+ declare function resolveUnsellableLineIds(lines: readonly SdkCartItem[], quoted: readonly CartQuoteLineBounds[]): string[];
2347
+
2348
+ /**
2349
+ * View models for the cart drawer and the `/cart` page.
2350
+ *
2351
+ * The cart lives in the SDK as a flat list of lines plus a server quote. This
2352
+ * module turns those two into the rows a buyer reads, and it is the only place
2353
+ * that decides what a cart line SAYS.
2354
+ *
2355
+ * PRICING RULE, and it is the whole reason this file exists: a line total is
2356
+ * the stored price snapshot times the quantity, and nothing else. The totals
2357
+ * block is the SERVER's quote, verbatim — subtotal, discounts and total all
2358
+ * from the SAME quote, on both the drawer and `/cart`.
2359
+ *
2360
+ * The two are never mixed. The quote's subtotal is pre-discount while the line
2361
+ * snapshots already carry the product and volume discounts, so printing the
2362
+ * client's sum above the quote's discount rows produces a summary whose
2363
+ * arithmetic does not work (Subtotal 9, Discount −1, Total 9) — which is
2364
+ * exactly what the drawer used to show. `CartView.subtotal` is therefore the
2365
+ * client's own number for ONE purpose: standing in for the whole block while no
2366
+ * quote exists, where every row is the client's and the block still adds up.
2367
+ *
2368
+ * When a line has no snapshot its total is zero and the whole cart is flagged
2369
+ * as an estimate rather than presenting a confident wrong number.
2370
+ *
2371
+ * Cents are settled by the SHARED `roundCurrency`, the same half-up-on-the-
2372
+ * decimal-text rule the buy box and the line-pricing maths use. This file used
2373
+ * to carry a naive `Math.round(v * 100) / 100` of its own, which is a different
2374
+ * function: binary noise puts 10.03 × 50% at 5.014999…, which that version
2375
+ * floors to 5.01 while the quote's ROUND_HALF_UP gives 5.02. One rounding rule,
2376
+ * or two surfaces disagree by a cent about the same cart.
2377
+ */
2378
+
2379
+ interface CartViewLine {
2380
+ lineId: string;
2381
+ title: string;
2382
+ /** The option, and any extras the buyer ticked, as one caption. Blank hides it. */
2383
+ subtitle: string;
2384
+ imageUrl: string | null;
2385
+ quantity: number;
2386
+ lineTotal: number;
2387
+ /** False at the line's floor — the stepper removes the line instead. */
2388
+ canDecrease: boolean;
2389
+ /** False at the stock or per-order ceiling recorded when the line was added. */
2390
+ canIncrease: boolean;
2391
+ /** The quantity the stepper drops to; below it the line is removed. */
2392
+ minimumQuantity: number;
2393
+ }
2394
+ interface CartView {
2395
+ lines: CartViewLine[];
2396
+ /** Sum of the line totals — the client's own number, not the quote's. */
2397
+ subtotal: number;
2398
+ /**
2399
+ * True when a line carries no price snapshot, so `subtotal` understates the
2400
+ * cart. The summary says "estimated" instead of pretending otherwise.
2401
+ */
2402
+ subtotalIsEstimate: boolean;
2403
+ isEmpty: boolean;
2404
+ }
2405
+ declare function buildCartView(lines: SdkCartItem[]): CartView;
2406
+ interface CartTotals {
2407
+ /**
2408
+ * The subtotal to print. From the quote when one exists (pre-discount, the
2409
+ * same basis its discount rows are stated against), otherwise the client's
2410
+ * own sum.
2411
+ */
2412
+ subtotal: number;
2413
+ /** Itemised discounts, exactly as the quote named them. */
2414
+ discounts: Array<{
2415
+ label: string;
2416
+ amount: string;
2417
+ }>;
2418
+ /**
2419
+ * Aggregate discount, shown only when the quote reports a reduction it did
2420
+ * not itemise. Never rendered alongside `discounts`.
2421
+ */
2422
+ aggregateDiscount: number | null;
2423
+ total: number;
2424
+ currency: string;
2425
+ /**
2426
+ * True while the printed numbers are not a server quote — a quote in flight,
2427
+ * a quote that failed, or a line without a price snapshot.
2428
+ */
2429
+ isEstimate: boolean;
2430
+ }
2431
+ declare function buildCartTotals(view: CartView, quote: SdkCartQuote | null, fallbackCurrency: string): CartTotals;
2432
+
2433
+ /**
2434
+ * Catalog view models: the step between the injected wire payload and the
2435
+ * cards.
2436
+ *
2437
+ * Everything a card shows — the price label, the badge, the stock chip, the
2438
+ * buy control it gets — is decided here once, so the card components stay
2439
+ * markup. Nothing in this module invents a value: a product without a rating
2440
+ * has no rating chip, a product without a description has no description line.
2441
+ */
2442
+
2443
+ /**
2444
+ * Money as the storefront shows it: `$49`, `$49.50`, `€9.99`, `¥1,200`.
2445
+ *
2446
+ * A whole amount drops its zero cents (`$49`, not `$49.00`); any other amount
2447
+ * keeps the currency's own number of decimals, so `$49.50` never turns into
2448
+ * `$49.5` and a three-decimal currency keeps all three. "Whole" is judged on
2449
+ * the amount rounded to those decimals, so float noise like `48.99999999`
2450
+ * still reads as `$49`.
2451
+ */
2452
+ declare function formatMoney(amount: number, currency: string): string;
2453
+ /** Compact counts for the store-stats strip: 12450 → `12k`, 8200 → `8.2k`. */
2454
+ declare function formatCompactCount(value: number): string;
2455
+ type StockTone = 'in_stock' | 'low_stock' | 'sold_out';
2456
+ type StockChip = {
2457
+ tone: StockTone;
2458
+ label: string;
2459
+ };
2460
+ /**
2461
+ * The units at or below which stock reads as LOW when the merchant configured
2462
+ * no threshold of their own. Ten is what every Easy-lane stock surface hardcodes
2463
+ * (`resolveCatalogStockPresentation`, `resolveStockTone`), so a merchant who
2464
+ * never set "Low Stock Warning" sees the same wording on both lanes.
2465
+ */
2466
+ declare const DEFAULT_LOW_STOCK_THRESHOLD = 10;
2467
+ interface ProductCardModel {
2468
+ kind: 'product';
2469
+ key: string;
2470
+ product: StorefrontProduct;
2471
+ href: string;
2472
+ priceLabel: string;
2473
+ badgeLabel: string | null;
2474
+ /**
2475
+ * The merchant's own badges for this surface, already filtered by
2476
+ * `show_on_product_card`. Separate from `badgeLabel`, which is the theme's
2477
+ * product-TYPE chip: one is authored, the other is derived, and they occupy
2478
+ * different corners of the media so neither can hide the other.
2479
+ */
2480
+ badges: ProductBadge[];
2481
+ /** Up to three merchant bullets; the description line renders only when empty. */
2482
+ highlights: string[];
2483
+ description: string | null;
2484
+ rating: number | null;
2485
+ /**
2486
+ * Absent for info cards, sold-out products and paused ones — each has its own
2487
+ * chip or its own action row instead.
2488
+ */
2489
+ stockChip: StockChip | null;
2490
+ soldOut: boolean;
2491
+ /**
2492
+ * The merchant PAUSED this product. Not a stock state: it outranks stock
2493
+ * entirely, because the backend refuses to sell it however many units exist.
2494
+ */
2495
+ onHold: boolean;
2496
+ categoryIds: string[];
2497
+ sortPriority: number;
2498
+ createdAt: string | null;
2499
+ price: number;
2500
+ }
2501
+ interface GroupCardModel {
2502
+ kind: 'group';
2503
+ key: string;
2504
+ group: StorefrontGroup;
2505
+ priceLabel: string;
2506
+ /** Resolvable members, in the merchant's order. */
2507
+ members: StorefrontProduct[];
2508
+ memberCountLabel: string;
2509
+ categoryIds: string[];
2510
+ sortPriority: number;
2511
+ price: number;
2512
+ }
2513
+ type CatalogCardModel = ProductCardModel | GroupCardModel;
2514
+ interface StorefrontCatalog {
2515
+ cards: CatalogCardModel[];
2516
+ categories: StorefrontCategory[];
2517
+ /**
2518
+ * Every product the payload carries, including the ones the shelf does not
2519
+ * list. A bundle member has a real product page even though it has no card,
2520
+ * so product-page lookups read this and not `cards`.
2521
+ */
2522
+ products: StorefrontProduct[];
2523
+ /** Products the storefront lists — the store-stats "products" cell counts these. */
2524
+ listedProductCount: number;
2525
+ }
2526
+ /**
2527
+ * The product a `/product/:slug` route points at.
2528
+ *
2529
+ * The id is accepted alongside the slug because a product without a slug is
2530
+ * linked by uniqid, and a bookmarked uniqid must keep working after the
2531
+ * merchant adds one.
2532
+ */
2533
+ declare function findStorefrontProduct(catalog: StorefrontCatalog, slugOrId: string): StorefrontProduct | null;
2534
+ /**
2535
+ * The storefront's catalog, in the merchant's shelf order.
2536
+ *
2537
+ * `items` decides what is on the shelf. Reading `products` instead would put
2538
+ * every bundle member on the home page as a standalone card — they are real
2539
+ * catalog products, they are simply not listed on their own.
2540
+ */
2541
+ declare function buildStorefrontCatalog(settings: StorefrontSettings): StorefrontCatalog;
2542
+ declare const CATALOG_SORTS: readonly ["featured", "newest", "price-asc", "price-desc"];
2543
+ type CatalogSort = StorefrontSortKey;
2544
+ declare const CATALOG_SORT_LABELS: Record<CatalogSort, string>;
2545
+ /**
2546
+ * Sorting never mutates the shelf order: `featured` IS that order, and the
2547
+ * others sort a copy of it so switching back is exact.
2548
+ */
2549
+ declare function sortCatalogCards(cards: CatalogCardModel[], sort: CatalogSort): CatalogCardModel[];
2550
+ /**
2551
+ * The category a `?category=` value names, as the CARD models key on it.
2552
+ *
2553
+ * A shared or bookmarked listing URL may carry the merchant's slug, the
2554
+ * category title, or the uniqid — the Easy lane accepts all of them
2555
+ * (`resolveAllProductsCategorySelection`) and answers with the uniqid the cards
2556
+ * are filtered by. Mirrored here so one storefront's filtered link keeps working
2557
+ * after a lane switch. An unknown value selects nothing rather than emptying the
2558
+ * grid against a category that does not exist.
2559
+ */
2560
+ declare function resolveCategorySelection(value: string | null, categories: readonly StorefrontCategory[]): string | null;
2561
+ declare function filterCatalogCards(cards: CatalogCardModel[], categoryId: string | null): CatalogCardModel[];
2562
+ /** Products per page in the full listing. Bundles do not count against it. */
2563
+ declare const CATALOG_PAGE_SIZE = 24;
2564
+ interface CatalogPage {
2565
+ cards: CatalogCardModel[];
2566
+ /** The page actually shown — an out-of-range request lands on the last one. */
2567
+ page: number;
2568
+ totalPages: number;
2569
+ }
2570
+ /**
2571
+ * One page of the full listing.
2572
+ *
2573
+ * Bundles all sit on page one and never count against the page size: a shop
2574
+ * with three bundles and twenty-four products is one page, not two, because a
2575
+ * bundle is an entry point into the catalog rather than an item in it. The
2576
+ * input must already be filtered and sorted — paging re-orders nothing.
2577
+ */
2578
+ declare function paginateCatalogCards(cards: CatalogCardModel[], page: number): CatalogPage;
2579
+
2580
+ declare const QUOTE_UNAVAILABLE_CHECKOUT_ERROR = "Cart pricing is unavailable, so this order cannot be priced yet. Try again in a moment.";
2581
+ /** Retires only the checkout refusal that a newly settled quote resolved. */
2582
+ declare function clearRecoveredQuoteCheckoutError(error: string | null): string | null;
2583
+
2584
+ /**
2585
+ * The contact form's submit path and the community channels beside it.
2586
+ *
2587
+ * The Storefront SDK has no contact method, so this posts the storefront
2588
+ * ticket endpoint directly — the same request, body and validation the
2589
+ * document lane's `sx-contact-form` runs through `submitStorefrontContactTicket`
2590
+ * (`packages/sdk/src/utils/storefront-contact.ts`). Keeping the payload shape
2591
+ * identical is what makes a message sent from a code storefront indistinguishable
2592
+ * from one sent from the Builder.
2593
+ */
2594
+
2595
+ interface ContactFormValues {
2596
+ name: string;
2597
+ email: string;
2598
+ subject: string;
2599
+ message: string;
2600
+ invoiceId: string;
2601
+ }
2602
+ interface ContactSubmissionContext {
2603
+ contactEnabled: boolean;
2604
+ requireInvoice: boolean;
2605
+ }
2606
+ /**
2607
+ * Client-side validation, mirroring `validateStorefrontContactSubmission`.
2608
+ *
2609
+ * It is a courtesy, not a gate: the backend enforces the same rules, and a
2610
+ * form that only *looked* validated would let a disabled shop's visitor watch
2611
+ * a request fail with no explanation.
2612
+ */
2613
+ declare function validateContactSubmission(values: ContactFormValues, context: ContactSubmissionContext): string | null;
2614
+ declare function submitContactTicket(values: ContactFormValues, context: ContactSubmissionContext): Promise<void>;
2615
+ interface SupportChannel {
2616
+ label: string;
2617
+ href: string;
2618
+ /** Mail links open the visitor's client rather than a new tab. */
2619
+ isMail: boolean;
2620
+ }
2621
+ /**
2622
+ * The channels a buyer can reach the shop on before they buy.
2623
+ *
2624
+ * Derived from the shop's own social profiles, not from theme content: those
2625
+ * handles belong to the merchant and are edited once in the dashboard. The
2626
+ * three networks and their order are the platform's
2627
+ * (`withShopContactFallbacks` in the document renderer), so the two lanes list
2628
+ * the same links in the same order.
2629
+ */
2630
+ declare function resolveSupportChannels(links: readonly SocialLink[]): SupportChannel[];
2631
+
2632
+ /**
2633
+ * The merchant's navigation menus, and where they sit against theme content.
2634
+ *
2635
+ * A shop configures menus once in the dashboard and expects every theme to use
2636
+ * them. The Easy lane resolves a slot in this order
2637
+ * (`build-storefront-navigation.ts` + `resolveDocumentNavigationLinks`):
2638
+ *
2639
+ * 1. a link list the merchant authored IN THE THEME wins — including an
2640
+ * empty one, which means "no links here", not "fall back";
2641
+ * 2. otherwise the merchant's menu for that slot;
2642
+ * 3. otherwise the theme's own static links.
2643
+ *
2644
+ * Mirrored exactly. `hasAuthoredLinks` is what separates (1) from (3): the
2645
+ * template's shipped `nav.links` are step 3, not an override, so a shop with a
2646
+ * header menu and no theme edit gets its menu — which is the whole gap this
2647
+ * closes.
2648
+ *
2649
+ * The menus are fetched once per page load and shared by both slots. While the
2650
+ * request is in flight the theme's own links render: that is the same list step
2651
+ * 3 would settle on, so the header is never empty and never shows a spinner
2652
+ * where its navigation belongs. A shop WITH a menu therefore swaps its links in
2653
+ * once, on first resolve.
2654
+ */
2655
+
2656
+ type MenuSlot = 'header' | 'footer';
2657
+ /** One navigation link as a theme authors it: a label and where it goes. */
2658
+ type NavLink = {
2659
+ label: string;
2660
+ href: string;
2661
+ };
2662
+ type MenuNavLink = NavLink & {
2663
+ target: string | null;
2664
+ };
2665
+ declare function resolveMenuLinksForSlot(menus: readonly SdkMenu[], slot: MenuSlot): MenuNavLink[];
2666
+ interface NavigationLinks {
2667
+ links: readonly MenuNavLink[];
2668
+ /**
2669
+ * True when the rendered list is the THEME's content, which is what decides
2670
+ * whether each entry carries a `data-sx-config` path — a merchant's menu item
2671
+ * is edited in the dashboard's navigation screen, not in the design
2672
+ * inspector, and pointing the inspector at it would offer an edit that goes
2673
+ * nowhere.
2674
+ */
2675
+ fromTheme: boolean;
2676
+ }
2677
+ /**
2678
+ * The links one slot should render, with the precedence above already applied.
2679
+ *
2680
+ * `themeLinks` is the template's own list. It is returned unchanged when the
2681
+ * merchant authored it (step 1) and while the menus are still loading; a
2682
+ * non-empty menu replaces it once resolved (step 2); an empty menu leaves it in
2683
+ * place (step 3).
2684
+ */
2685
+ declare function useNavigationLinks(slot: MenuSlot, themeLinks: readonly NavLink[],
2686
+ /** True when the merchant edited this slot's links in the theme content. */
2687
+ authored: boolean): NavigationLinks;
2688
+
2689
+ /**
2690
+ * Strict parsing helpers shared by the content contract (`src/config`) and the
2691
+ * storefront data contract (`src/commerce`).
2692
+ *
2693
+ * The rule both contracts follow: an ABSENT value may fall back to a documented
2694
+ * default, a PRESENT but malformed one throws. Silently repairing bad input
2695
+ * would hide a broken content draft or a changed wire contract behind a
2696
+ * storefront that merely looks slightly wrong.
2697
+ */
2698
+ declare function isRecord(value: unknown): value is Record<string, unknown>;
2699
+ declare function requireUnknownRecord(value: unknown, context: string): Record<string, unknown>;
2700
+ declare function requireRecord(record: Record<string, unknown>, key: string): Record<string, unknown>;
2701
+ declare function requireArray(record: Record<string, unknown>, key: string): unknown[];
2702
+ declare function requireString(record: Record<string, unknown>, key: string, context: string): string;
2703
+ declare function requireStringFromKeys(record: Record<string, unknown>, keys: string[], context: string): string;
2704
+ declare function requireNumber(record: Record<string, unknown>, key: string, context: string): number;
2705
+ /** A non-empty trimmed string, or null for absent/blank/non-string input. */
2706
+ declare function optionalString(value: unknown): string | null;
2707
+ /** The first usable string among several candidate keys. */
2708
+ declare function firstOptionalString(record: Record<string, unknown>, keys: string[]): string | null;
2709
+ /**
2710
+ * A visible string that may be absent from an older content draft. Absent or
2711
+ * blank renders the shipped wording; a present non-string is still an error.
2712
+ */
2713
+ declare function optionalStringWithDefault(value: unknown, fallback: string, context: string): string;
2714
+ /**
2715
+ * A boolean the serializer ALWAYS emits, so its absence is a broken payload.
2716
+ *
2717
+ * Defaulting one of these is worse than throwing: a merchant switch that never
2718
+ * arrives silently reads as whatever the theme guessed, and the storefront then
2719
+ * offers something the backend refuses. `0`/`1` are accepted because the wire
2720
+ * carries some flags that way; anything else is an error.
2721
+ */
2722
+ declare function requireBoolean(value: unknown, context: string): boolean;
2723
+ declare function optionalBoolean(value: unknown, fallback: boolean, context: string): boolean;
2724
+ declare function parseFiniteNumber(value: unknown, context: string): number;
2725
+ /**
2726
+ * One of a fixed option set. Absent falls back to the theme default; anything
2727
+ * else throws, because a typo in an enum is a broken content draft, not a
2728
+ * missing field — and the wrong branch of a layout switch is worse than a
2729
+ * visible error.
2730
+ */
2731
+ declare function parseEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T, context: string): T;
2732
+
2733
+ /**
2734
+ * Product-page view models: the step between one parsed product and the
2735
+ * gallery, buy box, details tabs and related strip.
2736
+ *
2737
+ * Same division of labour as `catalog.ts` — every decision a product page makes
2738
+ * about WHAT to show is made here, so the components stay markup. The two files
2739
+ * deliberately differ on stock wording: a catalog card shouts a short chip
2740
+ * ("999 IN STOCK"), the buy box states the option's availability in a sentence
2741
+ * ("999 in stock", "Only 3 left"). Same source, two audiences.
2742
+ */
2743
+
2744
+ /** The five star slots under the product title; `true` is a filled star. */
2745
+ type RatingStars = readonly [boolean, boolean, boolean, boolean, boolean];
2746
+ interface BuyBoxVariantModel {
2747
+ variant: StorefrontPriceVariant;
2748
+ /** Unit price with the product discount applied — what one unit costs today. */
2749
+ price: number;
2750
+ /** Undiscounted unit price, shown struck through. null when nothing is off. */
2751
+ compareAtPrice: number | null;
2752
+ stockTone: StockTone;
2753
+ stockLabel: string;
2754
+ soldOut: boolean;
2755
+ }
2756
+ interface ProductRatingModel {
2757
+ stars: RatingStars;
2758
+ /** Reviews the tally counted — the label pluralises off this. */
2759
+ count: number;
2760
+ /** 1-5, rounded to one decimal. */
2761
+ average: number;
2762
+ /**
2763
+ * How the tally splits. The bootstrap payload carries these three buckets and
2764
+ * no review bodies, so this is the finest grain the code lane can honestly
2765
+ * report.
2766
+ */
2767
+ positive: number;
2768
+ neutral: number;
2769
+ negative: number;
2770
+ }
2771
+ interface ProductDetailModel {
2772
+ product: StorefrontProduct;
2773
+ /** Gallery images, primary first. Empty when the product has no art at all. */
2774
+ images: string[];
2775
+ /** Rating derived from real feedback; null when the product has none. */
2776
+ rating: ProductRatingModel | null;
2777
+ variants: BuyBoxVariantModel[];
2778
+ addons: ProductAddon[];
2779
+ soldOut: boolean;
2780
+ /** Cards for the "You might also like" strip, in the merchant's shelf order. */
2781
+ related: ProductCardModel[];
2782
+ }
2783
+ declare function buildProductDetail(product: StorefrontProduct, settings: StorefrontSettings, cards: CatalogCardModel[]): ProductDetailModel;
2784
+ interface LinePricing {
2785
+ /** What the cart will charge for the whole line. */
2786
+ total: number;
2787
+ /** The same line before any discount. Equal to `total` when none applies. */
2788
+ originalSubtotal: number;
2789
+ /** Per-unit price after discounts, for the "… each" line. */
2790
+ effectiveUnitPrice: number;
2791
+ discountAmount: number;
2792
+ hasDiscount: boolean;
2793
+ }
2794
+ /**
2795
+ * What the buy box headline shows.
2796
+ *
2797
+ * The unit price folds the selected variant AND the selected addons before the
2798
+ * discount stages run, exactly as the platform's product-form controller does —
2799
+ * a storefront that discounted the variant and then added the extras would
2800
+ * advertise a total the checkout does not honour.
2801
+ */
2802
+ declare function calculateLinePricing(product: StorefrontProduct, variant: StorefrontPriceVariant | null, addons: ProductAddon[], quantity: number,
2803
+ /**
2804
+ * The buyer's pay-what-you-want amount: PER UNIT, and for the product alone.
2805
+ * The extras are added on top of it here exactly once, which is the same
2806
+ * split `pay_what_you_want_price` + `price_data.unit_price` the cart sends
2807
+ * and the backend charges.
2808
+ */
2809
+ payWhatYouWantPrice?: number): LinePricing;
2810
+ /**
2811
+ * The floor a pay-what-you-want buyer may name: PER UNIT, extras excluded.
2812
+ *
2813
+ * It is the selected option's UNDISCOUNTED price — the same number the backend
2814
+ * validates the buyer's amount against (`resolvePayWhatYouWantUnitPrice`) and
2815
+ * the same one the document lane offers as the default. Reading a discounted
2816
+ * price here would pre-fill an amount the checkout then refuses as too low.
2817
+ */
2818
+ declare function resolvePayWhatYouWantMinimum(product: StorefrontProduct, variant: StorefrontPriceVariant | null): number;
2819
+ /**
2820
+ * The backend's ceiling for a named price (`MAX_PAY_WHAT_YOU_WANT_PRICE`).
2821
+ * An amount above it fails when the invoice is built, so the buy box refuses it
2822
+ * first instead of handing the buyer to a checkout that cannot be created.
2823
+ */
2824
+ declare const MAX_PAY_WHAT_YOU_WANT_PRICE = 1000000000;
2825
+ /** Clamps a buyer-entered quantity into the bounds the line actually allows. */
2826
+ declare function clampQuantity(product: StorefrontProduct, variant: StorefrontPriceVariant | null, quantity: number): number;
2827
+
2828
+ /**
2829
+ * Shop feedback for the `/reviews` page.
2830
+ *
2831
+ * Shoppex feedback is SHOP-level, not per product, and it is not part of the
2832
+ * injected catalog payload — the bootstrap object carries only per-product
2833
+ * tallies. The bodies come from a runtime call, so this is the one surface in
2834
+ * the theme that renders a list it has to wait for.
2835
+ *
2836
+ * Nothing here invents a review. A shop with no feedback resolves to an empty
2837
+ * list and the page says so; a failed request resolves to an error state and
2838
+ * the page says that instead. Neither is padded with sample data.
2839
+ */
2840
+
2841
+ interface ShopReview {
2842
+ id: string;
2843
+ /** Filled-star count, the rating clamped to 0-5 and rounded. */
2844
+ starCount: number;
2845
+ rating: number;
2846
+ comment: string;
2847
+ author: string;
2848
+ /** Localized short date, e.g. "Jan 12, 2026". Blank when the API sent none. */
2849
+ dateLabel: string;
2850
+ /** Sort key; 0 when the entry carries no usable timestamp. */
2851
+ timestamp: number;
2852
+ /**
2853
+ * The reviewed product's CURRENT title, or null when the review names none.
2854
+ * The public feed withholds product ids, so the title is the only link to a
2855
+ * product there is; see `reviewsForProduct`.
2856
+ */
2857
+ productTitle: string | null;
2858
+ /** True when the review is tied to a real completed purchase. */
2859
+ verified: boolean;
2860
+ }
2861
+ type ShopReviewsState = {
2862
+ status: 'loading';
2863
+ } | {
2864
+ status: 'error';
2865
+ } | {
2866
+ status: 'ready';
2867
+ reviews: ShopReview[];
2868
+ average: number | null;
2869
+ };
2870
+ /**
2871
+ * How many cards the page shows. The reference storefront pages its list at
2872
+ * twelve; a shop with hundreds of reviews should not ship all of them into one
2873
+ * screenshot-tall grid.
2874
+ */
2875
+ declare const SHOP_REVIEW_PAGE_SIZE = 12;
2876
+ interface ShopReviewLabels {
2877
+ anonymousAuthor: string;
2878
+ emptyComment: string;
2879
+ }
2880
+ declare function toStarCount(rating: number): number;
2881
+ /**
2882
+ * Newest first, and only entries that carry a real rating: a zero-star row is
2883
+ * an unrated record, not a one-star verdict, and averaging it in would drag
2884
+ * every shop's score down.
2885
+ */
2886
+ declare function toShopReviews(rows: readonly SdkShopReview[], labels: ShopReviewLabels): ShopReview[];
2887
+ /**
2888
+ * The written reviews that name this product, newest first.
2889
+ *
2890
+ * Matched on the product's title because the public feed carries no product
2891
+ * ids. That makes it a sample, not a count: the feed leaves out appealed and
2892
+ * filtered feedback, and a renamed product loses its older reviews. Surfaces
2893
+ * show these as "latest reviews" beside the payload's own tally and never
2894
+ * derive a number from them.
2895
+ */
2896
+ declare function reviewsForProduct(reviews: readonly ShopReview[], productTitle: string): ShopReview[];
2897
+ declare function averageRating(reviews: readonly ShopReview[]): number | null;
2898
+ declare function useShopReviews(labels: ShopReviewLabels): ShopReviewsState;
2899
+
2900
+ /**
2901
+ * Merchant-authored HTML, made safe to inject.
2902
+ *
2903
+ * The storefront payload carries product descriptions and content tabs exactly
2904
+ * as the merchant stored them; nothing sanitizes them on the way out. The Easy
2905
+ * lane sanitizes at RENDER time (`sanitizeStorefrontRichTextHtml`), so a code
2906
+ * storefront that skipped this step would make the same shop's content safe on
2907
+ * one lane and executable on the other.
2908
+ *
2909
+ * "The merchant wrote it, on their own storefront" is not a safety argument:
2910
+ * imported shops carry HTML nobody at the shop authored, and dashboard access is
2911
+ * not always the owner's. The allowlist mirrors the Easy lane's — anything
2912
+ * outside it is DROPPED rather than escaped, so a stray `onerror` disappears
2913
+ * instead of showing up as text.
2914
+ */
2915
+ declare function sanitizeMerchantHtml(value: unknown): string;
2916
+ /**
2917
+ * Merchant HTML reduced to plain text, safely.
2918
+ *
2919
+ * Product descriptions arrive as rich text and several surfaces render them as
2920
+ * TEXT — a catalog card's two-line summary, the search haystack. Passing the
2921
+ * raw string there printed the merchant's own `<p>` tags at the buyer, and
2922
+ * passing `textContent` of the parsed markup ran the block boundaries together.
2923
+ *
2924
+ * So the value is sanitized first (same allowlist as everything else — the
2925
+ * string is never parsed as live markup before DOMPurify has seen it) and then
2926
+ * flattened with block boundaries counting as whitespace. The result is
2927
+ * collapsed to single spaces because CSS line-clamping cannot do it for text.
2928
+ */
2929
+ declare function flattenMerchantHtmlToText(value: unknown): string;
2930
+ /**
2931
+ * True when the sanitized markup carries no renderable content.
2932
+ *
2933
+ * A description of `<p></p>` is not a description: the Easy lane's
2934
+ * `hasDescription` gate tests the SOURCE text, so a merchant who cleared their
2935
+ * editor without clearing its wrapper tags gets the empty state rather than a
2936
+ * blank panel.
2937
+ */
2938
+ /**
2939
+ * Drops every picture from merchant HTML.
2940
+ *
2941
+ * The merchant's "show images in descriptions" switch, applied at the same
2942
+ * place the document lane applies it (`stripDescriptionImages`): the shop asked
2943
+ * for text, so the description and every content tab arrive without their
2944
+ * `<img>` elements. Run BEFORE the sanitizer's output is injected, on the
2945
+ * sanitized string, so a tag the allowlist would have dropped anyway cannot
2946
+ * survive by looking like an image to this pass.
2947
+ */
2948
+ declare function stripMerchantHtmlImages(html: string): string;
2949
+ declare function isBlankMerchantHtml(html: string): boolean;
2950
+
2951
+ /**
2952
+ * Merchant CMS pages (dashboard → Pages), fetched by slug for `/page/:slug`.
2953
+ *
2954
+ * These are NOT in the injected catalog: the edge injects the storefront
2955
+ * payload, and the merchant's pages come from a separate service the SDK reads
2956
+ * (`getPage` / `getPages`). So the route loads them, and every state it can be
2957
+ * in is explicit — a slug that does not resolve renders the theme's not-found
2958
+ * copy rather than an empty article.
2959
+ *
2960
+ * The rendering rules are the Easy lane's, from `enrichCmsPageBlockSettings`
2961
+ * plus `blocks/page-tool-page.ts` → `renderCmsPage`:
2962
+ *
2963
+ * 1. `template_type === 'faq'` and `settings.faq_items` non-empty → the
2964
+ * accordion. A row needs BOTH a question and an answer to count, exactly
2965
+ * as `normalizeCmsFaqItems` requires.
2966
+ * 2. otherwise the body, rendered from MARKDOWN (gfm + breaks, the same
2967
+ * `marked` configuration `renderCmsPageMarkdownHtml` uses).
2968
+ * 3. otherwise the empty state.
2969
+ *
2970
+ * What this module does NOT do is sanitize. The markdown pass emits merchant
2971
+ * HTML — markdown may contain raw tags — and it is merchant HTML of exactly the
2972
+ * same status as `shop.termsHtml` or a product description, so it is sanitized
2973
+ * where it is injected, by the component that injects it. That is the rule the
2974
+ * whole template follows and the one its HTML-sink guard checks.
2975
+ */
2976
+ type ShopPageFaqItem = {
2977
+ question: string;
2978
+ answer: string;
2979
+ };
2980
+ /** One resolved CMS page, reduced to what the route renders. */
2981
+ interface ShopPageModel {
2982
+ title: string;
2983
+ /**
2984
+ * The markdown body as HTML — merchant markup, NOT yet sanitized. The route
2985
+ * sanitizes it at the sink; see the module docblock.
2986
+ */
2987
+ bodyMarkdownHtml: string;
2988
+ /** Non-empty only for a `faq` page with usable rows. */
2989
+ faqItems: ShopPageFaqItem[];
2990
+ }
2991
+ type ShopPageState = {
2992
+ status: 'loading';
2993
+ } | {
2994
+ status: 'ready';
2995
+ page: ShopPageModel;
2996
+ }
2997
+ /** The slug resolved to nothing, or the request failed. Same screen either way. */
2998
+ | {
2999
+ status: 'missing';
3000
+ };
3001
+ /**
3002
+ * Loads one CMS page by slug.
3003
+ *
3004
+ * A slug the API does not know, a page without a name, and a failed request all
3005
+ * end in `missing`: there is no page to show, and inventing a title from the
3006
+ * slug would publish a heading the merchant never wrote.
3007
+ */
3008
+ declare function loadShopPage(slug: string): Promise<ShopPageState>;
3009
+
3010
+ /**
3011
+ * The sentence that names the CAUSE rather than the symptom.
3012
+ *
3013
+ * A missing bootstrap global and a missing `__SHOPPEX_INITIAL__` payload are
3014
+ * the same fault seen from two sides: the page is not being served by the
3015
+ * worker that injects both. `App.tsx`'s configuration screen shows this to the
3016
+ * merchant, so it lives here once instead of being written twice.
3017
+ */
3018
+ declare const SERVING_CONTEXT_HINT = "This storefront must be served by the Shoppex storefront (or preview) worker.";
3019
+ declare function getShoppex(): ShoppexSdk;
3020
+ /**
3021
+ * The currency the prices on this page are in.
3022
+ *
3023
+ * The edge injected it alongside the catalog, from the same payload the prices
3024
+ * came from, so it is right whether the buyer asked for a currency, whether
3025
+ * their country selected one, or neither.
3026
+ *
3027
+ * Pass it to every quote and checkout. Left out, the SDK falls back to reading
3028
+ * `?currency=` off `window.location` — which survives the first page load and
3029
+ * nothing after it, because client-side navigation drops the query. The cart
3030
+ * then quoted in the shop's geo-resolved currency while the page went on
3031
+ * showing the one the buyer asked for.
3032
+ */
3033
+ declare function getBuyerCurrency(): string;
3034
+
3035
+ interface StorefrontData {
3036
+ settings: StorefrontSettings;
3037
+ shop: StorefrontShop;
3038
+ catalog: StorefrontCatalog;
3039
+ }
3040
+ declare function readStorefrontData(): StorefrontData;
3041
+ declare function StorefrontProvider({ data, children }: {
3042
+ data: StorefrontData;
3043
+ children: ReactNode;
3044
+ }): react_jsx_runtime.JSX.Element;
3045
+ declare function useStorefront(): StorefrontData;
3046
+ /**
3047
+ * Live visitor count for the header pill.
3048
+ *
3049
+ * Starts at null — "not answered yet" — and the pill renders nothing until a
3050
+ * real number arrives. A placeholder zero would flash "0 online" on every page
3051
+ * load, and a placeholder guess would be a fabricated claim. A failed request
3052
+ * leaves it null and the pill simply stays away.
3053
+ */
3054
+ declare function useOnlineUserCount(): number | null;
3055
+
3056
+ /**
3057
+ * The merchant's product video, turned into an embeddable `src` — and gated.
3058
+ *
3059
+ * `products.video_link` is a raw string the merchant typed: a YouTube watch URL,
3060
+ * a bare video id, a Streamable short link, a Vimeo page. The Easy lane resolves
3061
+ * it through `resolveVideoEmbedSrc`
3062
+ * (`packages/builder-runtime/src/video-embed.ts`, called by the storefront
3063
+ * render context) and then renders the iframe ONLY when the resolved host is on
3064
+ * the gallery block's allowlist (`blocks/gallery.ts`, ADR-0045).
3065
+ *
3066
+ * Both halves are ported here, in that order, because they answer different
3067
+ * questions. The parser decides which provider a merchant meant; the allowlist
3068
+ * decides whether the storefront is willing to frame it at all. A host that is
3069
+ * not on the list renders NOTHING — never the raw string in an iframe, which is
3070
+ * what turns a merchant-editable text column into an arbitrary embed.
3071
+ *
3072
+ * Ported rather than imported: the theme builds standalone from
3073
+ * `templates/code-storefronts/`, with no workspace dependency on the renderer.
3074
+ * Keep the allowlist and the provider rules in step with the two files above.
3075
+ */
3076
+ /**
3077
+ * The iframe `src` for a merchant's `video_link`, or null when there is nothing
3078
+ * this storefront is willing to frame.
3079
+ *
3080
+ * Privacy-enhanced YouTube (`youtube-nocookie.com`) because that is what the
3081
+ * render context asks for (`resolveVideoEmbedSrc(..., { privacyEnhanced: true })`)
3082
+ * — a code storefront must not set more cookies on a buyer than the Easy lane
3083
+ * for the same merchant's video.
3084
+ */
3085
+ declare function resolveProductVideoEmbedSrc(value: unknown): string | null;
3086
+
3087
+ export { ACCOUNT_ARTIFACT_CAPABILITIES, ACCOUNT_PATH, ACCOUNT_PATH_ALIAS, ACCOUNT_ROUTE_MESSAGE_KEYS, ACCOUNT_STOREFRONT_ROUTES, ACCOUNT_TABS, type AccountRouteMessageKey, type AccountRouteParts, type AccountTab, type AccountView, type BuyBoxVariantModel, CATALOG_PAGE_SIZE, CATALOG_SORTS, CATALOG_SORT_LABELS, type CartBoundsHeal, CartProvider, type CartQuoteLineBounds, type CartTotals, type CartView, type CartViewLine, type CatalogCardModel, type CatalogPage, type CatalogSort, type CheckoutFieldGroup, type CheckoutFieldValues, type CheckoutFields, type ContactFormValues, type ContactSubmissionContext, DEFAULT_ACCOUNT_TAB, DEFAULT_LOW_STOCK_THRESHOLD, DELIVERY_TIME_UNITS, type DeliveryPromise, type DeliveryTimeUnit, type GroupCardModel, IMPLEMENTED_ACCOUNT_TABS, type ImplementedAccountTab, type LicenseStatement, type LicenseTerm, type LinePricing, MAX_PAY_WHAT_YOU_WANT_PRICE, type MenuNavLink, type MenuSlot, type NavLink, type NavigationLinks, PRODUCT_BADGE_ICON_NAMES, type ProductAddon, type ProductAutomaticDeal, type ProductBadge, type ProductBadgeIconName, type ProductBundle, type ProductBundleItem, type ProductCardModel, type ProductCategoryRef, type ProductContentTab, type ProductCustomField, type ProductDetailModel, type ProductFaq, type ProductRatingModel, type ProductSubscription, type ProductType, QUOTE_UNAVAILABLE_CHECKOUT_ERROR, type RatingStars, type ResellerProgramSummary, type RewardProgramItem, type RewardProgramSummary, SERVING_CONTEXT_HINT, SHOP_REVIEW_PAGE_SIZE, STOREFRONT_SORT_KEYS, type SdkCartAddOptions, type SdkCartAddon, type SdkCartAppliedDiscount, type SdkCartCodeSource, type SdkCartItem, type SdkCartQuote, type SdkCartQuoteLine, type SdkCheckoutResult, type SdkCustomerAffiliateLink, type SdkCustomerAffiliateSummary, type SdkCustomerDashboard, type SdkCustomerDashboardOrder, type SdkCustomerDeliverable, type SdkCustomerDeliveryStatus, type SdkCustomerDeliverySummary, type SdkCustomerLicense, type SdkCustomerLoyalty, type SdkCustomerLoyaltyRedeem, type SdkCustomerLoyaltyTransactionType, type SdkCustomerOrderDetail, type SdkCustomerOrderPage, type SdkCustomerOrderSummary, type SdkCustomerRewardActivity, type SdkCustomerRewards, type SdkCustomerSubscription, type SdkCustomerTicketSummary, type SdkCustomerWarranty, type SdkCustomerWarrantyClaim, type SdkCustomerWarrantyClaimStatus, type SdkCustomerWarrantyList, type SdkCustomerWarrantyStatus, type SdkEligibleCartDeal, type SdkInitOptions, type SdkMenu, type SdkMenuItem, type SdkPage, type SdkRecentSaleEntry, type SdkResponse, type SdkShopReview, type ShopPageFaqItem, type ShopPageModel, type ShopPageState, type ShopReview, type ShopReviewLabels, type ShopReviewsState, type ShoppexSdk, type SocialLink, type SocialNetwork, type StockChip, type StockTone, type StorefrontAddon, type StorefrontCatalog, type StorefrontCategory, type StorefrontData, type StorefrontGroup, type StorefrontListingItem, type StorefrontPriceVariant, type StorefrontProduct, StorefrontProvider, type StorefrontSettings, type StorefrontShop, type StorefrontShopStats, type StorefrontSortKey, type SubscriptionIntervalUnit, type SupportChannel, type VolumeDiscountRule, accountOrderHref, accountRouteMessage, accountTabHref, accountTabLabelKey, averageRating, badgesForCard, badgesForProductPage, buildCartTotals, buildCartView, buildCustomFieldPayload, buildInitialCheckoutFieldValues, buildProductDetail, buildStorefrontCatalog, calculateDisplayLineTotal, calculateLinePricing, canonicalAccountPath, checkoutFieldKey, checkoutFieldWriteRepriced, checkoutNeedsDetails, clampQuantity, clearRecoveredQuoteCheckoutError, filterCatalogCards, findStorefrontProduct, firstOptionalString, flattenMerchantHtmlToText, formatCompactCount, formatDeliveryTime, formatLicenseTerm, formatMoney, formatSubscriptionCadence, formatSubscriptionTrial, getAuthoredContent, getBuyerCurrency, getProductGroups, getProducts, getShoppex, getStorefrontAddons, getStorefrontCategories, getStorefrontListing, getStorefrontSettings, getStorefrontShop, hasInjectedStorefrontData, isAccountTab, isBlankMerchantHtml, isCheckboxValueChecked, isImplementedAccountTab, isProductSoldOut, isRecord, isSelectionSoldOut, loadShopPage, missingRequiredCheckoutValues, optionalBoolean, optionalString, optionalStringWithDefault, paginateCatalogCards, parseAccountPath, parseCartQuoteLines, parseEnum, parseFiniteNumber, parseProductBadges, parseStorefrontSortKey, readCheckoutNeedsDetails, readStorefrontData, requireArray, requireBoolean, requireNumber, requireRecord, requireString, requireStringFromKeys, requireUnknownRecord, resolveAccountView, resolveBadgeForeground, resolveCartBoundsHeals, resolveCategorySelection, resolveCheckoutFields, resolveDeliveryPromise, resolveDisplayStock, resolveHideStockCounter, resolveLicenseTerm, resolveMenuLinksForSlot, resolvePayWhatYouWantMinimum, resolveProductVideoEmbedSrc, resolveQuantityBounds, resolveSupportChannels, resolveUnsellableLineIds, resolveVariantStock, reviewsForProduct, roundCurrency, sanitizeMerchantHtml, sortCatalogCards, startCartMutation, stripMerchantHtmlImages, submitContactTicket, toShopReviews, toStarCount, useCart, useNavigationLinks, useOnlineUserCount, useShopReviews, useStorefront, validateCheckoutFieldValue, validateCheckoutFields, validateContactSubmission };