@cartbase/storefront 0.20.0 → 0.21.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +181 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +56 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
@@ -1,112 +1,112 @@
1
- /**
2
- * @cartbase/storefront/api/gift-cards — gift-card TENDER on carts.
3
- *
4
- * Redemption is a payment tender (`pp_giftcard`), never a discount: cart
5
- * totals/VAT compute first and NEVER move; applied cards cover part (or all)
6
- * of `cart.total` and the remainder provider (Stripe/COD/manual) charges
7
- * only what is left. The cart decoration fields (`gift_cards[]`,
8
- * `gift_card_total`, `gift_card_remainder` — declared on the `Cart` DTO in
9
- * `./carts`) are derived from the LIVE transactions ledger at every read.
10
- *
11
- * Route ground truth: src/app/api/store/carts/[id]/gift-cards/route.ts +
12
- * src/lib/gift-cards/{redeem,tender}.ts. Doc: docs/storefront/gift-cards.md.
13
- *
14
- * Security model baked into the contract:
15
- * - failure is ONE generic answer — unknown, disabled, expired, depleted
16
- * and foreign-tenant codes are all `400 invalid_gift_card` (no
17
- * code-existence oracle);
18
- * - attempts are recorded BEFORE lookup and rate-limited per cart
19
- * (10 / 15 min) AND per IP (30 / 15 min) → `429 rate_limited`;
20
- * - the confirmation is MASKED (`last4` only) — the code is never echoed.
21
- */
22
- import type { StorefrontClient } from "./http"
23
- import type { MajorUnitAmount } from "./types"
24
- import type { Cart } from "./carts"
25
-
26
- // ---------------------------------------------------------------------------
27
- // DTOs
28
- // ---------------------------------------------------------------------------
29
-
30
- /** Body of POST /api/store/carts/:id/gift-cards (`.strict()`). */
31
- export interface ApplyGiftCardInput {
32
- /** The plaintext code (4–64 chars). Hashed at rest server-side. */
33
- code: string
34
- }
35
-
36
- /** Body of DELETE /api/store/carts/:id/gift-cards (`.strict()`). */
37
- export interface RemoveGiftCardInput {
38
- /** The applied card's id (from `cart.gift_cards[].id` or the apply response). */
39
- gift_card_id: string
40
- }
41
-
42
- /** Masked apply confirmation. */
43
- export interface AppliedGiftCardConfirmation {
44
- id: string
45
- /** Last 4 characters of the code — the full code is never echoed. */
46
- last4: string
47
- /**
48
- * How much of the cart total THIS card covers right now
49
- * (`min(live balance, remaining total)` in apply order).
50
- */
51
- amount_applied: MajorUnitAmount
52
- }
53
-
54
- export interface ApplyGiftCardResponse {
55
- /** Decorated cart — `gift_cards[]` / `gift_card_total` / `gift_card_remainder` updated; `total` untouched. */
56
- cart: Cart
57
- gift_card: AppliedGiftCardConfirmation
58
- }
59
-
60
- export interface RemoveGiftCardResponse {
61
- cart: Cart
62
- }
63
-
64
- // ---------------------------------------------------------------------------
65
- // Functions
66
- // ---------------------------------------------------------------------------
67
-
68
- /**
69
- * Apply a gift-card code to an open cart (idempotent re-apply). → 200
70
- * `{cart, gift_card}` with a MASKED confirmation.
71
- *
72
- * Auth: anon `x-client-id`.
73
- * Errors: 400 `invalid_gift_card` (the deliberate generic answer for
74
- * unknown/disabled/expired/depleted/foreign-tenant codes), 429
75
- * `rate_limited` (per-cart and per-IP attempt windows — a valid code inside
76
- * a burned window is refused the same way), 404 `cart_not_found`, 409
77
- * `cart_completed`, 400 `validation_failed`.
78
- *
79
- * Behavior: also syncs the internal `pp_giftcard` payment session to the
80
- * covered amount when a payment collection exists (no-op before one does —
81
- * creating the collection composes it). Actual balance redemption happens
82
- * ONLY at cart complete, atomically; a card drained elsewhere in the
83
- * meantime shrinks this cart's tender at the next read instead of
84
- * over-redeeming.
85
- * Settings: gift cards are issued/disabled from the admin; expiry and
86
- * balance live on the card's ledger.
87
- */
88
- export async function applyGiftCard(
89
- client: StorefrontClient,
90
- cartId: string,
91
- input: ApplyGiftCardInput
92
- ): Promise<ApplyGiftCardResponse> {
93
- return client.post(`/api/store/carts/${cartId}/gift-cards`, input)
94
- }
95
-
96
- /**
97
- * Remove an applied gift card from an open cart (idempotent). → 200 `{cart}`.
98
- *
99
- * Auth: anon `x-client-id`.
100
- * Errors: 404 `cart_not_found`, 409 `cart_completed`, 400
101
- * `validation_failed`.
102
- * Behavior: re-syncs the `pp_giftcard` session to the remaining tender
103
- * (removing the last card zeroes it), so the remainder provider's session
104
- * re-inflates to the full total on the next amount sync.
105
- */
106
- export async function removeGiftCard(
107
- client: StorefrontClient,
108
- cartId: string,
109
- input: RemoveGiftCardInput
110
- ): Promise<RemoveGiftCardResponse> {
111
- return client.delete(`/api/store/carts/${cartId}/gift-cards`, input)
112
- }
1
+ /**
2
+ * @cartbase/storefront/api/gift-cards — gift-card TENDER on carts.
3
+ *
4
+ * Redemption is a payment tender (`pp_giftcard`), never a discount: cart
5
+ * totals/VAT compute first and NEVER move; applied cards cover part (or all)
6
+ * of `cart.total` and the remainder provider (Stripe/COD/manual) charges
7
+ * only what is left. The cart decoration fields (`gift_cards[]`,
8
+ * `gift_card_total`, `gift_card_remainder` — declared on the `Cart` DTO in
9
+ * `./carts`) are derived from the LIVE transactions ledger at every read.
10
+ *
11
+ * Route ground truth: src/app/api/store/carts/[id]/gift-cards/route.ts +
12
+ * src/lib/gift-cards/{redeem,tender}.ts. Doc: docs/storefront/gift-cards.md.
13
+ *
14
+ * Security model baked into the contract:
15
+ * - failure is ONE generic answer — unknown, disabled, expired, depleted
16
+ * and foreign-tenant codes are all `400 invalid_gift_card` (no
17
+ * code-existence oracle);
18
+ * - attempts are recorded BEFORE lookup and rate-limited per cart
19
+ * (10 / 15 min) AND per IP (30 / 15 min) → `429 rate_limited`;
20
+ * - the confirmation is MASKED (`last4` only) — the code is never echoed.
21
+ */
22
+ import type { StorefrontClient } from "./http"
23
+ import type { MajorUnitAmount } from "./types"
24
+ import type { Cart } from "./carts"
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // DTOs
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Body of POST /api/store/carts/:id/gift-cards (`.strict()`). */
31
+ export interface ApplyGiftCardInput {
32
+ /** The plaintext code (4–64 chars). Hashed at rest server-side. */
33
+ code: string
34
+ }
35
+
36
+ /** Body of DELETE /api/store/carts/:id/gift-cards (`.strict()`). */
37
+ export interface RemoveGiftCardInput {
38
+ /** The applied card's id (from `cart.gift_cards[].id` or the apply response). */
39
+ gift_card_id: string
40
+ }
41
+
42
+ /** Masked apply confirmation. */
43
+ export interface AppliedGiftCardConfirmation {
44
+ id: string
45
+ /** Last 4 characters of the code — the full code is never echoed. */
46
+ last4: string
47
+ /**
48
+ * How much of the cart total THIS card covers right now
49
+ * (`min(live balance, remaining total)` in apply order).
50
+ */
51
+ amount_applied: MajorUnitAmount
52
+ }
53
+
54
+ export interface ApplyGiftCardResponse {
55
+ /** Decorated cart — `gift_cards[]` / `gift_card_total` / `gift_card_remainder` updated; `total` untouched. */
56
+ cart: Cart
57
+ gift_card: AppliedGiftCardConfirmation
58
+ }
59
+
60
+ export interface RemoveGiftCardResponse {
61
+ cart: Cart
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Functions
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Apply a gift-card code to an open cart (idempotent re-apply). → 200
70
+ * `{cart, gift_card}` with a MASKED confirmation.
71
+ *
72
+ * Auth: anon `x-client-id`.
73
+ * Errors: 400 `invalid_gift_card` (the deliberate generic answer for
74
+ * unknown/disabled/expired/depleted/foreign-tenant codes), 429
75
+ * `rate_limited` (per-cart and per-IP attempt windows — a valid code inside
76
+ * a burned window is refused the same way), 404 `cart_not_found`, 409
77
+ * `cart_completed`, 400 `validation_failed`.
78
+ *
79
+ * Behavior: also syncs the internal `pp_giftcard` payment session to the
80
+ * covered amount when a payment collection exists (no-op before one does —
81
+ * creating the collection composes it). Actual balance redemption happens
82
+ * ONLY at cart complete, atomically; a card drained elsewhere in the
83
+ * meantime shrinks this cart's tender at the next read instead of
84
+ * over-redeeming.
85
+ * Settings: gift cards are issued/disabled from the admin; expiry and
86
+ * balance live on the card's ledger.
87
+ */
88
+ export async function applyGiftCard(
89
+ client: StorefrontClient,
90
+ cartId: string,
91
+ input: ApplyGiftCardInput
92
+ ): Promise<ApplyGiftCardResponse> {
93
+ return client.post(`/api/store/carts/${cartId}/gift-cards`, input)
94
+ }
95
+
96
+ /**
97
+ * Remove an applied gift card from an open cart (idempotent). → 200 `{cart}`.
98
+ *
99
+ * Auth: anon `x-client-id`.
100
+ * Errors: 404 `cart_not_found`, 409 `cart_completed`, 400
101
+ * `validation_failed`.
102
+ * Behavior: re-syncs the `pp_giftcard` session to the remaining tender
103
+ * (removing the last card zeroes it), so the remainder provider's session
104
+ * re-inflates to the full total on the next amount sync.
105
+ */
106
+ export async function removeGiftCard(
107
+ client: StorefrontClient,
108
+ cartId: string,
109
+ input: RemoveGiftCardInput
110
+ ): Promise<RemoveGiftCardResponse> {
111
+ return client.delete(`/api/store/carts/${cartId}/gift-cards`, input)
112
+ }
package/src/api/http.ts CHANGED
@@ -1,180 +1,181 @@
1
- /**
2
- * @cartbase/storefront — the ONE http seam.
3
- *
4
- * Every SDK function in this package is a thin typed wrapper over
5
- * `storeFetch()` from a `StorefrontClient`. Auth model (store-api.md):
6
- *
7
- * - `x-publishable-api-key` — THE store's key: names the store on its own
8
- * (one key, 2026-09-07) and scopes catalog and
9
- * carts to the key's sales channels
10
- * - `x-client-id` — the platform's own door (hosted builds carry it);
11
- * optional, wins over the key when both are sent
12
- * - `authorization: Bearer <jwt>` — customer session (passwordless code
13
- * flow or supabase password login)
14
- * - `x-locale` — optional storefront locale hint
15
- *
16
- * The client is isomorphic (browser + RSC/server actions) — it holds no
17
- * global state; construct once per request scope on the server, once per
18
- * app on the client.
19
- */
20
-
21
- import { StoreApiError } from "./types"
22
-
23
- export interface StorefrontClientConfig {
24
- /** Deployment origin, e.g. `https://admin.cartbase.ai` */
25
- baseUrl: string
26
- /**
27
- * The store's publishable key: names the store and scopes the catalog to
28
- * the key's sales channels. The one input a merchant hands out; at least
29
- * one of `publishableKey` and `clientId` is required.
30
- */
31
- publishableKey?: string
32
- /** Tenant id — the platform's own door. Optional when a key is given. */
33
- clientId?: string
34
- /** Called per request; return the customer JWT or null for guests. */
35
- getAuthToken?: () => string | null | Promise<string | null>
36
- /** Called per request; return the active locale code or null. */
37
- getLocale?: () => string | null | Promise<string | null>
38
- /** Override fetch (tests, custom caching). Defaults to global fetch. */
39
- fetch?: typeof fetch
40
- }
41
-
42
- export type QueryValue = string | number | boolean | null | undefined
43
- export type Query = Record<string, QueryValue | QueryValue[]>
44
-
45
- /**
46
- * The reads the platform's CDN holds for anonymous shoppers (store-api.md
47
- * § Caching). Such a request carries the store's publishable key in its
48
- * address as `pk`, so the edge can tell one store's answers from another's,
49
- * and the shopper's locale as `locale`. A request with a customer token
50
- * carries neither: its prices are the shopper's own, and an address without
51
- * the key is never held and never served from the hold. Carts, checkout,
52
- * consent, customers and every write are never on this list.
53
- */
54
- const CACHEABLE_READ_PREFIXES = [
55
- "/api/store/store",
56
- "/api/store/menus",
57
- "/api/store/regions",
58
- "/api/store/collections",
59
- "/api/store/products",
60
- "/api/store/product-variants",
61
- "/api/store/product-categories",
62
- "/api/store/product-types",
63
- "/api/store/product-tags",
64
- "/api/store/reviews",
65
- "/api/store/currencies",
66
- "/api/store/countries",
67
- "/api/store/locales",
68
- "/api/store/pages",
69
- "/api/store/blogs",
70
- "/api/store/metaobjects",
71
- "/api/store/url-redirects",
72
- "/api/store/return-reasons",
73
- ]
74
-
75
- export function isCacheableStoreRead(pathname: string): boolean {
76
- return CACHEABLE_READ_PREFIXES.some(
77
- (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
78
- )
79
- }
80
-
81
- export interface RequestOptions {
82
- method?: "GET" | "POST" | "DELETE"
83
- query?: Query
84
- body?: unknown
85
- headers?: Record<string, string>
86
- /** Passed through to fetch — lets Next.js RSC callers tag cache behavior. */
87
- cache?: RequestCache
88
- next?: { revalidate?: number | false; tags?: string[] }
89
- signal?: AbortSignal
90
- }
91
-
92
- export class StorefrontClient {
93
- constructor(private readonly config: StorefrontClientConfig) {
94
- if (!config.baseUrl) throw new Error("StorefrontClient: baseUrl is required")
95
- if (!config.publishableKey && !config.clientId) {
96
- throw new Error("StorefrontClient: publishableKey (or clientId) is required")
97
- }
98
- }
99
-
100
- /** The low-level typed request. Domain modules call this — apps rarely should. */
101
- async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
102
- const url = new URL(
103
- path.replace(/^\//, ""),
104
- this.config.baseUrl.replace(/\/?$/, "/")
105
- )
106
- if (opts.query) {
107
- for (const [key, value] of Object.entries(opts.query)) {
108
- if (value === undefined || value === null) continue
109
- if (Array.isArray(value)) {
110
- const joined = value.filter((v) => v !== undefined && v !== null).join(",")
111
- if (joined) url.searchParams.set(key, joined)
112
- } else {
113
- url.searchParams.set(key, String(value))
114
- }
115
- }
116
- }
117
-
118
- const headers: Record<string, string> = {
119
- ...(this.config.clientId ? { "x-client-id": this.config.clientId } : {}),
120
- ...(this.config.publishableKey
121
- ? { "x-publishable-api-key": this.config.publishableKey }
122
- : {}),
123
- ...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
124
- ...(opts.headers ?? {}),
125
- }
126
- const token = this.config.getAuthToken ? await this.config.getAuthToken() : null
127
- if (token && !headers.authorization) headers.authorization = `Bearer ${token}`
128
- const locale = this.config.getLocale ? await this.config.getLocale() : null
129
- if (locale && !headers["x-locale"]) headers["x-locale"] = locale
130
-
131
- // An anonymous catalogue read carries its key (and locale) in the
132
- // address, so the platform's CDN may hold the answer for this store.
133
- const method = opts.method ?? "GET"
134
- const anonymous = !headers.authorization
135
- if (
136
- method === "GET" &&
137
- anonymous &&
138
- this.config.publishableKey &&
139
- isCacheableStoreRead(url.pathname)
140
- ) {
141
- url.searchParams.set("pk", this.config.publishableKey)
142
- if (locale) url.searchParams.set("locale", locale)
143
- }
144
-
145
- const doFetch = this.config.fetch ?? fetch
146
- const res = await doFetch(url.toString(), {
147
- method,
148
- headers,
149
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
150
- cache: opts.cache,
151
- ...(opts.next ? { next: opts.next } : {}),
152
- signal: opts.signal,
153
- } as RequestInit)
154
-
155
- if (!res.ok) {
156
- let detail: unknown
157
- const text = await res.text()
158
- try {
159
- detail = JSON.parse(text)
160
- } catch {
161
- detail = text
162
- }
163
- throw new StoreApiError(res.status, detail)
164
- }
165
- if (res.status === 204) return undefined as T
166
- return (await res.json()) as T
167
- }
168
-
169
- get<T>(path: string, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
170
- return this.request<T>(path, { ...opts, method: "GET" })
171
- }
172
-
173
- post<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
174
- return this.request<T>(path, { ...opts, method: "POST", body })
175
- }
176
-
177
- delete<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
178
- return this.request<T>(path, { ...opts, method: "DELETE", body })
179
- }
180
- }
1
+ /**
2
+ * @cartbase/storefront — the ONE http seam.
3
+ *
4
+ * Every SDK function in this package is a thin typed wrapper over
5
+ * `storeFetch()` from a `StorefrontClient`. Auth model (store-api.md):
6
+ *
7
+ * - `x-publishable-api-key` — THE store's key: names the store on its own
8
+ * (one key, 2026-09-07) and scopes catalog and
9
+ * carts to the key's sales channels
10
+ * - `x-client-id` — the platform's own door (hosted builds carry it);
11
+ * optional, wins over the key when both are sent
12
+ * - `authorization: Bearer <jwt>` — customer session (passwordless code
13
+ * flow or supabase password login)
14
+ * - `x-locale` — optional storefront locale hint
15
+ *
16
+ * The client is isomorphic (browser + RSC/server actions) — it holds no
17
+ * global state; construct once per request scope on the server, once per
18
+ * app on the client.
19
+ */
20
+
21
+ import { StoreApiError } from "./types"
22
+
23
+ export interface StorefrontClientConfig {
24
+ /** Deployment origin, e.g. `https://admin.cartbase.ai` */
25
+ baseUrl: string
26
+ /**
27
+ * The store's publishable key: names the store and scopes the catalog to
28
+ * the key's sales channels. The one input a merchant hands out; at least
29
+ * one of `publishableKey` and `clientId` is required.
30
+ */
31
+ publishableKey?: string
32
+ /** Tenant id — the platform's own door. Optional when a key is given. */
33
+ clientId?: string
34
+ /** Called per request; return the customer JWT or null for guests. */
35
+ getAuthToken?: () => string | null | Promise<string | null>
36
+ /** Called per request; return the active locale code or null. */
37
+ getLocale?: () => string | null | Promise<string | null>
38
+ /** Override fetch (tests, custom caching). Defaults to global fetch. */
39
+ fetch?: typeof fetch
40
+ }
41
+
42
+ export type QueryValue = string | number | boolean | null | undefined
43
+ export type Query = Record<string, QueryValue | QueryValue[]>
44
+
45
+ /**
46
+ * The reads the platform's CDN holds for anonymous shoppers (store-api.md
47
+ * § Caching). Such a request carries the store's publishable key in its
48
+ * address as `pk`, so the edge can tell one store's answers from another's,
49
+ * and the shopper's locale as `locale`. A request with a customer token
50
+ * carries neither: its prices are the shopper's own, and an address without
51
+ * the key is never held and never served from the hold. Carts, checkout,
52
+ * consent, customers and every write are never on this list.
53
+ */
54
+ const CACHEABLE_READ_PREFIXES = [
55
+ "/api/store/store",
56
+ "/api/store/menus",
57
+ "/api/store/regions",
58
+ "/api/store/collections",
59
+ "/api/store/products",
60
+ "/api/store/product-variants",
61
+ "/api/store/product-categories",
62
+ "/api/store/product-types",
63
+ "/api/store/product-tags",
64
+ "/api/store/reviews",
65
+ "/api/store/payment-marks",
66
+ "/api/store/currencies",
67
+ "/api/store/countries",
68
+ "/api/store/locales",
69
+ "/api/store/pages",
70
+ "/api/store/blogs",
71
+ "/api/store/metaobjects",
72
+ "/api/store/url-redirects",
73
+ "/api/store/return-reasons",
74
+ ]
75
+
76
+ export function isCacheableStoreRead(pathname: string): boolean {
77
+ return CACHEABLE_READ_PREFIXES.some(
78
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)
79
+ )
80
+ }
81
+
82
+ export interface RequestOptions {
83
+ method?: "GET" | "POST" | "DELETE"
84
+ query?: Query
85
+ body?: unknown
86
+ headers?: Record<string, string>
87
+ /** Passed through to fetch — lets Next.js RSC callers tag cache behavior. */
88
+ cache?: RequestCache
89
+ next?: { revalidate?: number | false; tags?: string[] }
90
+ signal?: AbortSignal
91
+ }
92
+
93
+ export class StorefrontClient {
94
+ constructor(private readonly config: StorefrontClientConfig) {
95
+ if (!config.baseUrl) throw new Error("StorefrontClient: baseUrl is required")
96
+ if (!config.publishableKey && !config.clientId) {
97
+ throw new Error("StorefrontClient: publishableKey (or clientId) is required")
98
+ }
99
+ }
100
+
101
+ /** The low-level typed request. Domain modules call this apps rarely should. */
102
+ async request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
103
+ const url = new URL(
104
+ path.replace(/^\//, ""),
105
+ this.config.baseUrl.replace(/\/?$/, "/")
106
+ )
107
+ if (opts.query) {
108
+ for (const [key, value] of Object.entries(opts.query)) {
109
+ if (value === undefined || value === null) continue
110
+ if (Array.isArray(value)) {
111
+ const joined = value.filter((v) => v !== undefined && v !== null).join(",")
112
+ if (joined) url.searchParams.set(key, joined)
113
+ } else {
114
+ url.searchParams.set(key, String(value))
115
+ }
116
+ }
117
+ }
118
+
119
+ const headers: Record<string, string> = {
120
+ ...(this.config.clientId ? { "x-client-id": this.config.clientId } : {}),
121
+ ...(this.config.publishableKey
122
+ ? { "x-publishable-api-key": this.config.publishableKey }
123
+ : {}),
124
+ ...(opts.body !== undefined ? { "content-type": "application/json" } : {}),
125
+ ...(opts.headers ?? {}),
126
+ }
127
+ const token = this.config.getAuthToken ? await this.config.getAuthToken() : null
128
+ if (token && !headers.authorization) headers.authorization = `Bearer ${token}`
129
+ const locale = this.config.getLocale ? await this.config.getLocale() : null
130
+ if (locale && !headers["x-locale"]) headers["x-locale"] = locale
131
+
132
+ // An anonymous catalogue read carries its key (and locale) in the
133
+ // address, so the platform's CDN may hold the answer for this store.
134
+ const method = opts.method ?? "GET"
135
+ const anonymous = !headers.authorization
136
+ if (
137
+ method === "GET" &&
138
+ anonymous &&
139
+ this.config.publishableKey &&
140
+ isCacheableStoreRead(url.pathname)
141
+ ) {
142
+ url.searchParams.set("pk", this.config.publishableKey)
143
+ if (locale) url.searchParams.set("locale", locale)
144
+ }
145
+
146
+ const doFetch = this.config.fetch ?? fetch
147
+ const res = await doFetch(url.toString(), {
148
+ method,
149
+ headers,
150
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
151
+ cache: opts.cache,
152
+ ...(opts.next ? { next: opts.next } : {}),
153
+ signal: opts.signal,
154
+ } as RequestInit)
155
+
156
+ if (!res.ok) {
157
+ let detail: unknown
158
+ const text = await res.text()
159
+ try {
160
+ detail = JSON.parse(text)
161
+ } catch {
162
+ detail = text
163
+ }
164
+ throw new StoreApiError(res.status, detail)
165
+ }
166
+ if (res.status === 204) return undefined as T
167
+ return (await res.json()) as T
168
+ }
169
+
170
+ get<T>(path: string, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
171
+ return this.request<T>(path, { ...opts, method: "GET" })
172
+ }
173
+
174
+ post<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
175
+ return this.request<T>(path, { ...opts, method: "POST", body })
176
+ }
177
+
178
+ delete<T>(path: string, body?: unknown, opts: Omit<RequestOptions, "method" | "body"> = {}): Promise<T> {
179
+ return this.request<T>(path, { ...opts, method: "DELETE", body })
180
+ }
181
+ }