create-cartbase 0.1.14 → 0.1.16
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/package.json +1 -1
- package/template/app/docs/collections.md +167 -167
- package/template/app/docs/components.md +1294 -1246
- package/template/app/docs/customers.md +3 -4
- package/template/app/docs/products.md +316 -307
- package/template/app/next.config.ts +6 -4
- package/template/app/package.json +1 -1
- package/template/app/src/app/products/[handle]/page.tsx +9 -1
|
@@ -1,307 +1,316 @@
|
|
|
1
|
-
# Products
|
|
2
|
-
|
|
3
|
-
The catalog read surface. The product object documented here is THE
|
|
4
|
-
canonical shape every discovery endpoint reuses (search results, collection
|
|
5
|
-
membership pages, related products) — build one product-card renderer
|
|
6
|
-
against it. Money is EUR decimal major units.
|
|
7
|
-
|
|
8
|
-
SDK module: `@cartbase/storefront/api/products`.
|
|
9
|
-
|
|
10
|
-
**Pricing context (applies to every endpoint here):** pass `currency_code`
|
|
11
|
-
(or `region_id` — its region's currency is used; unknown region → 400
|
|
12
|
-
`invalid_region`) to receive `variant.calculated_price`. Without either,
|
|
13
|
-
`calculated_price` is `null` and only raw base prices are listed. Customer
|
|
14
|
-
groups come ONLY from the optional `authorization: Bearer <jwt>` — never
|
|
15
|
-
from params. **Cache rule:** any response carrying `calculated_price` varies
|
|
16
|
-
by customer group — never cache it shared when a JWT was present.
|
|
17
|
-
|
|
18
|
-
**Leak rule:** raw price rows in store responses contain ONLY base
|
|
19
|
-
(non-price-list) rows. Price-list amounts surface exclusively through
|
|
20
|
-
`calculated_price` for the caller's own context.
|
|
21
|
-
|
|
22
|
-
**Publishable-key channel scope:** sending `x-publishable-api-key` restricts
|
|
23
|
-
the catalog to products linked to the key's sales channels — a product
|
|
24
|
-
outside them **404s on retrieve and disappears from lists** (invisible, not
|
|
25
|
-
forbidden). A key with no channel links scopes nothing. Unknown/revoked/
|
|
26
|
-
foreign key → 400 `invalid_publishable_key`.
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## GET /api/store/products
|
|
31
|
-
|
|
32
|
-
- **Purpose** — the product listing: catalog pages, filtered grids.
|
|
33
|
-
Published products only (drafts are RLS-invisible).
|
|
34
|
-
- **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
|
|
35
|
-
(channel scope); `authorization: Bearer <jwt>` optional (group pricing).
|
|
36
|
-
- **Request**
|
|
37
|
-
|
|
38
|
-
```jsonc
|
|
39
|
-
// query (all optional)
|
|
40
|
-
{
|
|
41
|
-
"q": "linen", // case-insensitive substring on TITLE only
|
|
42
|
-
"id": "prod_a,prod_b", // CSV of ids
|
|
43
|
-
"handle": "linen-shirt", // exact
|
|
44
|
-
"collection_id": "pcol_a", // CSV —
|
|
45
|
-
"category_id": "pcat_a", // CSV — category membership
|
|
46
|
-
"tag_id": "ptag_a", // CSV — tag membership
|
|
47
|
-
"type_id": "ptyp_a", // CSV
|
|
48
|
-
"order": "-created_at", // sort column, "-" prefix = desc (default -created_at)
|
|
49
|
-
"currency_code": "eur", // pricing context
|
|
50
|
-
"region_id": "reg_…", // or region → currency
|
|
51
|
-
"limit": 50, // 1–200, default 50
|
|
52
|
-
"offset": 0
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
- **Response** — `{ products, count, offset, limit }`; each product:
|
|
57
|
-
|
|
58
|
-
```jsonc
|
|
59
|
-
{
|
|
60
|
-
"id": "prod_01tst00000000000000000001",
|
|
61
|
-
"title": "Linen Shirt",
|
|
62
|
-
"subtitle": null,
|
|
63
|
-
"description": "Lightweight linen shirt, relaxed fit.",
|
|
64
|
-
"handle": "linen-shirt",
|
|
65
|
-
"status": "published", // always — drafts never appear
|
|
66
|
-
"thumbnail": "https://…/600/800",
|
|
67
|
-
"is_giftcard": false,
|
|
68
|
-
"discountable": true,
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
"
|
|
88
|
-
"
|
|
89
|
-
"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
```bash
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
echo "$PRODUCT" |
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
curl -
|
|
199
|
-
-H "x-client-id: $CLIENT_ID"
|
|
200
|
-
STATUS
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
-
|
|
237
|
-
|
|
238
|
-
- **
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
- **
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
- **
|
|
293
|
-
- **
|
|
294
|
-
- **
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
- **
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
-
|
|
1
|
+
# Products
|
|
2
|
+
|
|
3
|
+
The catalog read surface. The product object documented here is THE
|
|
4
|
+
canonical shape every discovery endpoint reuses (search results, collection
|
|
5
|
+
membership pages, related products) — build one product-card renderer
|
|
6
|
+
against it. Money is EUR decimal major units.
|
|
7
|
+
|
|
8
|
+
SDK module: `@cartbase/storefront/api/products`.
|
|
9
|
+
|
|
10
|
+
**Pricing context (applies to every endpoint here):** pass `currency_code`
|
|
11
|
+
(or `region_id` — its region's currency is used; unknown region → 400
|
|
12
|
+
`invalid_region`) to receive `variant.calculated_price`. Without either,
|
|
13
|
+
`calculated_price` is `null` and only raw base prices are listed. Customer
|
|
14
|
+
groups come ONLY from the optional `authorization: Bearer <jwt>` — never
|
|
15
|
+
from params. **Cache rule:** any response carrying `calculated_price` varies
|
|
16
|
+
by customer group — never cache it shared when a JWT was present.
|
|
17
|
+
|
|
18
|
+
**Leak rule:** raw price rows in store responses contain ONLY base
|
|
19
|
+
(non-price-list) rows. Price-list amounts surface exclusively through
|
|
20
|
+
`calculated_price` for the caller's own context.
|
|
21
|
+
|
|
22
|
+
**Publishable-key channel scope:** sending `x-publishable-api-key` restricts
|
|
23
|
+
the catalog to products linked to the key's sales channels — a product
|
|
24
|
+
outside them **404s on retrieve and disappears from lists** (invisible, not
|
|
25
|
+
forbidden). A key with no channel links scopes nothing. Unknown/revoked/
|
|
26
|
+
foreign key → 400 `invalid_publishable_key`.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## GET /api/store/products
|
|
31
|
+
|
|
32
|
+
- **Purpose** — the product listing: catalog pages, filtered grids.
|
|
33
|
+
Published products only (drafts are RLS-invisible).
|
|
34
|
+
- **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
|
|
35
|
+
(channel scope); `authorization: Bearer <jwt>` optional (group pricing).
|
|
36
|
+
- **Request**
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
// query (all optional)
|
|
40
|
+
{
|
|
41
|
+
"q": "linen", // case-insensitive substring on TITLE only
|
|
42
|
+
"id": "prod_a,prod_b", // CSV of ids
|
|
43
|
+
"handle": "linen-shirt", // exact
|
|
44
|
+
"collection_id": "pcol_a", // CSV — collection membership (any of them)
|
|
45
|
+
"category_id": "pcat_a", // CSV — category membership
|
|
46
|
+
"tag_id": "ptag_a", // CSV — tag membership
|
|
47
|
+
"type_id": "ptyp_a", // CSV
|
|
48
|
+
"order": "-created_at", // sort column, "-" prefix = desc (default -created_at)
|
|
49
|
+
"currency_code": "eur", // pricing context
|
|
50
|
+
"region_id": "reg_…", // or region → currency
|
|
51
|
+
"limit": 50, // 1–200, default 50
|
|
52
|
+
"offset": 0
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- **Response** — `{ products, count, offset, limit }`; each product:
|
|
57
|
+
|
|
58
|
+
```jsonc
|
|
59
|
+
{
|
|
60
|
+
"id": "prod_01tst00000000000000000001",
|
|
61
|
+
"title": "Linen Shirt",
|
|
62
|
+
"subtitle": null,
|
|
63
|
+
"description": "Lightweight linen shirt, relaxed fit.",
|
|
64
|
+
"handle": "linen-shirt",
|
|
65
|
+
"status": "published", // always — drafts never appear
|
|
66
|
+
"thumbnail": "https://…/600/800",
|
|
67
|
+
"is_giftcard": false,
|
|
68
|
+
"discountable": true,
|
|
69
|
+
"type_id": null,
|
|
70
|
+
"external_id": null,
|
|
71
|
+
"vendor": null, // the brand, plain text
|
|
72
|
+
"weight": null, "length": null, "height": null, "width": null,
|
|
73
|
+
"hs_code": null, "origin_country": null, "mid_code": null, "material": null,
|
|
74
|
+
"seo_title": null, // null = fall back to title
|
|
75
|
+
"seo_description": null, // null = fall back to plain-text description
|
|
76
|
+
"metadata": null,
|
|
77
|
+
// The store's custom fields under their definition keys, values as stored
|
|
78
|
+
// (string, number, boolean, array for a list, id for a metaobject
|
|
79
|
+
// reference). Public definitions only: Settings, Custom fields switches a
|
|
80
|
+
// field off the storefront. Always present, {} when the product has none.
|
|
81
|
+
"metafields": { "custom.product_label_1": "500 мл", "custom.product_label_2": "Испания" },
|
|
82
|
+
"created_at": "2026-07-01T00:00:00.000Z",
|
|
83
|
+
"updated_at": "2026-07-01T00:00:00.000Z",
|
|
84
|
+
"variants": [
|
|
85
|
+
{
|
|
86
|
+
"id": "variant_01tst000000000000000001",
|
|
87
|
+
"title": "S",
|
|
88
|
+
"product_id": "prod_01tst00000000000000000001",
|
|
89
|
+
"sku": "LIN-SHIRT-S",
|
|
90
|
+
"barcode": null, "ean": null, "upc": null,
|
|
91
|
+
"thumbnail": null,
|
|
92
|
+
"allow_backorder": false,
|
|
93
|
+
"manage_inventory": true,
|
|
94
|
+
// Computed server-side by the platform's availability predicate:
|
|
95
|
+
// untracked or backorderable variants are always true; otherwise true
|
|
96
|
+
// iff available stock (kit-aware, reservations subtracted) is above
|
|
97
|
+
// zero. Read this — never re-derive stock client-side (the exact
|
|
98
|
+
// quantity is deliberately not exposed).
|
|
99
|
+
"in_stock": true,
|
|
100
|
+
"variant_rank": 0,
|
|
101
|
+
"metadata": null,
|
|
102
|
+
"options": [
|
|
103
|
+
{ "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
|
|
104
|
+
"option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
|
|
105
|
+
],
|
|
106
|
+
// Raw BASE prices via the price-set link embed (leak rule: price-list
|
|
107
|
+
// rows are stripped — price_list_id is always null here):
|
|
108
|
+
"prices": [
|
|
109
|
+
{ "price_set": { "prices": [
|
|
110
|
+
{ "id": "price_…", "amount": 45, "currency_code": "eur",
|
|
111
|
+
"min_quantity": null, "max_quantity": null,
|
|
112
|
+
"price_set_id": "pset_…", "price_list_id": null }
|
|
113
|
+
] } }
|
|
114
|
+
],
|
|
115
|
+
// Present when a pricing context was given; null otherwise or when no
|
|
116
|
+
// price matches. b2b-v1 shape (store-api.md §Pricing context):
|
|
117
|
+
"calculated_price": {
|
|
118
|
+
"calculated_amount": 45,
|
|
119
|
+
"original_amount": 45,
|
|
120
|
+
"currency_code": "eur",
|
|
121
|
+
"is_calculated_price_price_list": false,
|
|
122
|
+
"price_list_id": null,
|
|
123
|
+
"price_list_type": null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
|
|
128
|
+
"options": [ { "id": "opt_…", "title": "Size",
|
|
129
|
+
"values": [ { "id": "optv_…", "value": "S" } ] } ],
|
|
130
|
+
// EVERY collection the product is in, the shape categories and tags have.
|
|
131
|
+
// It was one `collection` object read off products.collection_id until
|
|
132
|
+
// 2026-09-15; that column held one of a product's collections at most.
|
|
133
|
+
"collections": [
|
|
134
|
+
{ "collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" } }
|
|
135
|
+
],
|
|
136
|
+
"categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
|
|
137
|
+
"tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
|
|
138
|
+
"type": null
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
- **Working curl**
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
|
|
146
|
+
echo "$PRODUCTS" | grep -q '"products"'
|
|
147
|
+
echo "$PRODUCTS" | grep -q '"count"'
|
|
148
|
+
# The handle filter answers deterministically regardless of catalog size.
|
|
149
|
+
curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
|
|
150
|
+
| grep -q '"handle":"linen-shirt"'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
|
|
154
|
+
400 `invalid_publishable_key`, 400 `invalid_region`.
|
|
155
|
+
- **SDK** — `listProducts(client, query?)`.
|
|
156
|
+
- **Components** — product card grid (components.md).
|
|
157
|
+
- **Settings** — sales-channel product links + publishable-key bindings
|
|
158
|
+
(channel scope), price lists (calculated_price).
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## GET /api/store/products/:idOrHandle
|
|
163
|
+
|
|
164
|
+
- **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
|
|
165
|
+
storefronts deep-link by handle.
|
|
166
|
+
- **Auth** — as the list.
|
|
167
|
+
- **Request** — query: pricing context only (`currency_code` / `region_id`).
|
|
168
|
+
- **Response** — `{ "product": { ...shape above... } }`
|
|
169
|
+
- **Working curl** — with pricing context; asserts the seeded base price
|
|
170
|
+
(Linen Shirt, EUR 45) and the leak rule structurally:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
|
|
174
|
+
-H "x-client-id: $CLIENT_ID")
|
|
175
|
+
echo "$PRODUCT" | grep -q '"product"'
|
|
176
|
+
echo "$PRODUCT" | grep -q '"calculated_price"'
|
|
177
|
+
echo "$PRODUCT" | grep -q '"calculated_amount":45'
|
|
178
|
+
echo "$PRODUCT" | grep -q '"seo_title"'
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
# Leak rule, asserted structurally: every raw price row is a BASE row
|
|
183
|
+
# (price_list_id null) — price-list amounts only ever ride calculated_price.
|
|
184
|
+
echo "$PRODUCT" | node -e "
|
|
185
|
+
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
|
|
186
|
+
const j=JSON.parse(Buffer.concat(c));
|
|
187
|
+
const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
|
|
188
|
+
if(!rows.length){console.error('no base price rows');process.exit(1)}
|
|
189
|
+
if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
|
|
190
|
+
})"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- **Errors** — 404 `not_found` (unknown handle/id, draft, soft-deleted, or
|
|
194
|
+
outside the publishable key's channels), 400 `invalid_publishable_key`,
|
|
195
|
+
400 `invalid_region`.
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
199
|
+
"$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
|
|
200
|
+
test "$STATUS" = 404
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
|
|
205
|
+
# catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
|
|
206
|
+
# works) but 404s under the key — invisible, not forbidden.
|
|
207
|
+
curl -sf "$BASE/api/store/products/leather-belt" \
|
|
208
|
+
-H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
|
|
209
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
210
|
+
"$BASE/api/store/products/leather-belt" \
|
|
211
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
|
|
212
|
+
test "$STATUS" = 404
|
|
213
|
+
curl -sf "$BASE/api/store/products/linen-shirt" \
|
|
214
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
|
|
215
|
+
| grep -q '"handle":"linen-shirt"'
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Unknown key → 400 invalid_publishable_key (never a silent unscoped read).
|
|
220
|
+
BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
|
|
221
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
|
|
222
|
+
echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- **SDK** — `retrieveProduct(client, idOrHandle, query?)`.
|
|
226
|
+
- **Components** — PDP family: gallery, option picker, price block.
|
|
227
|
+
- **Settings** — as the list; SEO overrides (Admin → Product → SEO).
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## GET /api/store/products/:idOrHandle/selling-plans
|
|
232
|
+
|
|
233
|
+
- **Purpose** — the subscription plans this product can be purchased with
|
|
234
|
+
(subscriptions add-on). Render as PDP purchase options (one-time vs each
|
|
235
|
+
plan); the chosen plan id goes on the cart line
|
|
236
|
+
(`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
|
|
237
|
+
the plan price. Empty list = one-time only.
|
|
238
|
+
- **Auth** — anon `x-client-id`; publishable-key channel scope applies
|
|
239
|
+
(same visibility rule as the product read).
|
|
240
|
+
- **Response** — `{ selling_plans: [{id, name, interval, interval_count,
|
|
241
|
+
pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
|
|
242
|
+
`pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
|
|
243
|
+
major units) | `null` (catalog price — the plan only sets the cadence);
|
|
244
|
+
enabled plans only, merchant-defined order.
|
|
245
|
+
- **Errors** — 404 `not_found` (same rules as the product read).
|
|
246
|
+
- **SDK** — `products.listSellingPlans(client, idOrHandle)`.
|
|
247
|
+
- **Components** — `PurchaseOptions` (products family): controlled radio
|
|
248
|
+
group, one-time + plans with display-only price preview
|
|
249
|
+
(`previewPlanPrice` mirrors the server's math; the server is truth).
|
|
250
|
+
NOTE: a cart with a plan line requires a logged-in customer at payment
|
|
251
|
+
(checkout.md `save_payment_method`) — surface login before checkout.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# The dev tenant seeds no plans — the endpoint contract still executes:
|
|
255
|
+
# a valid product answers with the envelope, an unknown product 404s.
|
|
256
|
+
SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
|
|
257
|
+
-H "x-client-id: $CLIENT_ID")
|
|
258
|
+
echo "$SPLANS" | grep -q '"selling_plans"'
|
|
259
|
+
echo "$SPLANS" | grep -q '"count"'
|
|
260
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
261
|
+
"$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
|
|
262
|
+
-H "x-client-id: $CLIENT_ID")
|
|
263
|
+
test "$STATUS" = 404
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## GET /api/store/product-variants
|
|
269
|
+
|
|
270
|
+
- **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
|
|
271
|
+
CSV of variant ids in ONE param. Ordered by `variant_rank`.
|
|
272
|
+
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
|
|
273
|
+
NOTE (code truth): this listing is NOT publishable-key channel-scoped —
|
|
274
|
+
scope applies to product reads.
|
|
275
|
+
- **Request** — query `{ id?, product_id?, sku?, currency_code?, region_id?,
|
|
276
|
+
limit?, offset? }` (`limit` 1–200, default 50).
|
|
277
|
+
- **Response** — `{ variants, count, offset, limit }`; variant shape exactly
|
|
278
|
+
as embedded in products above (options + prices + calculated_price).
|
|
279
|
+
- **Working curl**
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S¤cy_code=eur" \
|
|
283
|
+
-H "x-client-id: $CLIENT_ID")
|
|
284
|
+
echo "$VARIANTS" | grep -q '"variants"'
|
|
285
|
+
echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
|
|
286
|
+
VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
287
|
+
test -n "$VARIANT_ID"
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
|
|
291
|
+
400 `invalid_region`.
|
|
292
|
+
- **SDK** — `listProductVariants(client, query?)`.
|
|
293
|
+
- **Components** — cart line renderer.
|
|
294
|
+
- **Settings** — price lists (calculated_price).
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## GET /api/store/product-variants/:id
|
|
299
|
+
|
|
300
|
+
- **Purpose** — retrieve one variant with options + base prices hydrated.
|
|
301
|
+
- **Auth** — anon: `x-client-id`; optional Bearer JWT.
|
|
302
|
+
- **Request** — query: pricing context only.
|
|
303
|
+
- **Response** — `{ "variant": { ... } }`
|
|
304
|
+
- **Working curl**
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
|
|
308
|
+
-H "x-client-id: $CLIENT_ID")
|
|
309
|
+
echo "$VARIANT" | grep -q '"variant"'
|
|
310
|
+
echo "$VARIANT" | grep -q '"calculated_price"'
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
- **Errors** — 404 `not_found`, 400 `invalid_region`.
|
|
314
|
+
- **SDK** — `retrieveProductVariant(client, variantId, query?)`.
|
|
315
|
+
- **Components** — cart line renderer, option picker.
|
|
316
|
+
- **Settings** — price lists.
|