create-cartbase 0.0.1 → 0.1.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 -0
- package/README.md +9 -3
- package/dist/index.js +94 -0
- package/package.json +18 -4
- package/template/app/CLAUDE.md +18 -0
- package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
- package/template/app/docs/README.md +76 -0
- package/template/app/docs/auth.md +105 -0
- package/template/app/docs/carts.md +376 -0
- package/template/app/docs/categories.md +194 -0
- package/template/app/docs/checkout.md +611 -0
- package/template/app/docs/collections.md +167 -0
- package/template/app/docs/components.md +1089 -0
- package/template/app/docs/consent.md +81 -0
- package/template/app/docs/content.md +126 -0
- package/template/app/docs/customers.md +269 -0
- package/template/app/docs/deploy.md +192 -0
- package/template/app/docs/gift-cards.md +153 -0
- package/template/app/docs/integrations.md +137 -0
- package/template/app/docs/menus.md +73 -0
- package/template/app/docs/metaobjects.md +126 -0
- package/template/app/docs/orders.md +221 -0
- package/template/app/docs/products.md +300 -0
- package/template/app/docs/redirects.md +50 -0
- package/template/app/docs/regions.md +207 -0
- package/template/app/docs/reviews.md +223 -0
- package/template/app/docs/search.md +218 -0
- package/template/app/docs/subscriptions.md +148 -0
- package/template/app/next.config.ts +34 -0
- package/template/app/package.json +25 -0
- package/template/app/postcss.config.cjs +6 -0
- package/template/app/smoke.mjs +158 -0
- package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
- package/template/app/src/app/checkout/page.tsx +49 -0
- package/template/app/src/app/globals.css +42 -0
- package/template/app/src/app/layout.tsx +105 -0
- package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
- package/template/app/src/app/page.tsx +25 -0
- package/template/app/src/app/products/[handle]/page.tsx +58 -0
- package/template/app/src/app/providers.tsx +54 -0
- package/template/app/src/app/search/page.tsx +20 -0
- package/template/app/src/lib/browser-client.ts +35 -0
- package/template/app/src/lib/cart-actions.ts +43 -0
- package/template/app/src/lib/config.ts +16 -0
- package/template/app/src/lib/server-client.ts +25 -0
- package/template/app/tailwind.config.cjs +9 -0
- package/template/app/tsconfig.json +41 -0
- package/template/app/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Orders — customer reads + transfers
|
|
2
|
+
|
|
3
|
+
The authenticated customer's order surface: list, detail (items +
|
|
4
|
+
fulfillments with tracking + addresses) and order transfers. There is **no
|
|
5
|
+
anonymous order read** — every endpoint on this page requires a customer
|
|
6
|
+
session (`authorization: Bearer <jwt>`, obtained via the passwordless code
|
|
7
|
+
flow — see [auth.md](auth.md)); a guest's only order handle is the
|
|
8
|
+
`completeCart()` response ([checkout.md](checkout.md)). Missing/invalid
|
|
9
|
+
JWT → `401 {code: "unauthenticated"}` — demonstrated executably below;
|
|
10
|
+
happy-path shapes are shown as jsonc (a customer session is admin-owned
|
|
11
|
+
state on the shared tenant; the authenticated paths are proven by
|
|
12
|
+
`tests/store/customer-accounts-ownership.test.ts` and the transfer suite).
|
|
13
|
+
|
|
14
|
+
**Auth for every endpoint on this page:** `x-client-id` + Bearer JWT.
|
|
15
|
+
|
|
16
|
+
SDK module: `@cartbase/storefront/api/orders`.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## GET /api/store/orders/display/:displayId — my order by its human number
|
|
21
|
+
|
|
22
|
+
- **Purpose** — resolve "order #42" (the number customers see in emails and
|
|
23
|
+
confirmations) to the full order detail; account order pages and support
|
|
24
|
+
links use this instead of the opaque id.
|
|
25
|
+
- **Auth** — Bearer JWT required, same ownership scope as the id read:
|
|
26
|
+
another customer's number is `404 not_found`. `display_id` is a guessable
|
|
27
|
+
autoincrement — that is exactly why there is no anonymous lookup.
|
|
28
|
+
- **Request** — `GET`, no query. A numeric segment matches the
|
|
29
|
+
autoincrement `display_id` OR a purely-numeric `custom_display_id`
|
|
30
|
+
(custom wins ties — it is the number the store actually showed); any
|
|
31
|
+
other segment matches `custom_display_id` only.
|
|
32
|
+
- **Response** — identical `{order}` detail shape to
|
|
33
|
+
`GET /api/store/orders/:id` above.
|
|
34
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found`.
|
|
35
|
+
- **SDK** — `orders.retrieveOrderByDisplayId(client, displayId)`.
|
|
36
|
+
- **Components** — order-confirmation deep links, account order detail.
|
|
37
|
+
- **Settings** — `custom_display_id` is set by merchants/imports (admin
|
|
38
|
+
surface); absent it, only the autoincrement resolves.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
42
|
+
"$BASE/api/store/orders/display/42" -H "x-client-id: $CLIENT_ID")
|
|
43
|
+
test "$STATUS" = 401
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## GET /api/store/orders — list my orders
|
|
49
|
+
|
|
50
|
+
- **Purpose** — the account "My orders" list, newest first.
|
|
51
|
+
- **Auth** — Bearer JWT required. Scoped to the session customer AND the
|
|
52
|
+
tenant — another customer's orders are invisible (not 403).
|
|
53
|
+
- **Request** — `GET ?limit=&offset=&status=` — `limit` 1–200 (default
|
|
54
|
+
20), `offset` ≥ 0 (default 0), `status` exact-match filter (`pending`,
|
|
55
|
+
`completed`, `canceled`, …).
|
|
56
|
+
- **Response** — plain order rows (NO embeds — fetch the detail for
|
|
57
|
+
items/tracking):
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
{
|
|
61
|
+
"orders": [{
|
|
62
|
+
"id": "order_…",
|
|
63
|
+
"display_id": 42, // human-facing autoincrement
|
|
64
|
+
"status": "pending",
|
|
65
|
+
"email": "jane@example.com",
|
|
66
|
+
"currency_code": "eur",
|
|
67
|
+
"customer_id": "cus_…",
|
|
68
|
+
"sales_channel_id": null,
|
|
69
|
+
"region_id": "reg_…",
|
|
70
|
+
"metadata": {}, // internal staff notes NEVER appear here
|
|
71
|
+
"created_at": "2026-07-20T…", "updated_at": "2026-07-20T…"
|
|
72
|
+
}],
|
|
73
|
+
"count": 1, "offset": 0, "limit": 20
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **Errors** — 401 `unauthenticated`; 400 `validation_failed` (bad
|
|
78
|
+
limit/offset).
|
|
79
|
+
- **SDK** — `orders.listOrders(client, {limit, offset, status})`.
|
|
80
|
+
- **Components** — account order-history page.
|
|
81
|
+
- **Settings** — none (ownership is structural).
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Executable auth contract: no Bearer → 401 unauthenticated.
|
|
85
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
86
|
+
"$BASE/api/store/orders" -H "x-client-id: $CLIENT_ID")
|
|
87
|
+
test "$STATUS" = 401
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## GET /api/store/orders/:id — my order, in full
|
|
93
|
+
|
|
94
|
+
- **Purpose** — the order-detail page: items, fulfillment lifecycle +
|
|
95
|
+
tracking, both addresses.
|
|
96
|
+
- **Auth** — Bearer JWT required. Ownership is part of the lookup: an
|
|
97
|
+
unknown id and another customer's order are BOTH `404 not_found`
|
|
98
|
+
(indistinguishable — no existence oracle).
|
|
99
|
+
- **Request** — `GET`, no query.
|
|
100
|
+
- **Response** — a store-safe subset of the admin order select (no
|
|
101
|
+
customer row, no payment internals, no internal timeline comments):
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
{
|
|
105
|
+
"order": {
|
|
106
|
+
"id": "order_…", "display_id": 42, "status": "pending",
|
|
107
|
+
"email": "jane@example.com", "currency_code": "eur",
|
|
108
|
+
"items": [{ // version pivot + embedded line item
|
|
109
|
+
"id": "oi_…", "order_id": "order_…", "quantity": 1,
|
|
110
|
+
"line_item": {
|
|
111
|
+
"id": "oli_…", "title": "M", "product_title": "Linen Shirt",
|
|
112
|
+
"product_handle": "linen-shirt", "thumbnail": "https://…",
|
|
113
|
+
"variant_id": "variant_…", "variant_sku": "LIN-SHIRT-M",
|
|
114
|
+
"unit_price": 45, "metadata": null
|
|
115
|
+
}
|
|
116
|
+
}],
|
|
117
|
+
"fulfillments": [{ // couriers surface
|
|
118
|
+
"fulfillment": {
|
|
119
|
+
"id": "ful_…",
|
|
120
|
+
"packed_at": "2026-07-20T…", "shipped_at": null,
|
|
121
|
+
"delivered_at": null, "canceled_at": null,
|
|
122
|
+
"labels": [{ "tracking_number": "…", "tracking_url": "https://…" }]
|
|
123
|
+
}
|
|
124
|
+
}],
|
|
125
|
+
"shipping_address": { "first_name": "Jane", "city": "Sofia", "…": "order_addresses row" },
|
|
126
|
+
"billing_address": { "…": "order_addresses row" }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found`.
|
|
132
|
+
- **SDK** — `orders.retrieveOrder(client, orderId)`.
|
|
133
|
+
- **Components** — order-detail page, tracking widget.
|
|
134
|
+
- **Settings** — fulfillment/tracking data appears as the merchant packs
|
|
135
|
+
and ships (admin fulfillment flow + carrier labels).
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
139
|
+
"$BASE/api/store/orders/order_any" -H "x-client-id: $CLIENT_ID")
|
|
140
|
+
test "$STATUS" = 401
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Order transfers — claim a (guest) order into my account
|
|
146
|
+
|
|
147
|
+
Flow: the **claiming** customer calls `request` on an order that is not
|
|
148
|
+
theirs → a one-time token is stored for the order's email holder (email
|
|
149
|
+
dispatch pending server-side) → the claimer, whose account email must
|
|
150
|
+
EQUAL the order's email, calls `accept` with the token. `decline` (the
|
|
151
|
+
recipient rejects, token required) or `cancel` (the requester withdraws)
|
|
152
|
+
end a pending transfer. One pending transfer per order — a duplicate
|
|
153
|
+
`request` acknowledges with the same `{transfer: {requested: true}}` shape
|
|
154
|
+
without creating another. **The token never crosses this surface** — it is
|
|
155
|
+
email-delivered only (pinned by
|
|
156
|
+
`tests/store/order-transfer-token-leak.test.ts`).
|
|
157
|
+
|
|
158
|
+
### POST /api/store/orders/:id/transfer/request
|
|
159
|
+
|
|
160
|
+
- **Request** — `{description?}`.
|
|
161
|
+
- **Response** — `200 {"order":{"id":"order_…","transfer":{"requested":true}}}`
|
|
162
|
+
(or the existing pending action object on a duplicate request).
|
|
163
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 400 `already_owned`
|
|
164
|
+
(the order already belongs to the caller).
|
|
165
|
+
- **SDK** — `orders.requestOrderTransfer(client, orderId, {description})`.
|
|
166
|
+
|
|
167
|
+
### POST /api/store/orders/:id/transfer/accept
|
|
168
|
+
|
|
169
|
+
- **Request** — `{token}` (≥16 chars, from the transfer email).
|
|
170
|
+
- **Response** — `200 {order}` — the full updated order row, now owned by
|
|
171
|
+
the caller.
|
|
172
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found` (order / no pending
|
|
173
|
+
transfer); 403 `invalid_token` | `email_mismatch` (the caller's email
|
|
174
|
+
must match the order's original email — a leaked token alone is not
|
|
175
|
+
enough); 400 `validation_failed`.
|
|
176
|
+
- **SDK** — `orders.acceptOrderTransfer(client, orderId, {token})`.
|
|
177
|
+
|
|
178
|
+
### POST /api/store/orders/:id/transfer/decline
|
|
179
|
+
|
|
180
|
+
- **Request** — `{token}`.
|
|
181
|
+
- **Response** — `200 {"order":{"id":"order_…","transfer":{"declined":true}}}`.
|
|
182
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `invalid_token`;
|
|
183
|
+
400 `validation_failed`.
|
|
184
|
+
- **SDK** — `orders.declineOrderTransfer(client, orderId, {token})`.
|
|
185
|
+
|
|
186
|
+
### POST /api/store/orders/:id/transfer/cancel
|
|
187
|
+
|
|
188
|
+
- **Request** — no body.
|
|
189
|
+
- **Response** — `200 {"order":{"id":"order_…","transfer":{"canceled":true}}}`.
|
|
190
|
+
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `forbidden`
|
|
191
|
+
(only the requester may cancel).
|
|
192
|
+
- **SDK** — `orders.cancelOrderTransfer(client, orderId)`.
|
|
193
|
+
- **Components** — account "claim this order" flow + the transfer email's
|
|
194
|
+
accept/decline landing page.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Executable auth contract for the transfer family (all four Bearer-gated).
|
|
198
|
+
# Note the ordering nuance: accept/decline validate the BODY before auth,
|
|
199
|
+
# so a missing token 400s even for guests; request/cancel hit auth first.
|
|
200
|
+
for EP in transfer/request transfer/cancel; do
|
|
201
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
202
|
+
"$BASE/api/store/orders/order_any/$EP" \
|
|
203
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
|
|
204
|
+
test "$STATUS" = 401
|
|
205
|
+
done
|
|
206
|
+
for EP in transfer/accept transfer/decline; do
|
|
207
|
+
RES=$(curl -s -X POST "$BASE/api/store/orders/order_any/$EP" \
|
|
208
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
|
|
209
|
+
echo "$RES" | grep -q '"code":"validation_failed"'
|
|
210
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
211
|
+
"$BASE/api/store/orders/order_any/$EP" \
|
|
212
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
213
|
+
-d '{"token":"0123456789abcdef0123456789abcdef"}')
|
|
214
|
+
test "$STATUS" = 401
|
|
215
|
+
done
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Cleanup / accretion note
|
|
219
|
+
|
|
220
|
+
This page creates nothing — every executable block is a read/auth-contract
|
|
221
|
+
probe against nonexistent ids.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# Products & variants
|
|
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 — products.collection_id (primary collection)
|
|
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
|
+
"collection_id": "pcol_01tst00000000000000000001",
|
|
70
|
+
"type_id": null,
|
|
71
|
+
"external_id": null,
|
|
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
|
+
"created_at": "2026-07-01T00:00:00.000Z",
|
|
78
|
+
"updated_at": "2026-07-01T00:00:00.000Z",
|
|
79
|
+
"variants": [
|
|
80
|
+
{
|
|
81
|
+
"id": "variant_01tst000000000000000001",
|
|
82
|
+
"title": "S",
|
|
83
|
+
"product_id": "prod_01tst00000000000000000001",
|
|
84
|
+
"sku": "LIN-SHIRT-S",
|
|
85
|
+
"barcode": null, "ean": null, "upc": null,
|
|
86
|
+
"thumbnail": null,
|
|
87
|
+
"allow_backorder": false,
|
|
88
|
+
"manage_inventory": true,
|
|
89
|
+
"variant_rank": 0,
|
|
90
|
+
"metadata": null,
|
|
91
|
+
"options": [
|
|
92
|
+
{ "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
|
|
93
|
+
"option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
|
|
94
|
+
],
|
|
95
|
+
// Raw BASE prices via the price-set link embed (leak rule: price-list
|
|
96
|
+
// rows are stripped — price_list_id is always null here):
|
|
97
|
+
"prices": [
|
|
98
|
+
{ "price_set": { "prices": [
|
|
99
|
+
{ "id": "price_…", "amount": 45, "currency_code": "eur",
|
|
100
|
+
"min_quantity": null, "max_quantity": null,
|
|
101
|
+
"price_set_id": "pset_…", "price_list_id": null }
|
|
102
|
+
] } }
|
|
103
|
+
],
|
|
104
|
+
// Present when a pricing context was given; null otherwise or when no
|
|
105
|
+
// price matches. b2b-v1 shape (store-api.md §Pricing context):
|
|
106
|
+
"calculated_price": {
|
|
107
|
+
"calculated_amount": 45,
|
|
108
|
+
"original_amount": 45,
|
|
109
|
+
"currency_code": "eur",
|
|
110
|
+
"is_calculated_price_price_list": false,
|
|
111
|
+
"price_list_id": null,
|
|
112
|
+
"price_list_type": null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
],
|
|
116
|
+
"images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
|
|
117
|
+
"options": [ { "id": "opt_…", "title": "Size",
|
|
118
|
+
"values": [ { "id": "optv_…", "value": "S" } ] } ],
|
|
119
|
+
"collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" },
|
|
120
|
+
"categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
|
|
121
|
+
"tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
|
|
122
|
+
"type": null
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **Working curl**
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
|
|
130
|
+
echo "$PRODUCTS" | grep -q '"products"'
|
|
131
|
+
echo "$PRODUCTS" | grep -q '"count"'
|
|
132
|
+
# The handle filter answers deterministically regardless of catalog size.
|
|
133
|
+
curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
|
|
134
|
+
| grep -q '"handle":"linen-shirt"'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
|
|
138
|
+
400 `invalid_publishable_key`, 400 `invalid_region`.
|
|
139
|
+
- **SDK** — `listProducts(client, query?)`.
|
|
140
|
+
- **Components** — product card grid (components.md).
|
|
141
|
+
- **Settings** — sales-channel product links + publishable-key bindings
|
|
142
|
+
(channel scope), price lists (calculated_price).
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## GET /api/store/products/:idOrHandle
|
|
147
|
+
|
|
148
|
+
- **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
|
|
149
|
+
storefronts deep-link by handle.
|
|
150
|
+
- **Auth** — as the list.
|
|
151
|
+
- **Request** — query: pricing context only (`currency_code` / `region_id`).
|
|
152
|
+
- **Response** — `{ "product": { ...shape above... } }`
|
|
153
|
+
- **Working curl** — with pricing context; asserts the seeded base price
|
|
154
|
+
(Linen Shirt, EUR 45) and the leak rule structurally:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
|
|
158
|
+
-H "x-client-id: $CLIENT_ID")
|
|
159
|
+
echo "$PRODUCT" | grep -q '"product"'
|
|
160
|
+
echo "$PRODUCT" | grep -q '"calculated_price"'
|
|
161
|
+
echo "$PRODUCT" | grep -q '"calculated_amount":45'
|
|
162
|
+
echo "$PRODUCT" | grep -q '"seo_title"'
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# Leak rule, asserted structurally: every raw price row is a BASE row
|
|
167
|
+
# (price_list_id null) — price-list amounts only ever ride calculated_price.
|
|
168
|
+
echo "$PRODUCT" | node -e "
|
|
169
|
+
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
|
|
170
|
+
const j=JSON.parse(Buffer.concat(c));
|
|
171
|
+
const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
|
|
172
|
+
if(!rows.length){console.error('no base price rows');process.exit(1)}
|
|
173
|
+
if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
|
|
174
|
+
})"
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **Errors** — 404 `not_found` (unknown handle/id, draft, soft-deleted, or
|
|
178
|
+
outside the publishable key's channels), 400 `invalid_publishable_key`,
|
|
179
|
+
400 `invalid_region`.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
183
|
+
"$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
|
|
184
|
+
test "$STATUS" = 404
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
|
|
189
|
+
# catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
|
|
190
|
+
# works) but 404s under the key — invisible, not forbidden.
|
|
191
|
+
curl -sf "$BASE/api/store/products/leather-belt" \
|
|
192
|
+
-H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
|
|
193
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
194
|
+
"$BASE/api/store/products/leather-belt" \
|
|
195
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
|
|
196
|
+
test "$STATUS" = 404
|
|
197
|
+
curl -sf "$BASE/api/store/products/linen-shirt" \
|
|
198
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
|
|
199
|
+
| grep -q '"handle":"linen-shirt"'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
# Unknown key → 400 invalid_publishable_key (never a silent unscoped read).
|
|
204
|
+
BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
|
|
205
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
|
|
206
|
+
echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- **SDK** — `retrieveProduct(client, idOrHandle, query?)`.
|
|
210
|
+
- **Components** — PDP family: gallery, option picker, price block.
|
|
211
|
+
- **Settings** — as the list; SEO overrides (Admin → Product → SEO).
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## GET /api/store/products/:idOrHandle/selling-plans
|
|
216
|
+
|
|
217
|
+
- **Purpose** — the subscription plans this product can be purchased with
|
|
218
|
+
(subscriptions add-on). Render as PDP purchase options (one-time vs each
|
|
219
|
+
plan); the chosen plan id goes on the cart line
|
|
220
|
+
(`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
|
|
221
|
+
the plan price. Empty list = one-time only.
|
|
222
|
+
- **Auth** — anon `x-client-id`; publishable-key channel scope applies
|
|
223
|
+
(same visibility rule as the product read).
|
|
224
|
+
- **Response** — `{ selling_plans: [{id, name, interval, interval_count,
|
|
225
|
+
pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
|
|
226
|
+
`pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
|
|
227
|
+
major units) | `null` (catalog price — the plan only sets the cadence);
|
|
228
|
+
enabled plans only, merchant-defined order.
|
|
229
|
+
- **Errors** — 404 `not_found` (same rules as the product read).
|
|
230
|
+
- **SDK** — `products.listSellingPlans(client, idOrHandle)`.
|
|
231
|
+
- **Components** — `PurchaseOptions` (products family): controlled radio
|
|
232
|
+
group, one-time + plans with display-only price preview
|
|
233
|
+
(`previewPlanPrice` mirrors the server's math; the server is truth).
|
|
234
|
+
NOTE: a cart with a plan line requires a logged-in customer at payment
|
|
235
|
+
(checkout.md `save_payment_method`) — surface login before checkout.
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
# The dev tenant seeds no plans — the endpoint contract still executes:
|
|
239
|
+
# a valid product answers with the envelope, an unknown product 404s.
|
|
240
|
+
SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
|
|
241
|
+
-H "x-client-id: $CLIENT_ID")
|
|
242
|
+
echo "$SPLANS" | grep -q '"selling_plans"'
|
|
243
|
+
echo "$SPLANS" | grep -q '"count"'
|
|
244
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
245
|
+
"$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
|
|
246
|
+
-H "x-client-id: $CLIENT_ID")
|
|
247
|
+
test "$STATUS" = 404
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## GET /api/store/product-variants
|
|
253
|
+
|
|
254
|
+
- **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
|
|
255
|
+
CSV of variant ids in ONE param. Ordered by `variant_rank`.
|
|
256
|
+
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
|
|
257
|
+
NOTE (code truth): this listing is NOT publishable-key channel-scoped —
|
|
258
|
+
scope applies to product reads.
|
|
259
|
+
- **Request** — query `{ id?, product_id?, sku?, currency_code?, region_id?,
|
|
260
|
+
limit?, offset? }` (`limit` 1–200, default 50).
|
|
261
|
+
- **Response** — `{ variants, count, offset, limit }`; variant shape exactly
|
|
262
|
+
as embedded in products above (options + prices + calculated_price).
|
|
263
|
+
- **Working curl**
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S¤cy_code=eur" \
|
|
267
|
+
-H "x-client-id: $CLIENT_ID")
|
|
268
|
+
echo "$VARIANTS" | grep -q '"variants"'
|
|
269
|
+
echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
|
|
270
|
+
VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
271
|
+
test -n "$VARIANT_ID"
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
|
|
275
|
+
400 `invalid_region`.
|
|
276
|
+
- **SDK** — `listProductVariants(client, query?)`.
|
|
277
|
+
- **Components** — cart line renderer.
|
|
278
|
+
- **Settings** — price lists (calculated_price).
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## GET /api/store/product-variants/:id
|
|
283
|
+
|
|
284
|
+
- **Purpose** — retrieve one variant with options + base prices hydrated.
|
|
285
|
+
- **Auth** — anon: `x-client-id`; optional Bearer JWT.
|
|
286
|
+
- **Request** — query: pricing context only.
|
|
287
|
+
- **Response** — `{ "variant": { ... } }`
|
|
288
|
+
- **Working curl**
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
|
|
292
|
+
-H "x-client-id: $CLIENT_ID")
|
|
293
|
+
echo "$VARIANT" | grep -q '"variant"'
|
|
294
|
+
echo "$VARIANT" | grep -q '"calculated_price"'
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
- **Errors** — 404 `not_found`, 400 `invalid_region`.
|
|
298
|
+
- **SDK** — `retrieveProductVariant(client, variantId, query?)`.
|
|
299
|
+
- **Components** — cart line renderer, option picker.
|
|
300
|
+
- **Settings** — price lists.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# URL redirects — 404-path lookup
|
|
2
|
+
|
|
3
|
+
Exact-match redirect lookup (seo-listings card 21). **Hot-path rule: call
|
|
4
|
+
this ONLY from the storefront's not-found handler** — never on regular page
|
|
5
|
+
loads (index-covered exact match on `(client_id, from_path)`). When
|
|
6
|
+
`to_path` is non-null, issue a **301** to it; when null, render the 404.
|
|
7
|
+
|
|
8
|
+
Redirects are created in admin and **automatically on handle renames** of
|
|
9
|
+
products/collections/pages/posts — wiring this once means renames never
|
|
10
|
+
break old URLs (path convention shared with [content.md](content.md) and
|
|
11
|
+
[menus.md](menus.md)).
|
|
12
|
+
|
|
13
|
+
## GET /api/store/url-redirects?path=… — lookup
|
|
14
|
+
|
|
15
|
+
- **Purpose**: resolve a missed pathname (e.g. `/products/old-handle`) to
|
|
16
|
+
its redirect target, if any.
|
|
17
|
+
- **Auth**: anon (`x-client-id`).
|
|
18
|
+
- **Request**: query `{path}` — the exact pathname that 404'd (leading
|
|
19
|
+
slash included), min length 1.
|
|
20
|
+
- **Response**:
|
|
21
|
+
|
|
22
|
+
```jsonc
|
|
23
|
+
{
|
|
24
|
+
"path": "/products/old-handle", // echoed
|
|
25
|
+
"to_path": "/products/new-handle" // or null — no redirect, render the 404
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- **Errors**: `400 validation_failed` (missing/empty `path`) ·
|
|
30
|
+
`400 missing_client_id`.
|
|
31
|
+
- **SDK**: `redirects.lookupRedirect(client, path)`
|
|
32
|
+
- **Components**: the storefront `not-found` handler (no visual component).
|
|
33
|
+
- **Settings**: admin → Content → URL redirects; automatic rows on handle
|
|
34
|
+
renames.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Miss → to_path null (the everyday 404 case), echo intact.
|
|
38
|
+
BODY=$(curl -sf "$BASE/api/store/url-redirects?path=/doc-no-such-path-$RUN" \
|
|
39
|
+
-H "x-client-id: $CLIENT_ID")
|
|
40
|
+
echo "$BODY" | grep -q '"to_path":null'
|
|
41
|
+
echo "$BODY" | grep -q "doc-no-such-path-$RUN"
|
|
42
|
+
# Missing path param → 400.
|
|
43
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/url-redirects" \
|
|
44
|
+
-H "x-client-id: $CLIENT_ID")
|
|
45
|
+
test "$STATUS" = 400
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
> The hit case (`to_path` non-null) requires an admin-created redirect row;
|
|
49
|
+
> it is pinned executably by `tests/store/url-redirects-lookup.test.ts` and
|
|
50
|
+
> the SDK contract test `tests/contract/sdk-content.contract.test.ts`.
|