@cartbase/storefront 0.14.0 → 0.16.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 (46) hide show
  1. package/README.md +9 -3
  2. package/package.json +4 -1
  3. package/src/api/customers.ts +9 -13
  4. package/src/api/products.ts +330 -310
  5. package/src/cart-drawer/context.tsx +11 -1
  6. package/src/cart-drawer/item/variant.tsx +8 -3
  7. package/src/checkout/checkout-client.tsx +10 -1
  8. package/src/checkout/line-item-card.tsx +5 -2
  9. package/src/checkout/order-summary.tsx +9 -5
  10. package/src/common/country-select.tsx +11 -65
  11. package/src/common/index.ts +2 -0
  12. package/src/common/market-select.tsx +57 -0
  13. package/src/lib/variant-caption.ts +32 -0
  14. package/src/locales/bg.ts +3 -0
  15. package/src/locales/context.ts +37 -0
  16. package/src/locales/es.ts +3 -0
  17. package/src/locales/provider.tsx +17 -21
  18. package/src/locales/types.ts +24 -21
  19. package/src/order/order-completed-template.tsx +10 -4
  20. package/src/order/order-item.tsx +4 -4
  21. package/src/products/index.ts +14 -0
  22. package/src/products/labels.ts +7 -0
  23. package/src/products/product-actions-wrapper.tsx +62 -58
  24. package/src/products/product-actions.tsx +104 -177
  25. package/src/products/product-info.tsx +10 -5
  26. package/src/products/product-template.tsx +162 -147
  27. package/src/products/related-products.tsx +9 -9
  28. package/src/products/use-product-actions.ts +164 -0
  29. package/src/products/variant-url.ts +74 -0
  30. package/src/reviews-ui/photo-upload.tsx +2 -1
  31. package/src/reviews-ui/review-list.tsx +4 -2
  32. package/src/reviews-ui/review-widget.tsx +4 -1
  33. package/src/reviews-ui/review-wizard.tsx +3 -2
  34. package/src/store/category-template.tsx +8 -1
  35. package/src/store/collection-template.tsx +8 -1
  36. package/src/store/paginated-products.tsx +3 -2
  37. package/src/store/search-template.tsx +10 -4
  38. package/src/store/store-template.tsx +10 -4
  39. package/src/tracking/chatgpt-pixel.tsx +17 -10
  40. package/src/tracking/consent-init.tsx +62 -44
  41. package/src/tracking/consent.ts +58 -7
  42. package/src/tracking/index.ts +2 -0
  43. package/src/tracking/meta-pixel.tsx +27 -15
  44. package/src/tracking/storefront-tags.tsx +14 -7
  45. package/src/tracking/tiktok-pixel.tsx +15 -7
  46. package/src/tracking/types.ts +5 -2
