create-cartbase 0.0.1 → 0.1.1

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 (49) 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 +77 -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 +1090 -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/platform.md +126 -0
  24. package/template/app/docs/products.md +300 -0
  25. package/template/app/docs/redirects.md +50 -0
  26. package/template/app/docs/regions.md +206 -0
  27. package/template/app/docs/reviews.md +223 -0
  28. package/template/app/docs/search.md +218 -0
  29. package/template/app/docs/subscriptions.md +148 -0
  30. package/template/app/next.config.ts +34 -0
  31. package/template/app/package.json +25 -0
  32. package/template/app/postcss.config.cjs +6 -0
  33. package/template/app/smoke.mjs +158 -0
  34. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  35. package/template/app/src/app/checkout/page.tsx +51 -0
  36. package/template/app/src/app/globals.css +42 -0
  37. package/template/app/src/app/layout.tsx +113 -0
  38. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  39. package/template/app/src/app/page.tsx +25 -0
  40. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  41. package/template/app/src/app/providers.tsx +54 -0
  42. package/template/app/src/app/search/page.tsx +20 -0
  43. package/template/app/src/lib/browser-client.ts +35 -0
  44. package/template/app/src/lib/cart-actions.ts +47 -0
  45. package/template/app/src/lib/config.ts +21 -0
  46. package/template/app/src/lib/server-client.ts +25 -0
  47. package/template/app/tailwind.config.cjs +9 -0
  48. package/template/app/tsconfig.json +41 -0
  49. package/template/app/tsconfig.tsbuildinfo +1 -0
