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.
- package/LICENSE +21 -21
- package/README.md +25 -25
- package/dist/index.js +20 -20
- package/package.json +1 -1
- package/template/app/docs/auth.md +105 -105
- package/template/app/docs/carts.md +376 -376
- package/template/app/docs/categories.md +194 -194
- package/template/app/docs/checkout.md +714 -714
- package/template/app/docs/components.md +55 -11
- package/template/app/docs/consent.md +91 -91
- package/template/app/docs/deploy.md +197 -197
- package/template/app/docs/gift-cards.md +153 -153
- package/template/app/docs/metaobjects.md +126 -126
- package/template/app/docs/orders.md +221 -221
- package/template/app/docs/products.md +51 -2
- package/template/app/docs/regions.md +269 -269
- package/template/app/docs/reviews.md +223 -223
- package/template/app/docs/search.md +227 -227
- package/template/app/docs/store.md +47 -47
- package/template/app/docs/subscriptions.md +148 -148
- package/template/app/docs/variables.md +315 -315
- package/template/app/package.json +1 -1
- package/template/app/postcss.config.cjs +11 -11
- package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
- package/template/app/src/app/checkout/page.tsx +48 -48
- package/template/app/src/app/globals.css +26 -26
- package/template/app/src/app/page.tsx +28 -28
- package/template/app/src/app/products/[handle]/page.tsx +87 -87
- package/template/app/src/app/providers.tsx +68 -64
- package/template/app/src/app/search/page.tsx +23 -23
- package/template/app/src/lib/browser-client.ts +35 -35
- package/template/app/src/lib/config.ts +41 -41
- package/template/app/src/lib/server-client.ts +25 -25
- 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
|
+
```
|