@@ -1,310 +1,330 @@
1
- /**
2
- * @cartbase/storefront/api/products — products + product variants.
3
- *
4
- * The catalog read surface. Response shapes are the canonical store product
5
- * object every discovery endpoint reuses (search results, collection
6
- * membership pages, related products) — build ONE product-card renderer
7
- * against `StoreProduct` and reuse it everywhere.
8
- *
9
- * Pricing: pass `currency_code` (or `region_id`) to receive
10
- * `variant.calculated_price` (group-aware when a customer JWT is attached).
11
- * Raw `prices` embeds contain BASE rows only — price-list rows are stripped
12
- * server-side (leak rule) and surface exclusively through
13
- * `calculated_price`. Docs: docs/storefront/products.md.
14
- */
15
- import type { StorefrontClient } from "./http"
16
- import type {
17
- CalculatedPrice,
18
- IsoDateString,
19
- ListEnvelope,
20
- PaginationQuery,
21
- PricingContextQuery,
22
- } from "./types"
23
-
24
- /** Raw base price row (`prices` table). Amounts are EUR-style major units. */
25
- export interface StorePriceRow {
26
- id: string
27
- amount: number
28
- currency_code: string
29
- min_quantity: number | null
30
- max_quantity: number | null
31
- price_set_id: string
32
- /** Always null on the store surface — price-list rows are stripped. */
33
- price_list_id: string | null
34
- created_at: IsoDateString
35
- updated_at: IsoDateString
36
- }
37
-
38
- /**
39
- * The wire shape of `variant.prices`: a price-set LINK embed, not a flat
40
- * array. Base rows live at `prices[].price_set.prices[]`.
41
- */
42
- export interface StoreVariantPriceSetLink {
43
- price_set: { prices: StorePriceRow[] | null } | null
44
- }
45
-
46
- /** `variant.options[]` — the option VALUE with its parent option embedded. */
47
- export interface StoreVariantOptionValueLink {
48
- value: {
49
- id: string
50
- value: string
51
- option_id: string
52
- metadata: Record<string, unknown> | null
53
- option: StoreProductOption | null
54
- } | null
55
- }
56
-
57
- export interface StoreProductOptionValue {
58
- id: string
59
- value: string
60
- option_id: string
61
- metadata: Record<string, unknown> | null
62
- created_at: IsoDateString
63
- updated_at: IsoDateString
64
- }
65
-
66
- export interface StoreProductOption {
67
- id: string
68
- title: string
69
- product_id: string
70
- metadata: Record<string, unknown> | null
71
- created_at: IsoDateString
72
- updated_at: IsoDateString
73
- /** Present on `product.options[]`; absent inside variant option links. */
74
- values?: StoreProductOptionValue[]
75
- }
76
-
77
- export interface StoreProductImage {
78
- id: string
79
- url: string
80
- rank: number
81
- product_id: string
82
- metadata: Record<string, unknown> | null
83
- created_at: IsoDateString
84
- updated_at: IsoDateString
85
- }
86
-
87
- export interface StoreProductVariant {
88
- id: string
89
- title: string
90
- product_id: string
91
- sku: string | null
92
- barcode: string | null
93
- ean: string | null
94
- upc: string | null
95
- thumbnail: string | null
96
- allow_backorder: boolean
97
- manage_inventory: boolean
98
- variant_rank: number
99
- weight: number | null
100
- length: number | null
101
- height: number | null
102
- width: number | null
103
- hs_code: string | null
104
- origin_country: string | null
105
- mid_code: string | null
106
- material: string | null
107
- metadata: Record<string, unknown> | null
108
- created_at: IsoDateString
109
- updated_at: IsoDateString
110
- options: StoreVariantOptionValueLink[]
111
- /** Base-price rows via the price-set link embed (leak rule applied). */
112
- prices: StoreVariantPriceSetLink[]
113
- /**
114
- * Present when a pricing context was resolvable (`currency_code` or
115
- * `region_id` given); null otherwise, and null when no price matches the
116
- * context. Varies by customer group never cache shared.
117
- */
118
- calculated_price: CalculatedPrice | null
119
- /**
120
- * THE availability predicate, computed server-side (untracked or
121
- * backorderable ⇒ true; otherwise kit-aware available stock > 0).
122
- * Optional only for wire back-compat with platforms that predate it —
123
- * when absent, components fall back to the optimistic legacy behavior.
124
- */
125
- in_stock?: boolean
126
- }
127
-
128
- export interface StoreProduct {
129
- id: string
130
- title: string
131
- subtitle: string | null
132
- description: string | null
133
- handle: string
134
- status: "published"
135
- thumbnail: string | null
136
- is_giftcard: boolean
137
- discountable: boolean
138
- collection_id: string | null
139
- type_id: string | null
140
- external_id: string | null
141
- weight: number | null
142
- length: number | null
143
- height: number | null
144
- width: number | null
145
- hs_code: string | null
146
- origin_country: string | null
147
- mid_code: string | null
148
- material: string | null
149
- /** SEO override; null = fall back to `title`. */
150
- seo_title: string | null
151
- /** SEO override; null = fall back to the plain-text `description`. */
152
- seo_description: string | null
153
- metadata: Record<string, unknown> | null
154
- created_at: IsoDateString
155
- updated_at: IsoDateString
156
- variants: StoreProductVariant[]
157
- images: StoreProductImage[]
158
- options: StoreProductOption[]
159
- collection: { id: string; title: string; handle: string } | null
160
- categories: Array<{ category: { id: string; name: string; handle: string } | null }>
161
- tags: Array<{ tag: { id: string; value: string } | null }>
162
- type: { id: string; value: string } | null
163
- }
164
-
165
- export interface ListProductsQuery extends PricingContextQuery, PaginationQuery {
166
- /** Case-insensitive substring match on title (full search: use /search). */
167
- q?: string
168
- /** Product ids (joined as CSV on the wire). */
169
- id?: string[]
170
- handle?: string
171
- collection_id?: string[]
172
- category_id?: string[]
173
- tag_id?: string[]
174
- type_id?: string[]
175
- /**
176
- * Sort column; prefix with `-` for descending (e.g. `-created_at`,
177
- * the default).
178
- */
179
- order?: string
180
- }
181
-
182
- export interface ProductListResponse extends ListEnvelope {
183
- products: StoreProduct[]
184
- }
185
-
186
- export interface ProductResponse {
187
- product: StoreProduct
188
- }
189
-
190
- export interface ListProductVariantsQuery extends PricingContextQuery, PaginationQuery {
191
- /** Variant ids as CSV in ONE string (wire format of the route). */
192
- id?: string
193
- product_id?: string
194
- sku?: string
195
- }
196
-
197
- export interface ProductVariantListResponse extends ListEnvelope {
198
- variants: StoreProductVariant[]
199
- }
200
-
201
- export interface ProductVariantResponse {
202
- variant: StoreProductVariant
203
- }
204
-
205
- /**
206
- * List published products (newest first by default).
207
- *
208
- * Auth: anon (`x-client-id`); optional `x-publishable-api-key` restricts the
209
- * catalog to products linked to the key's sales channels (a channel-scoped
210
- * key with no matching products returns an empty list). Optional Bearer JWT
211
- * makes `calculated_price` customer-group-aware.
212
- *
213
- * Errors: 400 `missing_client_id`, 400 `validation_failed`,
214
- * 400 `invalid_publishable_key`, 400 `invalid_region`.
215
- * Settings: price lists (B2B pricing), sales-channel product links,
216
- * publishable-key channel bindings.
217
- */
218
- export async function listProducts(
219
- client: StorefrontClient,
220
- query?: ListProductsQuery
221
- ): Promise<ProductListResponse> {
222
- return client.get("/api/store/products", { query: { ...query } })
223
- }
224
-
225
- /**
226
- * Retrieve one product by id (`prod_…`) or handle — storefronts deep-link by
227
- * handle.
228
- *
229
- * Auth: anon (`x-client-id`); optional publishable key + Bearer JWT as on
230
- * the list. Errors: 404 `not_found` (unknown, draft RLS-hidden or
231
- * outside the key's channels: invisible, not forbidden),
232
- * 400 `invalid_publishable_key`, 400 `invalid_region`.
233
- */
234
- export async function retrieveProduct(
235
- client: StorefrontClient,
236
- idOrHandle: string,
237
- query?: PricingContextQuery
238
- ): Promise<ProductResponse> {
239
- return client.get(`/api/store/products/${encodeURIComponent(idOrHandle)}`, {
240
- query: { ...query },
241
- })
242
- }
243
-
244
- /**
245
- * List product variants (cart-line hydration). `id` accepts a CSV of variant
246
- * ids for multi-lookup. Ordered by `variant_rank`.
247
- *
248
- * Auth: anon (`x-client-id`); optional Bearer JWT for group-aware
249
- * `calculated_price`. NOTE: this listing is NOT publishable-key
250
- * channel-scoped (scope applies to product reads). Errors:
251
- * 400 `missing_client_id`, 400 `validation_failed`, 400 `invalid_region`.
252
- */
253
- export async function listProductVariants(
254
- client: StorefrontClient,
255
- query?: ListProductVariantsQuery
256
- ): Promise<ProductVariantListResponse> {
257
- return client.get("/api/store/product-variants", { query: { ...query } })
258
- }
259
-
260
- /**
261
- * Retrieve one variant by id, options + base prices hydrated,
262
- * `calculated_price` when a pricing context is given.
263
- *
264
- * Auth: anon (`x-client-id`). Errors: 404 `not_found`, 400 `invalid_region`.
265
- */
266
- export async function retrieveProductVariant(
267
- client: StorefrontClient,
268
- variantId: string,
269
- query?: PricingContextQuery
270
- ): Promise<ProductVariantResponse> {
271
- return client.get(`/api/store/product-variants/${encodeURIComponent(variantId)}`, {
272
- query: { ...query },
273
- })
274
- }
275
-
276
- /** A subscription offer on a product (PDP purchase options). */
277
- export interface StoreSellingPlan {
278
- id: string
279
- name: string
280
- interval: "day" | "week" | "month" | "year"
281
- interval_count: number
282
- /** null = catalog price (the plan only sets the cadence). */
283
- pricing_type: "percent" | "fixed" | null
284
- pricing_value: number | null
285
- min_cycles: number | null
286
- max_cycles: number | null
287
- }
288
-
289
- export interface SellingPlanListResponse {
290
- selling_plans: StoreSellingPlan[]
291
- count: number
292
- }
293
-
294
- /**
295
- * The subscription plans a product can be purchased with — render as PDP
296
- * purchase options (one-time vs each plan). The chosen plan id goes on the
297
- * cart line (`carts.addLineItem` `selling_plan_id`); the server applies the
298
- * plan price. Empty list = product is one-time only.
299
- *
300
- * Auth: anon (`x-client-id`); publishable-key channel scope applies (same
301
- * rule as the product read). Errors: 404 `not_found`.
302
- */
303
- export async function listSellingPlans(
304
- client: StorefrontClient,
305
- idOrHandle: string
306
- ): Promise<SellingPlanListResponse> {
307
- return client.get(
308
- `/api/store/products/${encodeURIComponent(idOrHandle)}/selling-plans`
309
- )
310
- }
1
+ /**
2
+ * @cartbase/storefront/api/products — products + product variants.
3
+ *
4
+ * The catalog read surface. Response shapes are the canonical store product
5
+ * object every discovery endpoint reuses (search results, collection
6
+ * membership pages, related products) — build ONE product-card renderer
7
+ * against `StoreProduct` and reuse it everywhere.
8
+ *
9
+ * Pricing: pass `currency_code` (or `region_id`) to receive
10
+ * `variant.calculated_price` (group-aware when a customer JWT is attached).
11
+ * Raw `prices` embeds contain BASE rows only — price-list rows are stripped
12
+ * server-side (leak rule) and surface exclusively through
13
+ * `calculated_price`. Docs: docs/storefront/products.md.
14
+ */
15
+ import type { StorefrontClient } from "./http"
16
+ import type {
17
+ CalculatedPrice,
18
+ IsoDateString,
19
+ ListEnvelope,
20
+ PaginationQuery,
21
+ PricingContextQuery,
22
+ } from "./types"
23
+
24
+ /** Raw base price row (`prices` table). Amounts are EUR-style major units. */
25
+ export interface StorePriceRow {
26
+ id: string
27
+ amount: number
28
+ currency_code: string
29
+ min_quantity: number | null
30
+ max_quantity: number | null
31
+ price_set_id: string
32
+ /** Always null on the store surface — price-list rows are stripped. */
33
+ price_list_id: string | null
34
+ created_at: IsoDateString
35
+ updated_at: IsoDateString
36
+ }
37
+
38
+ /**
39
+ * The wire shape of `variant.prices`: a price-set LINK embed, not a flat
40
+ * array. Base rows live at `prices[].price_set.prices[]`.
41
+ */
42
+ export interface StoreVariantPriceSetLink {
43
+ price_set: { prices: StorePriceRow[] | null } | null
44
+ }
45
+
46
+ /** `variant.options[]` — the option VALUE with its parent option embedded. */
47
+ export interface StoreVariantOptionValueLink {
48
+ value: {
49
+ id: string
50
+ value: string
51
+ option_id: string
52
+ metadata: Record<string, unknown> | null
53
+ option: StoreProductOption | null
54
+ } | null
55
+ }
56
+
57
+ export interface StoreProductOptionValue {
58
+ id: string
59
+ value: string
60
+ option_id: string
61
+ metadata: Record<string, unknown> | null
62
+ created_at: IsoDateString
63
+ updated_at: IsoDateString
64
+ }
65
+
66
+ export interface StoreProductOption {
67
+ id: string
68
+ title: string
69
+ product_id: string
70
+ /** The merchant's order of the options on the product page. */
71
+ position: number
72
+ metadata: Record<string, unknown> | null
73
+ created_at: IsoDateString
74
+ updated_at: IsoDateString
75
+ /** Present on `product.options[]`; absent inside variant option links. */
76
+ values?: StoreProductOptionValue[]
77
+ }
78
+
79
+ export interface StoreProductImage {
80
+ id: string
81
+ url: string
82
+ rank: number
83
+ product_id: string
84
+ metadata: Record<string, unknown> | null
85
+ created_at: IsoDateString
86
+ updated_at: IsoDateString
87
+ }
88
+
89
+ export interface StoreProductVariant {
90
+ id: string
91
+ title: string
92
+ product_id: string
93
+ sku: string | null
94
+ barcode: string | null
95
+ ean: string | null
96
+ upc: string | null
97
+ thumbnail: string | null
98
+ allow_backorder: boolean
99
+ manage_inventory: boolean
100
+ variant_rank: number
101
+ weight: number | null
102
+ length: number | null
103
+ height: number | null
104
+ width: number | null
105
+ hs_code: string | null
106
+ origin_country: string | null
107
+ mid_code: string | null
108
+ material: string | null
109
+ metadata: Record<string, unknown> | null
110
+ created_at: IsoDateString
111
+ updated_at: IsoDateString
112
+ options: StoreVariantOptionValueLink[]
113
+ /** Base-price rows via the price-set link embed (leak rule applied). */
114
+ prices: StoreVariantPriceSetLink[]
115
+ /**
116
+ * Present when a pricing context was resolvable (`currency_code` or
117
+ * `region_id` given); null otherwise, and null when no price matches the
118
+ * context. Varies by customer group — never cache shared.
119
+ */
120
+ calculated_price: CalculatedPrice | null
121
+ /**
122
+ * THE availability predicate, computed server-side (untracked or
123
+ * backorderable true; otherwise kit-aware available stock > 0).
124
+ * Optional only for wire back-compat with platforms that predate it —
125
+ * when absent, components fall back to the optimistic legacy behavior.
126
+ */
127
+ in_stock?: boolean
128
+ }
129
+
130
+ export interface StoreProduct {
131
+ id: string
132
+ title: string
133
+ subtitle: string | null
134
+ description: string | null
135
+ handle: string
136
+ status: "published"
137
+ thumbnail: string | null
138
+ is_giftcard: boolean
139
+ discountable: boolean
140
+ type_id: string | null
141
+ external_id: string | null
142
+ /** The brand, as plain text on the product (product-vendor card). */
143
+ vendor: string | null
144
+ weight: number | null
145
+ length: number | null
146
+ height: number | null
147
+ width: number | null
148
+ hs_code: string | null
149
+ origin_country: string | null
150
+ mid_code: string | null
151
+ material: string | null
152
+ /** SEO override; null = fall back to `title`. */
153
+ seo_title: string | null
154
+ /** SEO override; null = fall back to the plain-text `description`. */
155
+ seo_description: string | null
156
+ metadata: Record<string, unknown> | null
157
+ /**
158
+ * The store's custom fields under their definition keys
159
+ * (`custom.product_label_1`), values as stored: a string for text, a
160
+ * number, a boolean, an array for a list, an id for a metaobject
161
+ * reference. Public definitions only (Settings, Custom fields switches one
162
+ * off); always present, `{}` when the product has none. Served by every
163
+ * product read since 2026-09-14 (docs/storefront/products.md).
164
+ */
165
+ metafields: Record<string, unknown>
166
+ created_at: IsoDateString
167
+ updated_at: IsoDateString
168
+ variants: StoreProductVariant[]
169
+ images: StoreProductImage[]
170
+ options: StoreProductOption[]
171
+ /**
172
+ * EVERY collection the product belongs to, in the shape categories and
173
+ * tags already had. It was a single `collection` object resolved through
174
+ * products.collection_id, a column that held one of a product's
175
+ * collections at most and none at all for the ones a catalogue migration
176
+ * or a smart rule placed; the membership join is the one store since
177
+ * 2026-09-15 (docs/storefront/products.md).
178
+ */
179
+ collections: Array<{ collection: { id: string; title: string; handle: string } | null }>
180
+ categories: Array<{ category: { id: string; name: string; handle: string } | null }>
181
+ tags: Array<{ tag: { id: string; value: string } | null }>
182
+ type: { id: string; value: string } | null
183
+ }
184
+
185
+ export interface ListProductsQuery extends PricingContextQuery, PaginationQuery {
186
+ /** Case-insensitive substring match on title (full search: use /search). */
187
+ q?: string
188
+ /** Product ids (joined as CSV on the wire). */
189
+ id?: string[]
190
+ handle?: string
191
+ collection_id?: string[]
192
+ category_id?: string[]
193
+ tag_id?: string[]
194
+ type_id?: string[]
195
+ /**
196
+ * Sort column; prefix with `-` for descending (e.g. `-created_at`,
197
+ * the default).
198
+ */
199
+ order?: string
200
+ }
201
+
202
+ export interface ProductListResponse extends ListEnvelope {
203
+ products: StoreProduct[]
204
+ }
205
+
206
+ export interface ProductResponse {
207
+ product: StoreProduct
208
+ }
209
+
210
+ export interface ListProductVariantsQuery extends PricingContextQuery, PaginationQuery {
211
+ /** Variant ids as CSV in ONE string (wire format of the route). */
212
+ id?: string
213
+ product_id?: string
214
+ sku?: string
215
+ }
216
+
217
+ export interface ProductVariantListResponse extends ListEnvelope {
218
+ variants: StoreProductVariant[]
219
+ }
220
+
221
+ export interface ProductVariantResponse {
222
+ variant: StoreProductVariant
223
+ }
224
+
225
+ /**
226
+ * List published products (newest first by default).
227
+ *
228
+ * Auth: anon (`x-client-id`); optional `x-publishable-api-key` restricts the
229
+ * catalog to products linked to the key's sales channels (a channel-scoped
230
+ * key with no matching products returns an empty list). Optional Bearer JWT
231
+ * makes `calculated_price` customer-group-aware.
232
+ *
233
+ * Errors: 400 `missing_client_id`, 400 `validation_failed`,
234
+ * 400 `invalid_publishable_key`, 400 `invalid_region`.
235
+ * Settings: price lists (B2B pricing), sales-channel product links,
236
+ * publishable-key channel bindings.
237
+ */
238
+ export async function listProducts(
239
+ client: StorefrontClient,
240
+ query?: ListProductsQuery
241
+ ): Promise<ProductListResponse> {
242
+ return client.get("/api/store/products", { query: { ...query } })
243
+ }
244
+
245
+ /**
246
+ * Retrieve one product by id (`prod_…`) or handle — storefronts deep-link by
247
+ * handle.
248
+ *
249
+ * Auth: anon (`x-client-id`); optional publishable key + Bearer JWT as on
250
+ * the list. Errors: 404 `not_found` (unknown, draft RLS-hidden or
251
+ * outside the key's channels: invisible, not forbidden),
252
+ * 400 `invalid_publishable_key`, 400 `invalid_region`.
253
+ */
254
+ export async function retrieveProduct(
255
+ client: StorefrontClient,
256
+ idOrHandle: string,
257
+ query?: PricingContextQuery
258
+ ): Promise<ProductResponse> {
259
+ return client.get(`/api/store/products/${encodeURIComponent(idOrHandle)}`, {
260
+ query: { ...query },
261
+ })
262
+ }
263
+
264
+ /**
265
+ * List product variants (cart-line hydration). `id` accepts a CSV of variant
266
+ * ids for multi-lookup. Ordered by `variant_rank`.
267
+ *
268
+ * Auth: anon (`x-client-id`); optional Bearer JWT for group-aware
269
+ * `calculated_price`. NOTE: this listing is NOT publishable-key
270
+ * channel-scoped (scope applies to product reads). Errors:
271
+ * 400 `missing_client_id`, 400 `validation_failed`, 400 `invalid_region`.
272
+ */
273
+ export async function listProductVariants(
274
+ client: StorefrontClient,
275
+ query?: ListProductVariantsQuery
276
+ ): Promise<ProductVariantListResponse> {
277
+ return client.get("/api/store/product-variants", { query: { ...query } })
278
+ }
279
+
280
+ /**
281
+ * Retrieve one variant by id, options + base prices hydrated,
282
+ * `calculated_price` when a pricing context is given.
283
+ *
284
+ * Auth: anon (`x-client-id`). Errors: 404 `not_found`, 400 `invalid_region`.
285
+ */
286
+ export async function retrieveProductVariant(
287
+ client: StorefrontClient,
288
+ variantId: string,
289
+ query?: PricingContextQuery
290
+ ): Promise<ProductVariantResponse> {
291
+ return client.get(`/api/store/product-variants/${encodeURIComponent(variantId)}`, {
292
+ query: { ...query },
293
+ })
294
+ }
295
+
296
+ /** A subscription offer on a product (PDP purchase options). */
297
+ export interface StoreSellingPlan {
298
+ id: string
299
+ name: string
300
+ interval: "day" | "week" | "month" | "year"
301
+ interval_count: number
302
+ /** null = catalog price (the plan only sets the cadence). */
303
+ pricing_type: "percent" | "fixed" | null
304
+ pricing_value: number | null
305
+ min_cycles: number | null
306
+ max_cycles: number | null
307
+ }
308
+
309
+ export interface SellingPlanListResponse {
310
+ selling_plans: StoreSellingPlan[]
311
+ count: number
312
+ }
313
+
314
+ /**
315
+ * The subscription plans a product can be purchased with — render as PDP
316
+ * purchase options (one-time vs each plan). The chosen plan id goes on the
317
+ * cart line (`carts.addLineItem` `selling_plan_id`); the server applies the
318
+ * plan price. Empty list = product is one-time only.
319
+ *
320
+ * Auth: anon (`x-client-id`); publishable-key channel scope applies (same
321
+ * rule as the product read). Errors: 404 `not_found`.
322
+ */
323
+ export async function listSellingPlans(
324
+ client: StorefrontClient,
325
+ idOrHandle: string
326
+ ): Promise<SellingPlanListResponse> {
327
+ return client.get(
328
+ `/api/store/products/${encodeURIComponent(idOrHandle)}/selling-plans`
329
+ )
330
+ }