create-cartbase 0.1.16 → 0.1.18

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 (34) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +1 -1
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +376 -376
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +55 -11
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/products.md +51 -2
  16. package/template/app/docs/regions.md +269 -269
  17. package/template/app/docs/reviews.md +223 -223
  18. package/template/app/docs/search.md +227 -227
  19. package/template/app/docs/store.md +47 -47
  20. package/template/app/docs/subscriptions.md +148 -148
  21. package/template/app/docs/variables.md +315 -315
  22. package/template/app/package.json +1 -1
  23. package/template/app/postcss.config.cjs +11 -11
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  25. package/template/app/src/app/checkout/page.tsx +48 -48
  26. package/template/app/src/app/globals.css +26 -26
  27. package/template/app/src/app/page.tsx +28 -28
  28. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  29. package/template/app/src/app/providers.tsx +68 -64
  30. package/template/app/src/app/search/page.tsx +23 -23
  31. package/template/app/src/lib/browser-client.ts +35 -35
  32. package/template/app/src/lib/config.ts +41 -41
  33. package/template/app/src/lib/server-client.ts +25 -25
  34. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,223 +1,223 @@
1
- # Reviews
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
- ```
1
+ # Reviews
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
+ ```