create-cartbase 0.0.1 → 0.1.0
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 +76 -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 +1089 -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/products.md +300 -0
- package/template/app/docs/redirects.md +50 -0
- package/template/app/docs/regions.md +207 -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 +49 -0
- package/template/app/src/app/globals.css +42 -0
- package/template/app/src/app/layout.tsx +105 -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 +43 -0
- package/template/app/src/lib/config.ts +16 -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,207 @@
|
|
|
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 (divergence from Medusa's Store API — country/tax scope
|
|
33
|
+
lives server-side in `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 (per-store
|
|
206
|
+
`store_locales` manager; Cartbase's intentional divergence from Medusa's
|
|
207
|
+
global locale catalog).
|
|
@@ -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
|
+
```
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Search & related products
|
|
2
|
+
|
|
3
|
+
Configurable storefront search (search-discovery card) plus the PDP's
|
|
4
|
+
related-products rail. Results are the SAME canonical product objects the
|
|
5
|
+
products listing serves (see products.md) — reuse your product-card renderer
|
|
6
|
+
as-is. Money is EUR decimal major units.
|
|
7
|
+
|
|
8
|
+
SDK module: `@cartbase/storefront/api/search`.
|
|
9
|
+
|
|
10
|
+
**Matching** — title/subtitle/description full-text (Cyrillic-correct
|
|
11
|
+
'simple' config, prefix match) ∪ typo tolerance via trigram similarity on
|
|
12
|
+
the title ("linnen" finds "Linen Shirt") ∪ SKU prefix ∪ exact tag value.
|
|
13
|
+
Draft products, other tenants, and products outside the publishable key's
|
|
14
|
+
channels NEVER appear.
|
|
15
|
+
|
|
16
|
+
**Synonyms (admin-authored)** — expansion is BIDIRECTIONAL and SINGLE-LEVEL
|
|
17
|
+
(bounded — expansions never re-expand; capped at 24 terms): a query word
|
|
18
|
+
matching a row's term adds its synonyms; a query word matching one of a
|
|
19
|
+
row's synonyms adds the term + its sibling synonyms. So with the admin row
|
|
20
|
+
`term: "зехтин", synonyms: ["olive oil"]`, EITHER direction finds the
|
|
21
|
+
product. Terms are normalized lowercase.
|
|
22
|
+
|
|
23
|
+
**Pins & boosts (admin-authored)** — ordering is: (1) pins for this EXACT
|
|
24
|
+
normalized query, in position order — pins are INJECTED even without a text
|
|
25
|
+
match, but only if they pass filters + channel scope + published (the leak
|
|
26
|
+
rule applies to pins too); then (2) globally-boosted products WITHIN the
|
|
27
|
+
match set, in position order; then (3) relevance rank with a deterministic
|
|
28
|
+
tie-break. Global boosts reorder, never inject.
|
|
29
|
+
|
|
30
|
+
**Cache rule** — responses carry `calculated_price` when a pricing context
|
|
31
|
+
is given; they vary by customer group — never cache shared when a JWT was
|
|
32
|
+
present.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## GET /api/store/products/search
|
|
37
|
+
|
|
38
|
+
- **Purpose** — the search results page + autocomplete backend.
|
|
39
|
+
- **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
|
|
40
|
+
(channel scope, products-listing semantics); Bearer JWT optional (group
|
|
41
|
+
pricing).
|
|
42
|
+
- **Request**
|
|
43
|
+
|
|
44
|
+
```jsonc
|
|
45
|
+
// query — q is REQUIRED (min 1 char), everything else optional
|
|
46
|
+
{
|
|
47
|
+
"q": "linen",
|
|
48
|
+
"collection_id": "pcol_a,pcol_b", // CSV
|
|
49
|
+
"category_id": "pcat_a", // CSV
|
|
50
|
+
"tag_id": "ptag_a", // CSV
|
|
51
|
+
"type_id": "ptyp_a", // CSV
|
|
52
|
+
"price_min": 10, // major units, on the product's cheapest base price
|
|
53
|
+
"price_max": 50,
|
|
54
|
+
"availability": "in_stock", // in_stock | out_of_stock
|
|
55
|
+
// Option filters — dynamic keys, repeatable; OR within an option, AND
|
|
56
|
+
// across options. URL-encode titles ("Length (cm)" → option.Length%20(cm)):
|
|
57
|
+
"option.Size": "S,M",
|
|
58
|
+
"currency_code": "eur", // pricing context (default price facet currency: eur)
|
|
59
|
+
"region_id": "reg_…",
|
|
60
|
+
"limit": 20, // 1–100, default 20
|
|
61
|
+
"offset": 0
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **Response**
|
|
66
|
+
|
|
67
|
+
```jsonc
|
|
68
|
+
{
|
|
69
|
+
"results": [ /* canonical product objects — see products.md */ ],
|
|
70
|
+
"facets": [
|
|
71
|
+
{
|
|
72
|
+
"key": "price", // "price" | "availability" | "type" |
|
|
73
|
+
// "tags" | "collection" | "options.<Title>"
|
|
74
|
+
"label": "Price",
|
|
75
|
+
"type": "range", // "range" for price, "value" otherwise
|
|
76
|
+
"buckets": [
|
|
77
|
+
{ "value": "20-40", "label": "€20 – €40", "count": 3,
|
|
78
|
+
"min": 20, "max": 40 } // min/max on range buckets only; max null = open top
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"key": "availability", "label": "Availability", "type": "value",
|
|
83
|
+
"buckets": [ { "value": "in_stock", "label": "In stock", "count": 5 } ]
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"total": 5,
|
|
87
|
+
"offset": 0,
|
|
88
|
+
"limit": 20
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Facets** follow the tenant's Settings → Search & discovery → Filters
|
|
93
|
+
config (order + enabled; defaults: price, availability, type, tags,
|
|
94
|
+
collection). **The faceting count rule:** each facet's bucket counts are
|
|
95
|
+
computed on the result set filtered by every OTHER active filter — the
|
|
96
|
+
facet's own dimension is excluded, so selecting "Size: M" never zeroes the
|
|
97
|
+
other sizes. Empty buckets are omitted; a facet with no buckets is
|
|
98
|
+
omitted (the dev tenant has no tags/types, so those facets simply don't
|
|
99
|
+
appear). Price buckets: `auto` (deterministic nice-number split of
|
|
100
|
+
observed prices, ≤10 buckets) or `fixed` admin ranges; apply one by
|
|
101
|
+
passing its `min`/`max` back as `price_min`/`price_max`. Value-facet
|
|
102
|
+
buckets carry ids as `value` (type/tags/collection) or the option value
|
|
103
|
+
string (`options.<Title>`).
|
|
104
|
+
|
|
105
|
+
- **Working curl** — seeded catalog: Linen Shirt (45), Wool Beanie (23),
|
|
106
|
+
Leather Belt (60), all in the `essentials` collection:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
SEARCH=$(curl -sf "$BASE/api/store/products/search?q=linen¤cy_code=eur" \
|
|
110
|
+
-H "x-client-id: $CLIENT_ID")
|
|
111
|
+
echo "$SEARCH" | grep -q '"results"'
|
|
112
|
+
echo "$SEARCH" | grep -q '"facets"'
|
|
113
|
+
echo "$SEARCH" | grep -q '"total"'
|
|
114
|
+
echo "$SEARCH" | grep -q '"handle":"linen-shirt"'
|
|
115
|
+
echo "$SEARCH" | grep -q '"calculated_price"'
|
|
116
|
+
# Default facet config puts price + availability on a priced, stocked catalog:
|
|
117
|
+
echo "$SEARCH" | grep -q '"key":"price"'
|
|
118
|
+
echo "$SEARCH" | grep -q '"key":"availability"'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
# Typo tolerance (title trigram): "linnen" still finds the Linen Shirt.
|
|
123
|
+
curl -sf "$BASE/api/store/products/search?q=linnen" -H "x-client-id: $CLIENT_ID" \
|
|
124
|
+
| grep -q '"handle":"linen-shirt"'
|
|
125
|
+
# SKU prefix match:
|
|
126
|
+
curl -sf "$BASE/api/store/products/search?q=LIN-SHIRT" -H "x-client-id: $CLIENT_ID" \
|
|
127
|
+
| grep -q '"handle":"linen-shirt"'
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Option filter: only the Linen Shirt has Size S; the beanie (Color) and the
|
|
132
|
+
# belt (Length (cm)) don't match option.Size at all.
|
|
133
|
+
OPT=$(curl -sf "$BASE/api/store/products/search?q=linen&option.Size=S" \
|
|
134
|
+
-H "x-client-id: $CLIENT_ID")
|
|
135
|
+
echo "$OPT" | grep -q '"handle":"linen-shirt"'
|
|
136
|
+
# A value no variant carries → empty result set (envelope intact):
|
|
137
|
+
curl -sf "$BASE/api/store/products/search?q=linen&option.Size=XXL" \
|
|
138
|
+
-H "x-client-id: $CLIENT_ID" | grep -q '"results":\[\]'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
# Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel
|
|
143
|
+
# (shirt + beanie). "leather" matches anon but returns nothing under the key.
|
|
144
|
+
curl -sf "$BASE/api/store/products/search?q=leather" -H "x-client-id: $CLIENT_ID" \
|
|
145
|
+
| grep -q '"handle":"leather-belt"'
|
|
146
|
+
curl -sf "$BASE/api/store/products/search?q=leather" \
|
|
147
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
|
|
148
|
+
| grep -q '"results":\[\]'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
- **Errors** — 400 `validation_failed` (missing/empty `q`, bad
|
|
152
|
+
`availability`, limit > 100), 400 `missing_client_id`,
|
|
153
|
+
400 `invalid_publishable_key`, 400 `invalid_region`.
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
157
|
+
"$BASE/api/store/products/search" -H "x-client-id: $CLIENT_ID")
|
|
158
|
+
test "$STATUS" = 400
|
|
159
|
+
curl -s "$BASE/api/store/products/search" -H "x-client-id: $CLIENT_ID" \
|
|
160
|
+
| grep -q '"code":"validation_failed"'
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **SDK** — `searchProducts(client, query)` — pass option filters as
|
|
164
|
+
`options: { Size: ["S","M"] }`; the SDK folds them into `option.<Title>`
|
|
165
|
+
params.
|
|
166
|
+
- **Components** — search page, facet sidebar, autocomplete.
|
|
167
|
+
- **Settings** — Search & discovery: synonyms, pins (per-query) + global
|
|
168
|
+
boosts, facet order/enabled, price bucket strategy (auto/fixed).
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## GET /api/store/products/:idOrHandle/related
|
|
173
|
+
|
|
174
|
+
- **Purpose** — the PDP's related-products rail. Manual admin picks first
|
|
175
|
+
(position order), then a DETERMINISTIC fallback fills to `limit`: same
|
|
176
|
+
primary collection (newest first), then most-shared-tags.
|
|
177
|
+
`auto_filled: true` when any fallback item is present.
|
|
178
|
+
- **Auth** — anon: `x-client-id`; optional publishable key — the anchor
|
|
179
|
+
product must be visible to the key, and scoped-away products never appear
|
|
180
|
+
as related items; optional Bearer JWT (group pricing).
|
|
181
|
+
- **Request** — query `{ limit? (1–24, default 12), currency_code?,
|
|
182
|
+
region_id? }`. Accepts a product id (`prod_…`) or handle.
|
|
183
|
+
- **Response**
|
|
184
|
+
|
|
185
|
+
```jsonc
|
|
186
|
+
{
|
|
187
|
+
"products": [ /* canonical product objects, self-excluded */ ],
|
|
188
|
+
"count": 2,
|
|
189
|
+
"auto_filled": true // at least one item came from the fallback chain
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- **Working curl** — the seeded products share the `essentials` collection,
|
|
194
|
+
so the shirt's rail fills from it:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
RELATED=$(curl -sf "$BASE/api/store/products/linen-shirt/related?currency_code=eur" \
|
|
198
|
+
-H "x-client-id: $CLIENT_ID")
|
|
199
|
+
echo "$RELATED" | grep -q '"products"'
|
|
200
|
+
echo "$RELATED" | grep -q '"auto_filled"'
|
|
201
|
+
echo "$RELATED" | grep -q '"count"'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- **Errors** — 404 `not_found` (unknown id/handle, draft, or anchor outside
|
|
205
|
+
the key's channels), 400 `validation_failed`, 400 `invalid_region`.
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Channel scope on the ANCHOR: the belt is outside the B2B key's channels.
|
|
209
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
210
|
+
"$BASE/api/store/products/leather-belt/related" \
|
|
211
|
+
-H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
|
|
212
|
+
test "$STATUS" = 404
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **SDK** — `listRelatedProducts(client, idOrHandle, query?)`.
|
|
216
|
+
- **Components** — PDP related rail.
|
|
217
|
+
- **Settings** — manual picks: Admin → Product → Related (drag-ordered,
|
|
218
|
+
≤24); the fallback chain needs no configuration.
|