@cartbase/storefront 0.19.0 → 0.20.1

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 (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  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 +180 -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/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  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/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. 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,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/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/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
+ }
package/src/api/index.ts CHANGED
@@ -1,30 +1,30 @@
1
- /**
2
- * @cartbase/storefront/api — barrel.
3
- *
4
- * PRE-DECLARED SEAM: every domain module line below is written up front so
5
- * fleet agents create their module file WITHOUT editing this barrel (no
6
- * union-merge seams). Do not reorder; append new domains at the end.
7
- */
8
-
9
- export * from "./types"
10
- export * from "./http"
11
-
12
- export * as products from "./products"
13
- export * as collections from "./collections"
14
- export * as categories from "./categories"
15
- export * as regions from "./regions"
16
- export * as store from "./store"
17
- export * as carts from "./carts"
18
- export * as giftCards from "./gift-cards"
19
- export * as checkout from "./checkout"
20
- export * as orders from "./orders"
21
- export * as customers from "./customers"
22
- export * as auth from "./auth"
23
- export * as content from "./content"
24
- export * as menus from "./menus"
25
- export * as metaobjects from "./metaobjects"
26
- export * as reviews from "./reviews"
27
- export * as search from "./search"
28
- export * as integrations from "./integrations"
29
- export * as consent from "./consent"
30
- export * as redirects from "./redirects"
1
+ /**
2
+ * @cartbase/storefront/api — barrel.
3
+ *
4
+ * PRE-DECLARED SEAM: every domain module line below is written up front so
5
+ * fleet agents create their module file WITHOUT editing this barrel (no
6
+ * union-merge seams). Do not reorder; append new domains at the end.
7
+ */
8
+
9
+ export * from "./types"
10
+ export * from "./http"
11
+
12
+ export * as products from "./products"
13
+ export * as collections from "./collections"
14
+ export * as categories from "./categories"
15
+ export * as regions from "./regions"
16
+ export * as store from "./store"
17
+ export * as carts from "./carts"
18
+ export * as giftCards from "./gift-cards"
19
+ export * as checkout from "./checkout"
20
+ export * as orders from "./orders"
21
+ export * as customers from "./customers"
22
+ export * as auth from "./auth"
23
+ export * as content from "./content"
24
+ export * as menus from "./menus"
25
+ export * as metaobjects from "./metaobjects"
26
+ export * as reviews from "./reviews"
27
+ export * as search from "./search"
28
+ export * as integrations from "./integrations"
29
+ export * as consent from "./consent"
30
+ export * as redirects from "./redirects"