@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.
- package/LICENSE +21 -21
- package/package.json +4 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +181 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +56 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/payment-badges.tsx +36 -96
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/icons/cartbase-mark.ts +9 -0
- package/src/common/icons/payment-marks.ts +73 -0
- package/src/common/icons/social-marks.ts +61 -0
- package/src/common/index.ts +35 -20
- package/src/common/market-select.tsx +57 -57
- package/src/common/payment-icons.tsx +54 -0
- package/src/common/powered-by-cartbase.tsx +47 -0
- package/src/common/social-links.tsx +65 -0
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -0
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -0
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/store/labels.ts +10 -0
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
package/src/api/gift-cards.ts
CHANGED
|
@@ -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/
|
|
66
|
-
"/api/store/
|
|
67
|
-
"/api/store/
|
|
68
|
-
"/api/store/
|
|
69
|
-
"/api/store/
|
|
70
|
-
"/api/store/
|
|
71
|
-
"/api/store/
|
|
72
|
-
"/api/store/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
cache
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!config.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
...(this.config.
|
|
121
|
-
|
|
122
|
-
:
|
|
123
|
-
|
|
124
|
-
...(opts.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
+
}
|