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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/index.js +94 -0
  4. package/package.json +18 -4
  5. package/template/app/CLAUDE.md +18 -0
  6. package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
  7. package/template/app/docs/README.md +76 -0
  8. package/template/app/docs/auth.md +105 -0
  9. package/template/app/docs/carts.md +376 -0
  10. package/template/app/docs/categories.md +194 -0
  11. package/template/app/docs/checkout.md +611 -0
  12. package/template/app/docs/collections.md +167 -0
  13. package/template/app/docs/components.md +1089 -0
  14. package/template/app/docs/consent.md +81 -0
  15. package/template/app/docs/content.md +126 -0
  16. package/template/app/docs/customers.md +269 -0
  17. package/template/app/docs/deploy.md +192 -0
  18. package/template/app/docs/gift-cards.md +153 -0
  19. package/template/app/docs/integrations.md +137 -0
  20. package/template/app/docs/menus.md +73 -0
  21. package/template/app/docs/metaobjects.md +126 -0
  22. package/template/app/docs/orders.md +221 -0
  23. package/template/app/docs/products.md +300 -0
  24. package/template/app/docs/redirects.md +50 -0
  25. package/template/app/docs/regions.md +207 -0
  26. package/template/app/docs/reviews.md +223 -0
  27. package/template/app/docs/search.md +218 -0
  28. package/template/app/docs/subscriptions.md +148 -0
  29. package/template/app/next.config.ts +34 -0
  30. package/template/app/package.json +25 -0
  31. package/template/app/postcss.config.cjs +6 -0
  32. package/template/app/smoke.mjs +158 -0
  33. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  34. package/template/app/src/app/checkout/page.tsx +49 -0
  35. package/template/app/src/app/globals.css +42 -0
  36. package/template/app/src/app/layout.tsx +105 -0
  37. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  38. package/template/app/src/app/page.tsx +25 -0
  39. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  40. package/template/app/src/app/providers.tsx +54 -0
  41. package/template/app/src/app/search/page.tsx +20 -0
  42. package/template/app/src/lib/browser-client.ts +35 -0
  43. package/template/app/src/lib/cart-actions.ts +43 -0
  44. package/template/app/src/lib/config.ts +16 -0
  45. package/template/app/src/lib/server-client.ts +25 -0
  46. package/template/app/tailwind.config.cjs +9 -0
  47. package/template/app/tsconfig.json +41 -0
  48. 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&currency_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.