create-cartbase 0.1.20 → 0.1.22

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 (35) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +24 -24
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +23 -4
  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 +270 -24
  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/regions.md +269 -269
  16. package/template/app/docs/reviews.md +258 -238
  17. package/template/app/docs/search.md +227 -227
  18. package/template/app/docs/store.md +23 -1
  19. package/template/app/docs/subscriptions.md +148 -148
  20. package/template/app/docs/variables.md +331 -315
  21. package/template/app/package.json +1 -1
  22. package/template/app/postcss.config.cjs +11 -11
  23. package/template/app/src/app/checkout/checkout-empty.tsx +36 -0
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +80 -73
  25. package/template/app/src/app/checkout/error.tsx +23 -0
  26. package/template/app/src/app/checkout/page.tsx +16 -7
  27. package/template/app/src/app/globals.css +26 -26
  28. package/template/app/src/app/layout.tsx +126 -126
  29. package/template/app/src/app/page.tsx +37 -37
  30. package/template/app/src/app/products/[handle]/page.tsx +89 -89
  31. package/template/app/src/app/search/page.tsx +33 -33
  32. package/template/app/src/lib/browser-client.ts +35 -35
  33. package/template/app/src/lib/catalog.ts +120 -120
  34. package/template/app/src/lib/server-client.ts +25 -25
  35. package/template/app/src/lib/store-client.ts +16 -16
