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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/index.js +94 -0
  4. package/package.json +18 -4
  5. package/template/app/CLAUDE.md +18 -0
  6. package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
  7. package/template/app/docs/README.md +76 -0
  8. package/template/app/docs/auth.md +105 -0
  9. package/template/app/docs/carts.md +376 -0
  10. package/template/app/docs/categories.md +194 -0
  11. package/template/app/docs/checkout.md +611 -0
  12. package/template/app/docs/collections.md +167 -0
  13. package/template/app/docs/components.md +1089 -0
  14. package/template/app/docs/consent.md +81 -0
  15. package/template/app/docs/content.md +126 -0
  16. package/template/app/docs/customers.md +269 -0
  17. package/template/app/docs/deploy.md +192 -0
  18. package/template/app/docs/gift-cards.md +153 -0
  19. package/template/app/docs/integrations.md +137 -0
  20. package/template/app/docs/menus.md +73 -0
  21. package/template/app/docs/metaobjects.md +126 -0
  22. package/template/app/docs/orders.md +221 -0
  23. package/template/app/docs/products.md +300 -0
  24. package/template/app/docs/redirects.md +50 -0
  25. package/template/app/docs/regions.md +207 -0
  26. package/template/app/docs/reviews.md +223 -0
  27. package/template/app/docs/search.md +218 -0
  28. package/template/app/docs/subscriptions.md +148 -0
  29. package/template/app/next.config.ts +34 -0
  30. package/template/app/package.json +25 -0
  31. package/template/app/postcss.config.cjs +6 -0
  32. package/template/app/smoke.mjs +158 -0
  33. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  34. package/template/app/src/app/checkout/page.tsx +49 -0
  35. package/template/app/src/app/globals.css +42 -0
  36. package/template/app/src/app/layout.tsx +105 -0
  37. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  38. package/template/app/src/app/page.tsx +25 -0
  39. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  40. package/template/app/src/app/providers.tsx +54 -0
  41. package/template/app/src/app/search/page.tsx +20 -0
  42. package/template/app/src/lib/browser-client.ts +35 -0
  43. package/template/app/src/lib/cart-actions.ts +43 -0
  44. package/template/app/src/lib/config.ts +16 -0
  45. package/template/app/src/lib/server-client.ts +25 -0
  46. package/template/app/tailwind.config.cjs +9 -0
  47. package/template/app/tsconfig.json +41 -0
  48. 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&currency_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`.