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.
- package/LICENSE +21 -0
- package/README.md +9 -3
- package/dist/index.js +94 -0
- package/package.json +18 -4
- package/template/app/CLAUDE.md +18 -0
- package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
- package/template/app/docs/README.md +77 -0
- package/template/app/docs/auth.md +105 -0
- package/template/app/docs/carts.md +376 -0
- package/template/app/docs/categories.md +194 -0
- package/template/app/docs/checkout.md +611 -0
- package/template/app/docs/collections.md +167 -0
- package/template/app/docs/components.md +1090 -0
- package/template/app/docs/consent.md +81 -0
- package/template/app/docs/content.md +126 -0
- package/template/app/docs/customers.md +269 -0
- package/template/app/docs/deploy.md +192 -0
- package/template/app/docs/gift-cards.md +153 -0
- package/template/app/docs/integrations.md +137 -0
- package/template/app/docs/menus.md +73 -0
- package/template/app/docs/metaobjects.md +126 -0
- package/template/app/docs/orders.md +221 -0
- package/template/app/docs/platform.md +126 -0
- package/template/app/docs/products.md +300 -0
- package/template/app/docs/redirects.md +50 -0
- package/template/app/docs/regions.md +206 -0
- package/template/app/docs/reviews.md +223 -0
- package/template/app/docs/search.md +218 -0
- package/template/app/docs/subscriptions.md +148 -0
- package/template/app/next.config.ts +34 -0
- package/template/app/package.json +25 -0
- package/template/app/postcss.config.cjs +6 -0
- package/template/app/smoke.mjs +158 -0
- package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
- package/template/app/src/app/checkout/page.tsx +51 -0
- package/template/app/src/app/globals.css +42 -0
- package/template/app/src/app/layout.tsx +113 -0
- package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
- package/template/app/src/app/page.tsx +25 -0
- package/template/app/src/app/products/[handle]/page.tsx +58 -0
- package/template/app/src/app/providers.tsx +54 -0
- package/template/app/src/app/search/page.tsx +20 -0
- package/template/app/src/lib/browser-client.ts +35 -0
- package/template/app/src/lib/cart-actions.ts +47 -0
- package/template/app/src/lib/config.ts +21 -0
- package/template/app/src/lib/server-client.ts +25 -0
- package/template/app/tailwind.config.cjs +9 -0
- package/template/app/tsconfig.json +41 -0
- 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
|
+
```
|