@@ -1,238 +1,258 @@
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": {
98
- "layout": "masonry", // or "list"
99
- "page_size": 6,
100
- "photo_first": true,
101
- "header": "minimal", // "minimal" | "compact" | "expanded"
102
- "header_content": "count", // the minimal header: "count" | "average" | "stars"
103
- "show_distribution": true, // the 5 to 1 star breakdown
104
- "show_sort": true, // the shopper's sort menu
105
- "show_date": true // the date on each card
106
- }
107
- }
108
- ```
109
-
110
- - **Errors**: `400 validation_failed`.
111
- - **SDK**: `reviews.getWidget(client, productId)`
112
- - **Components**: the review widget (header, masonry/list, Show more, the
113
- lightbox) + star badge.
114
- - **Settings**: admin, Reviews, Widgets: `widget_header`,
115
- `widget_header_content`, `widget_layout`, `widget_page_size`,
116
- `widget_photo_first`, `widget_show_distribution`, `widget_show_sort`,
117
- `widget_show_date`. The three headers are Loox's: `minimal` is the stars
118
- and one fact (`header_content`) with the breakdown under a chevron,
119
- `compact` the average, the stars and the count on one line, `expanded`
120
- the big score with the breakdown open beside it.
121
-
122
- ```bash
123
- BODY=$(curl -sf "$BASE/api/store/reviews/widget?product_id=prod_01tst00000000000000000001" \
124
- -H "x-client-id: $CLIENT_ID")
125
- echo "$BODY" | grep -q '"aggregate"'
126
- echo "$BODY" | grep -q '"options"'
127
- echo "$BODY" | grep -q '"photo_first"'
128
- ```
129
-
130
- ## GET /api/store/reviews/token/:token — validate + form context
131
-
132
- - **Purpose**: bootstrap the review form from the emailed link: validity,
133
- product card, greeting, and the RESUME state. **Never cached** — state
134
- changes on submit.
135
- - **Auth**: anon (`x-client-id`); the token is the bearer secret.
136
- - **Response** — ALWAYS 200, `TokenValidation`:
137
-
138
- ```jsonc
139
- // invalid
140
- { "valid": false, "reason": "not_found" } // or "expired" | "invalid" (malformed/too short)
141
- // valid
142
- {
143
- "valid": true,
144
- "already_submitted": false,
145
- "review": null, // {id, reward_code} once submitted
146
- "product": { "id": "prod_…", "handle": "linen-shirt", "title": "Linen Shirt", "thumbnail": "https://…" },
147
- "customer_name": "Елена",
148
- "expires_at": "ISO-8601"
149
- }
150
- ```
151
-
152
- - **Errors**: none over HTTP failures are in-band (`valid: false`).
153
- - **SDK**: `reviews.validateToken(client, token)`
154
- - **Components**: review wizard entry route.
155
-
156
- ```bash
157
- # Unknown (but well-formed) token → in-band not_found, HTTP 200.
158
- curl -sf "$BASE/api/store/reviews/token/doc-not-a-real-token-$RUN" \
159
- -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"not_found"'
160
- # Malformed (too short) "invalid".
161
- curl -sf "$BASE/api/store/reviews/token/short" \
162
- -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"invalid"'
163
- ```
164
-
165
- ## POST /api/store/reviews submit (wizard step 1)
166
-
167
- - **Purpose**: create the review from a token. Consumes the token;
168
- idempotent on the (order, product) unique — a race/retry returns the
169
- existing review and still consumes the token.
170
- - **Auth**: anon (`x-client-id`); the token is the auth.
171
- - **Request**: `{token, rating: 1–5 int, body (REQUIRED — a rating alone is
172
- not a review; HTML stripped, ≤2000 chars), media?}` media ≤ 7 items
173
- (≤6 images + ≤1 video), URLs must be on the store's file host (from
174
- upload-url below). **No title field.**
175
- - **Response**: `{id, success: true}`.
176
- - **Errors**: `400 invalid_data` (shape / empty-after-sanitize body / media
177
- rule) · `404 not_found` (unknown token) · `409 conflict` (token consumed
178
- replay) · `410 gone` (expired) · `429 rate_limited` (3/h per IP).
179
- - **SDK**: `reviews.submitReview(client, input)`
180
- - **Components**: review wizard, step 1.
181
- - **Settings**: `moderation_mode: "hold"` lands the review as `pending`
182
- (not publicly visible until approved); `auto_publish` goes live at once.
183
-
184
- ```bash
185
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews" \
186
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
187
- -d '{"token": "doc-not-a-real-token-'$RUN'", "rating": 5, "body": "great"}')
188
- test "$STATUS" = 404
189
- ```
190
-
191
- ## POST /api/store/reviews/:id/photo attach media + reward (step 2)
192
-
193
- - **Purpose**: attach media and mint the single-use reward code (a REAL
194
- promotion, percentage-off-order — 10% default). The reward is for the
195
- PHOTO, never the rating. Idempotent — retries return the same code; a
196
- promo-mint failure never loses the media. A CONSUMED token is accepted
197
- (step 1 consumed it).
198
- - **Auth**: anon (`x-client-id`); the token must match the review's
199
- (order, product) pair.
200
- - **Request**: `{token, media (1–7 items, ≤6 images + ≤1 video, file-host
201
- URLs only)}`.
202
- - **Response**: `{code}` (fresh mint also emails the `review-reward`
203
- template) | `{code, already_issued: true}` (retry) | `{code: null,
204
- message}` (mint failed; media saved).
205
- - **Errors**: `400 invalid_data` · `403 forbidden` (token does not match
206
- this review) · `404 not_found` (review or token unknown).
207
- - **SDK**: `reviews.attachReviewPhoto(client, reviewId, input)`
208
- - **Components**: review wizard, step 2 (photo + reward reveal).
209
- - **Settings**: `reward_enabled`, `reward_percentage`.
210
-
211
- ```bash
212
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
213
- -X POST "$BASE/api/store/reviews/00000000-0000-0000-0000-00000000dead/photo" \
214
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
215
- -d '{"token": "doc-not-a-real-token-'$RUN'", "media": [{"type": "image", "url": "https://example.com/x.jpg"}]}')
216
- test "$STATUS" = 400
217
- ```
218
-
219
- ## POST /api/store/reviews/upload-url — signed media upload
220
-
221
- - **Purpose**: get a signed R2 PUT for the photo step. Upload the raw file
222
- to `uploadUrl`, then reference `publicUrl` in the media array. A consumed
223
- token is accepted; expiry still applies.
224
- - **Auth**: anon (`x-client-id`); the token is the auth.
225
- - **Request**: `{token, name, type (image/jpeg|jpg|png|webp or
226
- video/mp4|quicktime), size?}` caps: image 8MB, video ≤ 50MB.
227
- - **Response**: `{uploadUrl, publicUrl, key, filename}`.
228
- - **Errors**: `400 invalid_data` (unsupported MIME, too large) ·
229
- `404 not_found` (unknown token) · `410 gone` (expired token).
230
- - **SDK**: `reviews.createUploadUrl(client, input)`
231
- - **Components**: review wizard photo picker.
232
-
233
- ```bash
234
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews/upload-url" \
235
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
236
- -d '{"token": "doc-not-a-real-token-'$RUN'", "name": "x.jpg", "type": "image/jpeg"}')
237
- test "$STATUS" = 404
238
- ```
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/summary: the store's rating
82
+
83
+ - **Purpose**: the trust band. Every visible review of the store together:
84
+ the count, the average, the 5 to 1 breakdown, how many carry a photo or
85
+ a video, and the newest five star reviews that have words. One
86
+ statement. Edge-cached 60s.
87
+ - **Auth**: anon (`x-client-id`).
88
+ - **Request**: query `{limit?}`, the reviews served, 0 to 24, default 12.
89
+ - **Response**: `{count, avg_rating, distribution: {"1"…"5"}, with_media,
90
+ reviews: PublicReview[]}`; `avg_rating` rounded to 1 decimal, 0 when
91
+ there are no reviews.
92
+ - **Errors**: `400 validation_failed` (a limit outside 0 to 24).
93
+ - **SDK**: none yet; `client.get("/api/store/reviews/summary", { query: { limit } })`.
94
+ - **Components**: none yet in the kit; evoo's trust band is the design phase.
95
+
96
+ ```bash
97
+ curl -sf "$BASE/api/store/reviews/summary?limit=6" \
98
+ -H "x-client-id: $CLIENT_ID" | grep -q '"with_media"'
99
+ ```
100
+
101
+ ## GET /api/store/reviews/widget one-call widget payload
102
+
103
+ - **Purpose**: aggregate + first page (sized/sorted per the store's
104
+ Settings Reviews display options) + display options in ONE call — what
105
+ the product widget + star badge mount from. Edge-cached 60s.
106
+ - **Auth**: anon (`x-client-id`).
107
+ - **Request**: query `{product_id}`.
108
+ - **Response**:
109
+
110
+ ```jsonc
111
+ {
112
+ "product_id": "prod_…",
113
+ "aggregate": { /* ReviewAggregate — shape above */ },
114
+ "reviews": [ /* PublicReview[] first page */ ],
115
+ "count": 3,
116
+ "has_more": false,
117
+ "options": {
118
+ "layout": "masonry", // or "list"
119
+ "page_size": 20,
120
+ "photo_first": true,
121
+ "header": "minimal", // "minimal" | "compact" | "expanded"
122
+ "header_content": "count", // the minimal header: "count" | "average" | "stars"
123
+ "show_distribution": true, // the 5 to 1 star breakdown
124
+ "show_sort": true, // the shopper's sort menu
125
+ "show_date": true // the date on each card
126
+ }
127
+ }
128
+ ```
129
+
130
+ - **Errors**: `400 validation_failed`.
131
+ - **SDK**: `reviews.getWidget(client, productId)`
132
+ - **Components**: the review widget (header, masonry/list, Show more, the
133
+ lightbox) + star badge.
134
+ - **Settings**: admin, Reviews, Widgets: `widget_header`,
135
+ `widget_header_content`, `widget_layout`, `widget_page_size`,
136
+ `widget_photo_first`, `widget_show_distribution`, `widget_show_sort`,
137
+ `widget_show_date`. The three headers are Loox's: `minimal` is the stars
138
+ and one fact (`header_content`) with the breakdown under a chevron,
139
+ `compact` the average, the stars and the count on one line, `expanded`
140
+ the big score with the breakdown open beside it.
141
+
142
+ ```bash
143
+ BODY=$(curl -sf "$BASE/api/store/reviews/widget?product_id=prod_01tst00000000000000000001" \
144
+ -H "x-client-id: $CLIENT_ID")
145
+ echo "$BODY" | grep -q '"aggregate"'
146
+ echo "$BODY" | grep -q '"options"'
147
+ echo "$BODY" | grep -q '"photo_first"'
148
+ ```
149
+
150
+ ## GET /api/store/reviews/token/:token — validate + form context
151
+
152
+ - **Purpose**: bootstrap the review form from the emailed link: validity,
153
+ product card, greeting, and the RESUME state. **Never cached** — state
154
+ changes on submit.
155
+ - **Auth**: anon (`x-client-id`); the token is the bearer secret.
156
+ - **Response** — ALWAYS 200, `TokenValidation`:
157
+
158
+ ```jsonc
159
+ // invalid
160
+ { "valid": false, "reason": "not_found" } // or "expired" | "invalid" (malformed/too short)
161
+ // valid
162
+ {
163
+ "valid": true,
164
+ "already_submitted": false,
165
+ "review": null, // {id, reward_code} once submitted
166
+ "product": { "id": "prod_…", "handle": "linen-shirt", "title": "Linen Shirt", "thumbnail": "https://…" },
167
+ "customer_name": "Елена",
168
+ "expires_at": "ISO-8601"
169
+ }
170
+ ```
171
+
172
+ - **Errors**: none over HTTP failures are in-band (`valid: false`).
173
+ - **SDK**: `reviews.validateToken(client, token)`
174
+ - **Components**: review wizard entry route.
175
+
176
+ ```bash
177
+ # Unknown (but well-formed) token in-band not_found, HTTP 200.
178
+ curl -sf "$BASE/api/store/reviews/token/doc-not-a-real-token-$RUN" \
179
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"not_found"'
180
+ # Malformed (too short) "invalid".
181
+ curl -sf "$BASE/api/store/reviews/token/short" \
182
+ -H "x-client-id: $CLIENT_ID" | grep -q '"reason":"invalid"'
183
+ ```
184
+
185
+ ## POST /api/store/reviews — submit (wizard step 1)
186
+
187
+ - **Purpose**: create the review from a token. Consumes the token;
188
+ idempotent on the (order, product) unique — a race/retry returns the
189
+ existing review and still consumes the token.
190
+ - **Auth**: anon (`x-client-id`); the token is the auth.
191
+ - **Request**: `{token, rating: 1–5 int, body (REQUIRED — a rating alone is
192
+ not a review; HTML stripped, ≤2000 chars), media?}` — media ≤ 7 items
193
+ (≤6 images + ≤1 video), URLs must be on the store's file host (from
194
+ upload-url below). **No title field.**
195
+ - **Response**: `{id, success: true}`.
196
+ - **Errors**: `400 invalid_data` (shape / empty-after-sanitize body / media
197
+ rule) · `404 not_found` (unknown token) · `409 conflict` (token consumed
198
+ replay) · `410 gone` (expired) · `429 rate_limited` (3/h per IP).
199
+ - **SDK**: `reviews.submitReview(client, input)`
200
+ - **Components**: review wizard, step 1.
201
+ - **Settings**: `moderation_mode: "hold"` lands the review as `pending`
202
+ (not publicly visible until approved); `auto_publish` goes live at once.
203
+
204
+ ```bash
205
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews" \
206
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
207
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "rating": 5, "body": "great"}')
208
+ test "$STATUS" = 404
209
+ ```
210
+
211
+ ## POST /api/store/reviews/:id/photo — attach media + reward (step 2)
212
+
213
+ - **Purpose**: attach media and mint the single-use reward code (a REAL
214
+ promotion, percentage-off-order 10% default). The reward is for the
215
+ PHOTO, never the rating. Idempotent retries return the same code; a
216
+ promo-mint failure never loses the media. A CONSUMED token is accepted
217
+ (step 1 consumed it).
218
+ - **Auth**: anon (`x-client-id`); the token must match the review's
219
+ (order, product) pair.
220
+ - **Request**: `{token, media (1–7 items, ≤6 images + ≤1 video, file-host
221
+ URLs only)}`.
222
+ - **Response**: `{code}` (fresh mint also emails the `review-reward`
223
+ template) | `{code, already_issued: true}` (retry) | `{code: null,
224
+ message}` (mint failed; media saved).
225
+ - **Errors**: `400 invalid_data` · `403 forbidden` (token does not match
226
+ this review) · `404 not_found` (review or token unknown).
227
+ - **SDK**: `reviews.attachReviewPhoto(client, reviewId, input)`
228
+ - **Components**: review wizard, step 2 (photo + reward reveal).
229
+ - **Settings**: `reward_enabled`, `reward_percentage`.
230
+
231
+ ```bash
232
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
233
+ -X POST "$BASE/api/store/reviews/00000000-0000-0000-0000-00000000dead/photo" \
234
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
235
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "media": [{"type": "image", "url": "https://example.com/x.jpg"}]}')
236
+ test "$STATUS" = 400
237
+ ```
238
+
239
+ ## POST /api/store/reviews/upload-url — signed media upload
240
+
241
+ - **Purpose**: get a signed R2 PUT for the photo step. Upload the raw file
242
+ to `uploadUrl`, then reference `publicUrl` in the media array. A consumed
243
+ token is accepted; expiry still applies.
244
+ - **Auth**: anon (`x-client-id`); the token is the auth.
245
+ - **Request**: `{token, name, type (image/jpeg|jpg|png|webp or
246
+ video/mp4|quicktime), size?}` — caps: image ≤ 8MB, video ≤ 50MB.
247
+ - **Response**: `{uploadUrl, publicUrl, key, filename}`.
248
+ - **Errors**: `400 invalid_data` (unsupported MIME, too large) ·
249
+ `404 not_found` (unknown token) · `410 gone` (expired token).
250
+ - **SDK**: `reviews.createUploadUrl(client, input)`
251
+ - **Components**: review wizard photo picker.
252
+
253
+ ```bash
254
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/reviews/upload-url" \
255
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
256
+ -d '{"token": "doc-not-a-real-token-'$RUN'", "name": "x.jpg", "type": "image/jpeg"}')
257
+ test "$STATUS" = 404
258
+ ```