@@ -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`.
@@ -0,0 +1,206 @@
1
+ # Regions, currencies, locales
2
+
3
+ Catalog-context primitives a storefront resolves at boot: regions feed the
4
+ pricing context (`region_id` → region currency), currencies tell you what the
5
+ store has enabled, locales drive the language switcher and the client's
6
+ `x-locale` header. All reads are anonymous. Money is EUR decimal major units
7
+ everywhere.
8
+
9
+ SDK module: `@cartbase/storefront/api/regions`.
10
+
11
+ ---
12
+
13
+ ## GET /api/store/regions
14
+
15
+ - **Purpose** — list the store's regions; a storefront usually picks one at
16
+ boot (or by shopper choice) and passes its id/currency as the pricing
17
+ context on catalog reads.
18
+ - **Auth** — anon: `x-client-id` required.
19
+ - **Request** — `GET /api/store/regions`
20
+
21
+ ```jsonc
22
+ // query (all optional)
23
+ {
24
+ "q": "bulg", // case-insensitive substring on name
25
+ "currency_code": "eur", // exact match, lowercased server-side
26
+ "limit": 50, // 1–200, default 50
27
+ "offset": 0
28
+ }
29
+ ```
30
+
31
+ - **Response** — ordered by name. NOTE: Cartbase regions carry NO embedded
32
+ `countries` array — country/tax scope lives server-side in
33
+ `tax_regions`.
34
+
35
+ ```jsonc
36
+ {
37
+ "regions": [
38
+ {
39
+ "id": "reg_01tst000000000000000000001",
40
+ "name": "Bulgaria",
41
+ "currency_code": "eur", // lowercase — feeds the pricing context
42
+ "automatic_taxes": true,
43
+ "metadata": null,
44
+ "created_at": "2026-07-01T00:00:00.000Z",
45
+ "updated_at": "2026-07-01T00:00:00.000Z"
46
+ }
47
+ ],
48
+ "count": 1,
49
+ "offset": 0,
50
+ "limit": 50
51
+ }
52
+ ```
53
+
54
+ - **Working curl**
55
+
56
+ ```bash
57
+ REGIONS=$(curl -sf "$BASE/api/store/regions" -H "x-client-id: $CLIENT_ID")
58
+ echo "$REGIONS" | grep -q '"regions"'
59
+ echo "$REGIONS" | grep -q '"count"'
60
+ REGION_ID=$(echo "$REGIONS" | grep -o '"id":"reg_[^"]*"' | head -1 | cut -d'"' -f4)
61
+ test -n "$REGION_ID"
62
+ ```
63
+
64
+ - **Errors** — 400 `missing_client_id` (header absent/empty),
65
+ 400 `validation_failed` (bad limit/offset).
66
+ - **SDK** — `listRegions(client, query?)`.
67
+ - **Components** — region/currency selector (see components.md).
68
+ - **Settings** — Admin → Settings → Regions.
69
+
70
+ ```bash
71
+ # Auth contract: no x-client-id → 400 missing_client_id
72
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/regions")
73
+ test "$STATUS" = 400
74
+ curl -s "$BASE/api/store/regions" | grep -q '"code":"missing_client_id"'
75
+ ```
76
+
77
+ ---
78
+
79
+ ## GET /api/store/regions/:id
80
+
81
+ - **Purpose** — retrieve one region (e.g. re-hydrate the shopper's stored
82
+ choice).
83
+ - **Auth** — anon: `x-client-id` required.
84
+ - **Request** — `GET /api/store/regions/{region_id}` — no query.
85
+ - **Response** — `{ "region": { ...same shape as the list rows... } }`
86
+ - **Working curl**
87
+
88
+ ```bash
89
+ REGION=$(curl -sf "$BASE/api/store/regions/$REGION_ID" -H "x-client-id: $CLIENT_ID")
90
+ echo "$REGION" | grep -q '"region"'
91
+ echo "$REGION" | grep -q '"currency_code"'
92
+ ```
93
+
94
+ - **Errors** — 404 `not_found` (unknown id, soft-deleted, or another
95
+ tenant's region — invisible, not forbidden).
96
+
97
+ ```bash
98
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
99
+ "$BASE/api/store/regions/reg_doesnotexist$RUN" -H "x-client-id: $CLIENT_ID")
100
+ test "$STATUS" = 404
101
+ curl -s "$BASE/api/store/regions/reg_doesnotexist$RUN" \
102
+ -H "x-client-id: $CLIENT_ID" | grep -q '"code":"not_found"'
103
+ ```
104
+
105
+ - **SDK** — `retrieveRegion(client, regionId)`.
106
+ - **Components** — region/currency selector.
107
+ - **Settings** — Admin → Settings → Regions.
108
+
109
+ ---
110
+
111
+ ## GET /api/store/currencies
112
+
113
+ - **Purpose** — list the currencies ENABLED on this store (the shared
114
+ currency catalog filtered by the store's `store_currencies` links). Use
115
+ for formatting metadata (symbol, decimal digits).
116
+ - **Auth** — anon: `x-client-id` required.
117
+ - **Request** — query `{ code?, limit?, offset? }` (`code` exact,
118
+ lowercased; `limit` 1–200, default 50).
119
+ - **Response** — ordered by code; a store with no enabled currencies
120
+ returns an empty list.
121
+
122
+ ```jsonc
123
+ {
124
+ "currencies": [
125
+ {
126
+ "code": "eur",
127
+ "name": "Euro",
128
+ "symbol": "€",
129
+ "symbol_native": "€",
130
+ "decimal_digits": 2,
131
+ "rounding": 0,
132
+ "created_at": "2026-07-01T00:00:00.000Z",
133
+ "updated_at": "2026-07-01T00:00:00.000Z"
134
+ }
135
+ ],
136
+ "count": 1,
137
+ "offset": 0,
138
+ "limit": 50
139
+ }
140
+ ```
141
+
142
+ - **Working curl**
143
+
144
+ ```bash
145
+ CURRENCIES=$(curl -sf "$BASE/api/store/currencies" -H "x-client-id: $CLIENT_ID")
146
+ echo "$CURRENCIES" | grep -q '"currencies"'
147
+ echo "$CURRENCIES" | grep -q '"code":"eur"' # the dev store enables EUR
148
+ ```
149
+
150
+ - **Errors** — 400 `missing_client_id`, 400 `validation_failed`.
151
+ - **SDK** — `listCurrencies(client, query?)`.
152
+ - **Components** — price formatting helpers.
153
+ - **Settings** — Admin → Settings → Store → currencies (enable/disable +
154
+ default).
155
+
156
+ ---
157
+
158
+ ## GET /api/store/currencies/:code
159
+
160
+ - **Purpose** — retrieve one currency by code (case-insensitive).
161
+ - **Auth** — anon: `x-client-id` required.
162
+ - **Request** — `GET /api/store/currencies/{code}` — no query.
163
+ - **Response** — `{ "currency": { ...same shape as the list rows... } }`
164
+ - **Working curl**
165
+
166
+ ```bash
167
+ CURRENCY=$(curl -sf "$BASE/api/store/currencies/eur" -H "x-client-id: $CLIENT_ID")
168
+ echo "$CURRENCY" | grep -q '"currency"'
169
+ echo "$CURRENCY" | grep -q '"symbol"'
170
+ ```
171
+
172
+ - **Errors** — 404 `not_found`.
173
+ - **CODE-TRUTH NOTE** — unlike the list, the single read is NOT filtered by
174
+ the store's enabled set: any currency in the shared catalog resolves.
175
+ Treat the LIST as the authority on what the store supports.
176
+ - **SDK** — `retrieveCurrency(client, code)`.
177
+ - **Components** — price formatting helpers.
178
+ - **Settings** — Admin → Settings → Store → currencies.
179
+
180
+ ---
181
+
182
+ ## GET /api/store/locales
183
+
184
+ - **Purpose** — the store's supported locale codes; drives the language
185
+ switcher and the value your client sends as `x-locale`.
186
+ - **Auth** — anon: `x-client-id` required.
187
+ - **Request** — no query.
188
+ - **Response** — default locale FIRST, then alphabetical:
189
+
190
+ ```jsonc
191
+ { "locales": ["en", "bg"] }
192
+ ```
193
+
194
+ - **Working curl**
195
+
196
+ ```bash
197
+ LOCALES=$(curl -sf "$BASE/api/store/locales" -H "x-client-id: $CLIENT_ID")
198
+ echo "$LOCALES" | grep -q '"locales"'
199
+ echo "$LOCALES" | grep -q '"en"'
200
+ ```
201
+
202
+ - **Errors** — 400 `missing_client_id`.
203
+ - **SDK** — `listLocales(client)`.
204
+ - **Components** — locale switcher; `StorefrontClient`'s `getLocale` hook.
205
+ - **Settings** — Admin → Settings → Store → locales (each store manages
206
+ its own locale list in `store_locales`).
@@ -0,0 +1,223 @@
1
+ # Reviews — widget, token wizard, photo rewards
2
+
3
+ Verified-purchase reviews. Reviews exist **only** via a
4
+ single-use, order-scoped, expiring **token** minted by the request scanner
5
+ and mailed as `<review_link_base>/<token>` — the token IS the auth for
6
+ every write; no login. All public reads serve `status='visible'` rows only
7
+ — hidden/pending/deleted never leak (RLS-enforced too).
8
+
9
+ The wizard is two-step: **submit** (rating + body, consumes the token) →
10
+ **photo** (attach media, mint the reward code). Resume rule: token consumed
11
+ + `review.reward_code` null → resume at the photo step; `reward_code` set →
12
+ fully done, show the code.
13
+
14
+ > Public reads below run executably against the seeded `Linen Shirt`
15
+ > product (`prod_01tst00000000000000000001`, scripts/seed-fixtures.ts) —
16
+ > shape holds at any review count, including zero. Token-gated writes need
17
+ > a server-minted token, so their happy paths are pinned by
18
+ > `tests/store/reviews-store.test.ts` + `tests/contract/reviews-widget.contract.test.ts`;
19
+ > here the error contracts run executably.
20
+
21
+ ## GET /api/store/reviews — list visible reviews
22
+
23
+ - **Purpose**: the paginated review list under the PDP widget.
24
+ - **Auth**: anon (`x-client-id`).
25
+ - **Request**: query `{product_id (required), sort? default|rating|date,
26
+ order? asc|desc, limit? (≤50, default 10), offset?}`. `default` = with-
27
+ media first, newest within.
28
+ - **Response**: `{reviews: [PublicReview…], count, has_more}`.
29
+ `PublicReview` is EXACTLY these keys (leak guard — no email/order/IP/
30
+ reward/status; media entries hidden by moderation are filtered out):
31
+
32
+ ```jsonc
33
+ {
34
+ "reviews": [
35
+ {
36
+ "id": "uuid",
37
+ "customer_name": "Елена Г.",
38
+ "rating": 5,
39
+ "title": null, // always null — the form has no title
40
+ "body": "Страхотна риза!",
41
+ "media": [ { "type": "image", "url": "https://…", "thumb": "https://…", "w": 0, "h": 0, "bytes": 0 } ],
42
+ "admin_response": null,
43
+ "admin_response_at": null,
44
+ "created_at": "ISO-8601"
45
+ }
46
+ ],
47
+ "count": 1,
48
+ "has_more": false
49
+ }
50
+ ```
51
+
52
+ - **Errors**: `400 validation_failed` (missing product_id, bad sort/limit).
53
+ - **SDK**: `reviews.listReviews(client, query)`
54
+ - **Components**: review list / masonry grid (review widget family).
55
+
56
+ ```bash
57
+ curl -sf "$BASE/api/store/reviews?product_id=prod_01tst00000000000000000001&sort=date" \
58
+ -H "x-client-id: $CLIENT_ID" | grep -q '"has_more"'
59
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/reviews" \
60
+ -H "x-client-id: $CLIENT_ID")
61
+ test "$STATUS" = 400
62
+ ```
63
+
64
+ ## GET /api/store/reviews/aggregate — star-badge stats
65
+
66
+ - **Purpose**: the PDP star rating + histogram. Visible only. Edge-cached
67
+ 60s (`Cache-Control: public, max-age=60, s-maxage=60`).
68
+ - **Auth**: anon (`x-client-id`).
69
+ - **Request**: query `{product_id}`.
70
+ - **Response**: `{product_id, count, avg_rating, distribution: {"1"…"5"}}`
71
+ — `avg_rating` rounded to 1 decimal, 0 when no reviews.
72
+ - **Errors**: `400 validation_failed`.
73
+ - **SDK**: `reviews.getAggregate(client, productId)`
74
+ - **Components**: star badge (PDP + product cards).
75
+
76
+ ```bash
77
+ curl -sf "$BASE/api/store/reviews/aggregate?product_id=prod_01tst00000000000000000001" \
78
+ -H "x-client-id: $CLIENT_ID" | grep -q '"distribution"'
79
+ ```
80
+
81
+ ## GET /api/store/reviews/widget — one-call widget payload
82
+
83
+ - **Purpose**: aggregate + first page (sized/sorted per the store's
84
+ Settings → Reviews display options) + display options in ONE call — what
85
+ the product widget + star badge mount from. Edge-cached 60s.
86
+ - **Auth**: anon (`x-client-id`).
87
+ - **Request**: query `{product_id}`.
88
+ - **Response**:
89
+
90
+ ```jsonc
91
+ {
92
+ "product_id": "prod_…",
93
+ "aggregate": { /* ReviewAggregate — shape above */ },
94
+ "reviews": [ /* PublicReview[] — first page */ ],
95
+ "count": 3,
96
+ "has_more": false,
97
+ "options": { "layout": "masonry", "page_size": 6, "photo_first": true }
98
+ }
99
+ ```
100
+
101
+ - **Errors**: `400 validation_failed`.
102
+ - **SDK**: `reviews.getWidget(client, productId)`
103
+ - **Components**: the review widget (masonry/list) + star badge.
104
+ - **Settings**: `widget_layout`, `widget_page_size`, `widget_photo_first`
105
+ (admin → Settings → Reviews).
106
+
107
+ ```bash
108
+ BODY=$(curl -sf "$BASE/api/store/reviews/widget?product_id=prod_01tst00000000000000000001" \
109
+ -H "x-client-id: $CLIENT_ID")
110
+ echo "$BODY" | grep -q '"aggregate"'
111
+ echo "$BODY" | grep -q '"options"'
112
+ echo "$BODY" | grep -q '"photo_first"'
113
+ ```
114
+
115
+ ## GET /api/store/reviews/token/:token — validate + form context
116
+
117
+ - **Purpose**: bootstrap the review form from the emailed link: validity,
118
+ product card, greeting, and the RESUME state. **Never cached** — state
119
+ changes on submit.
120
+ - **Auth**: anon (`x-client-id`); the token is the bearer secret.
121
+ - **Response** — ALWAYS 200, `TokenValidation`:
122
+
123
+ ```jsonc
124
+ // invalid
125
+ { "valid": false, "reason": "not_found" } // or "expired" | "invalid" (malformed/too short)
126
+ // valid
127
+ {
128
+ "valid": true,
129
+ "already_submitted": false,
130
+ "review": null, // {id, reward_code} once submitted
131
+ "product": { "id": "prod_…", "handle": "linen-shirt", "title": "Linen Shirt", "thumbnail": "https://…" },
132
+ "customer_name": "Елена",
133
+ "expires_at": "ISO-8601"
134
+ }
135
+ ```
136
+
137
+ - **Errors**: none over HTTP — failures are in-band (`valid: false`).
138
+ - **SDK**: `reviews.validateToken(client, token)`
139
+ - **Components**: review wizard entry route.
140
+
141
+ ```bash
142
+ # Unknown (but well-formed) token → in-band not_found, HTTP 200.
143
+ curl -sf "$BASE/api/store/reviews/token/doc-not-a-real-token-$RUN" \
144
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"not_found"'
145
+ # Malformed (too short) → "invalid".
146
+ curl -sf "$BASE/api/store/reviews/token/short" \
147
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"invalid"'
148
+ ```
149
+
150
+ ## POST /api/store/reviews — submit (wizard step 1)
151
+
152
+ - **Purpose**: create the review from a token. Consumes the token;
153
+ idempotent on the (order, product) unique — a race/retry returns the
154
+ existing review and still consumes the token.
155
+ - **Auth**: anon (`x-client-id`); the token is the auth.
156
+ - **Request**: `{token, rating: 1–5 int, body (REQUIRED — a rating alone is
157
+ not a review; HTML stripped, ≤2000 chars), media?}` — media ≤ 7 items
158
+ (≤6 images + ≤1 video), URLs must be on the store's file host (from
159
+ upload-url below). **No title field.**
160
+ - **Response**: `{id, success: true}`.
161
+ - **Errors**: `400 invalid_data` (shape / empty-after-sanitize body / media
162
+ rule) · `404 not_found` (unknown token) · `409 conflict` (token consumed
163
+ — replay) · `410 gone` (expired) · `429 rate_limited` (3/h per IP).
164
+ - **SDK**: `reviews.submitReview(client, input)`
165
+ - **Components**: review wizard, step 1.
166
+ - **Settings**: `moderation_mode: "hold"` lands the review as `pending`
167
+ (not publicly visible until approved); `auto_publish` goes live at once.
168
+
169
+ ```bash
170
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews" \
171
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
172
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "rating": 5, "body": "great"}')
173
+ test "$STATUS" = 404
174
+ ```
175
+
176
+ ## POST /api/store/reviews/:id/photo — attach media + reward (step 2)
177
+
178
+ - **Purpose**: attach media and mint the single-use reward code (a REAL
179
+ promotion, percentage-off-order — 10% default). The reward is for the
180
+ PHOTO, never the rating. Idempotent — retries return the same code; a
181
+ promo-mint failure never loses the media. A CONSUMED token is accepted
182
+ (step 1 consumed it).
183
+ - **Auth**: anon (`x-client-id`); the token must match the review's
184
+ (order, product) pair.
185
+ - **Request**: `{token, media (1–7 items, ≤6 images + ≤1 video, file-host
186
+ URLs only)}`.
187
+ - **Response**: `{code}` (fresh mint — also emails the `review-reward`
188
+ template) | `{code, already_issued: true}` (retry) | `{code: null,
189
+ message}` (mint failed; media saved).
190
+ - **Errors**: `400 invalid_data` · `403 forbidden` (token does not match
191
+ this review) · `404 not_found` (review or token unknown).
192
+ - **SDK**: `reviews.attachReviewPhoto(client, reviewId, input)`
193
+ - **Components**: review wizard, step 2 (photo + reward reveal).
194
+ - **Settings**: `reward_enabled`, `reward_percentage`.
195
+
196
+ ```bash
197
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
198
+ -X POST "$BASE/api/store/reviews/00000000-0000-0000-0000-00000000dead/photo" \
199
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
200
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "media": [{"type": "image", "url": "https://example.com/x.jpg"}]}')
201
+ test "$STATUS" = 400
202
+ ```
203
+
204
+ ## POST /api/store/reviews/upload-url — signed media upload
205
+
206
+ - **Purpose**: get a signed R2 PUT for the photo step. Upload the raw file
207
+ to `uploadUrl`, then reference `publicUrl` in the media array. A consumed
208
+ token is accepted; expiry still applies.
209
+ - **Auth**: anon (`x-client-id`); the token is the auth.
210
+ - **Request**: `{token, name, type (image/jpeg|jpg|png|webp or
211
+ video/mp4|quicktime), size?}` — caps: image ≤ 8MB, video ≤ 50MB.
212
+ - **Response**: `{uploadUrl, publicUrl, key, filename}`.
213
+ - **Errors**: `400 invalid_data` (unsupported MIME, too large) ·
214
+ `404 not_found` (unknown token) · `410 gone` (expired token).
215
+ - **SDK**: `reviews.createUploadUrl(client, input)`
216
+ - **Components**: review wizard photo picker.
217
+
218
+ ```bash
219
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews/upload-url" \
220
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
221
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "name": "x.jpg", "type": "image/jpeg"}')
222
+ test "$STATUS" = 404
223
+ ```