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,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.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Subscriptions — the customer portal
|
|
2
|
+
|
|
3
|
+
The "My subscriptions" surface (subscriptions-portal card): list, detail,
|
|
4
|
+
schedule control, contract edits, cancel/reactivate and payment-method
|
|
5
|
+
recovery. Every endpoint requires a **customer session**
|
|
6
|
+
(`authorization: Bearer <supabase jwt>` — see [auth.md](auth.md)) **plus**
|
|
7
|
+
`x-client-id`. Missing/invalid JWT → `401 {code: "unauthenticated"}`;
|
|
8
|
+
a subscription that isn't the caller's own → **`404 not_found`** (never
|
|
9
|
+
403 — existence is not confirmed across accounts).
|
|
10
|
+
|
|
11
|
+
The API contract (all routes, shapes, error codes):
|
|
12
|
+
`docs/contracts/store-api.md` § Subscriptions portal. This doc is the
|
|
13
|
+
component-facing guide.
|
|
14
|
+
|
|
15
|
+
> The executable blocks prove the **auth boundary** — the docs harness is
|
|
16
|
+
> anonymous; happy paths are pinned by
|
|
17
|
+
> `tests/store/subscriptions-portal.test.ts` and
|
|
18
|
+
> `tests/store/subscription-payment-update.test.ts` with real sessions.
|
|
19
|
+
|
|
20
|
+
## Render actions from `permissions` — never hardcode
|
|
21
|
+
|
|
22
|
+
Every detail payload carries the merchant's live portal policy:
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
"permissions": {
|
|
26
|
+
"allow_skip": true, // merchant toggles (Settings → Subscriptions)
|
|
27
|
+
"allow_reschedule": true,
|
|
28
|
+
"allow_pause": true,
|
|
29
|
+
"allow_frequency_change": true,
|
|
30
|
+
"allow_line_edits": true,
|
|
31
|
+
"allow_address_change": true,
|
|
32
|
+
"allow_cancel": true, // constant — cancel is a customer right
|
|
33
|
+
"can_update_payment": false // true only for card (Stripe) contracts
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Components MUST render conditionally from this object — a toggled-off
|
|
38
|
+
action answers `403 {code: "portal_action_disabled"}`, so hiding the button
|
|
39
|
+
is UX, the server is the enforcement. Cancel is ALWAYS shown (the cancel
|
|
40
|
+
law: retention offers may render alongside, never instead). Payment update
|
|
41
|
+
renders only when `can_update_payment` — COD/offline contracts have no card.
|
|
42
|
+
|
|
43
|
+
## GET /api/store/subscriptions — my contracts
|
|
44
|
+
|
|
45
|
+
`{ subscriptions: [...], count }` — newest first. `payment_method` is the
|
|
46
|
+
merchant-facing display name from the payments registry (never `pp_*`).
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions" \
|
|
50
|
+
-H "x-client-id: $CLIENT_ID")
|
|
51
|
+
test "$STATUS" = 401
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## GET /api/store/subscriptions/:id — the receipt view
|
|
55
|
+
|
|
56
|
+
Sanitized detail: `plan`, `lines` (titles + contracted `unit_price`),
|
|
57
|
+
`cycles` (index, status, date, linked order display id — no internal error
|
|
58
|
+
strings), `upcoming` (next 3 PROJECTED charge dates; empty unless active)
|
|
59
|
+
and `permissions`. Render the cycle list as order history; a `failed` cycle
|
|
60
|
+
plus `can_update_payment` is the cue to surface the payment-update flow
|
|
61
|
+
prominently (that pairing IS dunning recovery).
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions/sub_doesnotexist" \
|
|
65
|
+
-H "x-client-id: $CLIENT_ID")
|
|
66
|
+
test "$STATUS" = 401
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Actions
|
|
70
|
+
|
|
71
|
+
All POST, all answer `{ subscription }` (the fresh detail — re-render from
|
|
72
|
+
it, no refetch needed):
|
|
73
|
+
|
|
74
|
+
| Route | Body | Gate |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `/:id/skip` | — | `allow_skip` |
|
|
77
|
+
| `/:id/charge-date` | `{ next_charge_at }` | `allow_reschedule` |
|
|
78
|
+
| `/:id/pause` | `{ until? }` | `allow_pause` |
|
|
79
|
+
| `/:id/resume` | — | right |
|
|
80
|
+
| `/:id/cancel` | `{ reason? }` | right |
|
|
81
|
+
| `/:id/reactivate` | `{ next_charge_at? }` | right |
|
|
82
|
+
| `/:id/address` | `{ shipping_address }` | `allow_address_change` |
|
|
83
|
+
| `/:id/lines/:lineId` | `{ quantity?, variant_id? }` | `allow_line_edits` |
|
|
84
|
+
| `/:id/plan` | `{ selling_plan_id }` | `allow_frequency_change` |
|
|
85
|
+
|
|
86
|
+
Component notes:
|
|
87
|
+
|
|
88
|
+
- **Skip** — confirm-dialog copy should show the NEW next date (current
|
|
89
|
+
next date + one plan interval). A cycle mid-payment-retry cannot skip
|
|
90
|
+
(400) — hide skip while the latest cycle is `failed`.
|
|
91
|
+
- **Pause** — offer preset durations (1/2/3 months → `until`); an `until`
|
|
92
|
+
pause auto-resumes server-side, no customer action needed. Indefinite
|
|
93
|
+
pause (no body) needs an explicit Resume.
|
|
94
|
+
- **Reactivate** — render on canceled contracts; default schedule is
|
|
95
|
+
now + interval and it NEVER charges immediately — say so in the copy.
|
|
96
|
+
- **Swap / frequency** — variant options come from the product's variants
|
|
97
|
+
(same product only); frequency options are the product's other selling
|
|
98
|
+
plans (`GET /api/store/products/:id/selling-plans`). Both re-price
|
|
99
|
+
server-side through the one price engine — display the returned
|
|
100
|
+
`unit_price`, never compute prices client-side.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/cancel" \
|
|
104
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
|
|
105
|
+
test "$STATUS" = 401
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Payment-method update (dunning recovery)
|
|
109
|
+
|
|
110
|
+
Two steps, card contracts only:
|
|
111
|
+
|
|
112
|
+
1. `POST /:id/payment-method/session` → `{ session: { setup_intent_id,
|
|
113
|
+
client_secret, publishable_key } }`.
|
|
114
|
+
2. Confirm client-side with Stripe.js — card fields never touch Cartbase:
|
|
115
|
+
|
|
116
|
+
```jsonc
|
|
117
|
+
// stripe = Stripe(session.publishable_key)
|
|
118
|
+
// elements = stripe.elements({ clientSecret: session.client_secret })
|
|
119
|
+
// mount PaymentElement, then:
|
|
120
|
+
// await stripe.confirmSetup({ elements, redirect: "if_required" })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
3. `POST /:id/payment-method` with `{ setup_intent_id }` → verified +
|
|
124
|
+
stamped; the response is the fresh detail. The next renewal charge (the
|
|
125
|
+
automatic retry ladder or the merchant's "Retry now") uses the new card.
|
|
126
|
+
|
|
127
|
+
Entry points to build: the payment-failed email links here; the detail view
|
|
128
|
+
surfaces it on `failed` cycles; the account shell may badge past-due
|
|
129
|
+
subscriptions.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/payment-method/session" \
|
|
133
|
+
-H "x-client-id: $CLIENT_ID" -d '')
|
|
134
|
+
test "$STATUS" = 401
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Checkout + confirmation touchpoints
|
|
138
|
+
|
|
139
|
+
- **Consent line at checkout**: subscription carts save the card for future
|
|
140
|
+
charges (`setup_future_usage: off_session`) — the checkout MUST show the
|
|
141
|
+
mandate text next to the pay button. [checkout.md](checkout.md) documents
|
|
142
|
+
the duty; the component ships with the portal family.
|
|
143
|
+
- **Order confirmation**: a completed subscription checkout returns
|
|
144
|
+
contracts (cycle 1 = that order) — show "subscription started, next
|
|
145
|
+
charge on <date>" from the order's subscription metadata.
|
|
146
|
+
- **PDP purchase options**: `GET /api/store/products/:id/selling-plans`
|
|
147
|
+
(see [products.md](products.md)) — the plan chosen at PDP rides the cart
|
|
148
|
+
line as `selling_plan_id`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import type { NextConfig } from "next"
|
|
3
|
+
|
|
4
|
+
const nextConfig: NextConfig = {
|
|
5
|
+
// Monorepo: pin the workspace root (Next otherwise infers it from a
|
|
6
|
+
// stray lockfile OUTSIDE the repo and mis-roots the dev module graph).
|
|
7
|
+
outputFileTracingRoot: path.join(__dirname, "../.."),
|
|
8
|
+
// The smoke driver browses via 127.0.0.1; allow dev-resource access.
|
|
9
|
+
allowedDevOrigins: ["127.0.0.1"],
|
|
10
|
+
// @cartbase/storefront is source-shipped TypeScript (workspace package) —
|
|
11
|
+
// the app's Next build transpiles it.
|
|
12
|
+
transpilePackages: ["@cartbase/storefront"],
|
|
13
|
+
images: {
|
|
14
|
+
// Reference app: seeded product images live on arbitrary demo hosts.
|
|
15
|
+
// A real store should allowlist its media domain via remotePatterns.
|
|
16
|
+
unoptimized: true,
|
|
17
|
+
},
|
|
18
|
+
// The Cartbase store API ships NO CORS headers — browser-side SDK calls
|
|
19
|
+
// must be same-origin. Proxy them through the app origin (the browser
|
|
20
|
+
// client's baseUrl is window.location.origin); server-side SDK calls go
|
|
21
|
+
// straight to NEXT_PUBLIC_CARTBASE_URL and are unaffected.
|
|
22
|
+
async rewrites() {
|
|
23
|
+
const barterUrl = process.env.NEXT_PUBLIC_CARTBASE_URL
|
|
24
|
+
if (!barterUrl) return []
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
source: "/api/store/:path*",
|
|
28
|
+
destination: `${barterUrl}/api/store/:path*`,
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default nextConfig
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cartbase-storefront",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Reference storefront built from docs/storefront/BUILD-A-STOREFRONT.md alone — the adoption proof for @cartbase/storefront.",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev --webpack -p 4778",
|
|
8
|
+
"typecheck": "tsc --noEmit"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@cartbase/storefront": "^0.2.0",
|
|
12
|
+
"next": "16.2.4",
|
|
13
|
+
"react": "19.2.4",
|
|
14
|
+
"react-dom": "19.2.4"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^20",
|
|
18
|
+
"@types/react": "^19",
|
|
19
|
+
"@types/react-dom": "^19",
|
|
20
|
+
"autoprefixer": "^10.4.20",
|
|
21
|
+
"postcss": "^8.4.49",
|
|
22
|
+
"tailwindcss": "^3.4.17",
|
|
23
|
+
"typescript": "^5"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adoption-proof smoke: drives the REAL example-storefront UI from home to
|
|
3
|
+
* a completed pp_manual checkout against the barter backend.
|
|
4
|
+
*
|
|
5
|
+
* Prereqs: backend on :4777, example app on :4778 (see examples/storefront
|
|
6
|
+
* package.json). Run: `node examples/storefront/smoke.mjs` from the repo
|
|
7
|
+
* root (Playwright is a root devDependency). Exits 0 only when the
|
|
8
|
+
* confirmation page shows an order number and totals.
|
|
9
|
+
*/
|
|
10
|
+
import { chromium } from "@playwright/test"
|
|
11
|
+
|
|
12
|
+
const BASE = process.env.SMOKE_BASE_URL ?? "http://127.0.0.1:4778"
|
|
13
|
+
const trail = []
|
|
14
|
+
const step = (msg) => {
|
|
15
|
+
trail.push(msg)
|
|
16
|
+
console.log(`[smoke] ${msg}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const browser = await chromium.launch()
|
|
20
|
+
const page = await browser.newPage()
|
|
21
|
+
page.setDefaultTimeout(30_000)
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// ── Home ────────────────────────────────────────────────────────────
|
|
25
|
+
await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" })
|
|
26
|
+
step("home loaded")
|
|
27
|
+
|
|
28
|
+
// Consent banner (builtin modal is the server default) — accept.
|
|
29
|
+
const acceptButton = page.getByRole("button", { name: "Accept", exact: true })
|
|
30
|
+
try {
|
|
31
|
+
await acceptButton.waitFor({ state: "visible", timeout: 10_000 })
|
|
32
|
+
await acceptButton.click()
|
|
33
|
+
step("consent banner accepted")
|
|
34
|
+
} catch {
|
|
35
|
+
step("no consent banner rendered (disabled or external mode)")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await page.locator('a[href^="/products/"]').first().waitFor()
|
|
39
|
+
step("home renders product cards")
|
|
40
|
+
|
|
41
|
+
// ── PDP (seeded handle) ─────────────────────────────────────────────
|
|
42
|
+
await page.goto(`${BASE}/products/linen-shirt`, {
|
|
43
|
+
waitUntil: "domcontentloaded",
|
|
44
|
+
})
|
|
45
|
+
await page.getByTestId("product-container").waitFor()
|
|
46
|
+
step("PDP linen-shirt rendered")
|
|
47
|
+
|
|
48
|
+
// Pick the REAL Size option's "M" (the shared dev tenant accretes junk
|
|
49
|
+
// options; variant match needs exactly the Size choice). Dev-mode
|
|
50
|
+
// hydration can lag the SSR paint — retry until the selection sticks.
|
|
51
|
+
const sizeRow = page
|
|
52
|
+
.locator('div.flex.flex-col:has(> span:text-is("Select Size"))')
|
|
53
|
+
.first()
|
|
54
|
+
const mButton = sizeRow.getByRole("button", { name: "M", exact: true })
|
|
55
|
+
let selected = false
|
|
56
|
+
for (let i = 0; i < 30 && !selected; i++) {
|
|
57
|
+
await mButton.click()
|
|
58
|
+
await page.waitForTimeout(700)
|
|
59
|
+
selected = ((await mButton.getAttribute("class")) ?? "").includes(
|
|
60
|
+
"border-primary"
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
if (!selected) throw new Error("Size M never became selected")
|
|
64
|
+
step("selected Size M")
|
|
65
|
+
|
|
66
|
+
const addButton = page.getByTestId("add-product-button").first()
|
|
67
|
+
await addButton.click()
|
|
68
|
+
step("clicked Add to cart")
|
|
69
|
+
|
|
70
|
+
// ── Cart drawer → checkout ──────────────────────────────────────────
|
|
71
|
+
// The drawer auto-opens only on a rise from a nonzero base (first add
|
|
72
|
+
// never auto-opens — components.md); open it via the header cart
|
|
73
|
+
// button once the badge confirms the server-side add landed.
|
|
74
|
+
await page
|
|
75
|
+
.getByTestId("nav-cart-link")
|
|
76
|
+
.locator("span")
|
|
77
|
+
.filter({ hasText: /^\d+$/ })
|
|
78
|
+
.waitFor({ timeout: 20_000 })
|
|
79
|
+
step("header cart badge shows the added item")
|
|
80
|
+
await page.getByTestId("nav-cart-link").click()
|
|
81
|
+
const checkoutLink = page.getByRole("link", { name: "Checkout" })
|
|
82
|
+
await checkoutLink.click()
|
|
83
|
+
step("cart drawer opened via header cart button")
|
|
84
|
+
await page.waitForURL("**/checkout")
|
|
85
|
+
step("navigated to /checkout")
|
|
86
|
+
|
|
87
|
+
// ── Address form ────────────────────────────────────────────────────
|
|
88
|
+
const fill = async (name, value) => {
|
|
89
|
+
await page.locator(`[name="${name}"]`).fill(value)
|
|
90
|
+
}
|
|
91
|
+
await fill("email", "smoke-adoption@example.test")
|
|
92
|
+
await fill("shipping_address.first_name", "Ivan")
|
|
93
|
+
await fill("shipping_address.last_name", "Petrov")
|
|
94
|
+
await fill("shipping_address.address_1", "ul. Vitosha 15")
|
|
95
|
+
await fill("shipping_address.postal_code", "1000")
|
|
96
|
+
await fill("shipping_address.city", "Sofia")
|
|
97
|
+
await fill("shipping_address.phone", "+359888123456")
|
|
98
|
+
await page.locator('[name="shipping_address.phone"]').blur()
|
|
99
|
+
step("address form filled (BG address, EUR cart)")
|
|
100
|
+
|
|
101
|
+
// ── Shipping method ─────────────────────────────────────────────────
|
|
102
|
+
await page.getByText("Flat Rate (Bulgaria)", { exact: false }).first().click()
|
|
103
|
+
step('selected shipping "Flat Rate (Bulgaria)"')
|
|
104
|
+
|
|
105
|
+
// ── Payment: manual/offline tab (no Stripe env) ─────────────────────
|
|
106
|
+
const codTab = page.getByRole("button", { name: "Cash on delivery" })
|
|
107
|
+
await codTab.waitFor({ state: "visible" })
|
|
108
|
+
await codTab.click()
|
|
109
|
+
step("selected manual payment (pp_manual offline tab)")
|
|
110
|
+
|
|
111
|
+
// ── Place order ─────────────────────────────────────────────────────
|
|
112
|
+
const submit = page.getByTestId("submit-order-button")
|
|
113
|
+
await submit.waitFor({ state: "visible" })
|
|
114
|
+
// The Buy button enables once the debounced address autosave lands.
|
|
115
|
+
await page.waitForFunction(
|
|
116
|
+
() =>
|
|
117
|
+
!document.querySelector('[data-testid="submit-order-button"]')?.disabled,
|
|
118
|
+
undefined,
|
|
119
|
+
{ timeout: 20_000 }
|
|
120
|
+
)
|
|
121
|
+
await submit.click()
|
|
122
|
+
step("clicked Place order")
|
|
123
|
+
|
|
124
|
+
// ── Confirmation ────────────────────────────────────────────────────
|
|
125
|
+
await page.waitForURL("**/order/**/confirmed", { timeout: 60_000 })
|
|
126
|
+
step(`confirmation URL: ${new URL(page.url()).pathname}`)
|
|
127
|
+
|
|
128
|
+
const orderNumberText = await page
|
|
129
|
+
.getByText(/#\d+/)
|
|
130
|
+
.first()
|
|
131
|
+
.innerText({ timeout: 30_000 })
|
|
132
|
+
const displayNumber = orderNumberText.match(/#(\d+)/)?.[1]
|
|
133
|
+
if (!displayNumber) throw new Error("no order display number rendered")
|
|
134
|
+
step(`order number rendered: #${displayNumber}`)
|
|
135
|
+
|
|
136
|
+
// Totals: the order family renders a Total row with a EUR amount.
|
|
137
|
+
const totalRow = page
|
|
138
|
+
.locator("div,li")
|
|
139
|
+
.filter({ hasText: /^Total/ })
|
|
140
|
+
.filter({ hasText: "€" })
|
|
141
|
+
.first()
|
|
142
|
+
const totalText = (await totalRow.innerText()).replace(/\s+/g, " ").trim()
|
|
143
|
+
if (!/€\s?\d/.test(totalText)) throw new Error("no EUR total rendered")
|
|
144
|
+
step(`totals rendered: ${totalText}`)
|
|
145
|
+
|
|
146
|
+
console.log("\n[smoke] PASS — completed checkout, order #" + displayNumber)
|
|
147
|
+
await browser.close()
|
|
148
|
+
process.exit(0)
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("\n[smoke] FAIL after steps:\n - " + trail.join("\n - "))
|
|
151
|
+
console.error(err)
|
|
152
|
+
try {
|
|
153
|
+
await page.screenshot({ path: "examples/storefront/smoke-failure.png" })
|
|
154
|
+
console.error("[smoke] screenshot: examples/storefront/smoke-failure.png")
|
|
155
|
+
} catch {}
|
|
156
|
+
await browser.close()
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation"
|
|
4
|
+
import type { Cart } from "@cartbase/storefront/api/carts"
|
|
5
|
+
import type { StoreShippingOption } from "@cartbase/storefront/api/checkout"
|
|
6
|
+
import type { StorePaymentProvider } from "@cartbase/storefront/api/checkout"
|
|
7
|
+
import type { PublicCodConfig } from "@cartbase/storefront/api/integrations"
|
|
8
|
+
import { CheckoutProvider } from "@cartbase/storefront/checkout/context"
|
|
9
|
+
import { CheckoutClient } from "@cartbase/storefront/checkout/checkout-client"
|
|
10
|
+
import { browserClient } from "@/lib/browser-client"
|
|
11
|
+
|
|
12
|
+
/** sessionStorage key the confirmation page reads (guests have no order
|
|
13
|
+
* read endpoint — the completeCart() response is the only order handle,
|
|
14
|
+
* orders.md). */
|
|
15
|
+
export const LAST_ORDER_STORAGE_KEY = "barter:last-order"
|
|
16
|
+
|
|
17
|
+
export function CheckoutPageClient({
|
|
18
|
+
cart,
|
|
19
|
+
shippingOptions,
|
|
20
|
+
paymentProviders,
|
|
21
|
+
codConfig,
|
|
22
|
+
}: {
|
|
23
|
+
cart: Cart
|
|
24
|
+
shippingOptions: StoreShippingOption[]
|
|
25
|
+
paymentProviders: StorePaymentProvider[]
|
|
26
|
+
codConfig: PublicCodConfig | null
|
|
27
|
+
}) {
|
|
28
|
+
const router = useRouter()
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<CheckoutProvider orderConfirmedPath="/order/{id}/confirmed">
|
|
32
|
+
<CheckoutClient
|
|
33
|
+
client={browserClient}
|
|
34
|
+
cart={cart}
|
|
35
|
+
customer={null}
|
|
36
|
+
availableShippingMethods={shippingOptions}
|
|
37
|
+
availablePaymentMethods={paymentProviders}
|
|
38
|
+
countryCode="bg"
|
|
39
|
+
countries={[{ iso_2: "bg", display_name: "Bulgaria" }]}
|
|
40
|
+
// Per-store rule (the documented paymentMethodFilter seam): this
|
|
41
|
+
// reference store checks out offline via pp_manual only — without
|
|
42
|
+
// the filter the hook prefers a tenant-enabled pp_cod for the
|
|
43
|
+
// offline tab.
|
|
44
|
+
paymentMethodFilter={(methods) =>
|
|
45
|
+
methods?.filter((m) => m.id === "pp_manual") ?? null
|
|
46
|
+
}
|
|
47
|
+
codConfig={codConfig}
|
|
48
|
+
onOrderPlaced={(order) => {
|
|
49
|
+
// Guest order handle = the completeCart() response (orders.md).
|
|
50
|
+
// Stash it (+ the decorated cart lines for the items list) for
|
|
51
|
+
// the confirmation page, then navigate.
|
|
52
|
+
try {
|
|
53
|
+
sessionStorage.setItem(
|
|
54
|
+
LAST_ORDER_STORAGE_KEY,
|
|
55
|
+
JSON.stringify({ order, cartItems: cart.items ?? [] })
|
|
56
|
+
)
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort — the confirmation page has a fallback state
|
|
59
|
+
}
|
|
60
|
+
router.push(`/order/${order.id}/confirmed`)
|
|
61
|
+
}}
|
|
62
|
+
onCartChange={() => router.refresh()}
|
|
63
|
+
/>
|
|
64
|
+
</CheckoutProvider>
|
|
65
|
+
)
|
|
66
|
+
}
|