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,81 @@
|
|
|
1
|
+
# Consent — Consent Mode v2 banner config
|
|
2
|
+
|
|
3
|
+
The store's CMP configuration for the built-in Consent Mode v2 banner
|
|
4
|
+
(consent-management card). Defaults are **always applied server-side**, so
|
|
5
|
+
the payload is complete and renderable even for an unconfigured store
|
|
6
|
+
(compliant built-in modal, BG + EN copy shipped).
|
|
7
|
+
|
|
8
|
+
## Storefront wiring (the trap that matters)
|
|
9
|
+
|
|
10
|
+
- Mount `<ConsentInit>` as the **first child of `<body>`** — it sets the
|
|
11
|
+
synchronous Consent Mode v2 DEFAULT and must **never wait on this fetch**
|
|
12
|
+
(async default = first-hit consent race). Resolve this config server-side
|
|
13
|
+
(RSC) and inline it into the document.
|
|
14
|
+
- Render the built-in banner only when `enabled && mode === "builtin"`.
|
|
15
|
+
- `mode: "external"` = the merchant's CMP owns the UI and must write the
|
|
16
|
+
same `_1c_consent` cookie (or call `setConsent()`) — all Cartbase-side tag
|
|
17
|
+
gating works off that one seam.
|
|
18
|
+
- Choices persist 12 months in the cookie. Rybbit (platform analytics)
|
|
19
|
+
stays outside consent by design.
|
|
20
|
+
- Pair with [integrations.md](integrations.md): `tracking.consent_required`
|
|
21
|
+
mirrors `enabled` here — tags mount only through the consent gate when
|
|
22
|
+
true.
|
|
23
|
+
|
|
24
|
+
## GET /api/store/consent — the CMP config
|
|
25
|
+
|
|
26
|
+
- **Purpose**: everything the banner needs to render, per locale.
|
|
27
|
+
- **Auth**: anon (`x-client-id`).
|
|
28
|
+
- **Request**: no params.
|
|
29
|
+
- **Response** — the EXACT allowlist (nothing else will ever appear here):
|
|
30
|
+
|
|
31
|
+
```jsonc
|
|
32
|
+
{
|
|
33
|
+
"consent": {
|
|
34
|
+
"enabled": true,
|
|
35
|
+
"mode": "builtin", // "builtin" | "external"
|
|
36
|
+
"layout": "modal", // "modal" (blocking) | "banner-bottom" (non-blocking)
|
|
37
|
+
"privacy_href": "/cookies",
|
|
38
|
+
"reject_on_first_layer": false,
|
|
39
|
+
"copy": {
|
|
40
|
+
"bg": {
|
|
41
|
+
"title": "Преди да продължиш",
|
|
42
|
+
"body": "…",
|
|
43
|
+
"privacy_link_label": "…",
|
|
44
|
+
"accept_label": "…", "settings_label": "…", "reject_label": "…",
|
|
45
|
+
"settings_title": "…",
|
|
46
|
+
"accept_all_label": "…", "save_label": "…", "reject_all_label": "…",
|
|
47
|
+
"necessary_label": "…", "necessary_description": "…",
|
|
48
|
+
"analytics_label": "…", "analytics_description": "…",
|
|
49
|
+
"ads_label": "…", "ads_description": "…"
|
|
50
|
+
},
|
|
51
|
+
"en": { /* same 16 keys — every field always present per locale */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **Errors**: `400 missing_client_id` only — the payload itself always
|
|
58
|
+
succeeds (a corrupt/missing stored config degrades to defaults, never to
|
|
59
|
+
a broken banner).
|
|
60
|
+
- **SDK**: `consent.getConsent(client)`
|
|
61
|
+
- **Components**: `<ConsentInit>` + `<ConsentBanner copy={copy[locale]}
|
|
62
|
+
layout privacyHref rejectOnFirstLayer>` (consent family; reference impl
|
|
63
|
+
`src/components/storefront/consent/`).
|
|
64
|
+
- **Settings**: admin → Settings → Consent (enabled/mode/layout/
|
|
65
|
+
privacy_href/copy per locale).
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Complete, renderable config — even on an unconfigured store the defaults
|
|
69
|
+
# make every documented key present.
|
|
70
|
+
BODY=$(curl -sf "$BASE/api/store/consent" -H "x-client-id: $CLIENT_ID")
|
|
71
|
+
echo "$BODY" | grep -q '"consent"'
|
|
72
|
+
echo "$BODY" | grep -q '"mode"'
|
|
73
|
+
echo "$BODY" | grep -q '"layout"'
|
|
74
|
+
echo "$BODY" | grep -q '"privacy_href"'
|
|
75
|
+
echo "$BODY" | grep -q '"reject_on_first_layer"'
|
|
76
|
+
echo "$BODY" | grep -q '"analytics_label"'
|
|
77
|
+
echo "$BODY" | grep -q '"ads_description"'
|
|
78
|
+
# Missing tenant header → the standard 400.
|
|
79
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/consent")
|
|
80
|
+
test "$STATUS" = 400
|
|
81
|
+
```
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Content — pages + blogs
|
|
2
|
+
|
|
3
|
+
Shopify Storefront `Page` / `Article` shapes **verbatim** so storefront code
|
|
4
|
+
ports 1:1. **PUBLISHED only** — drafts and deleted rows 404 (lib status
|
|
5
|
+
filter AND the anon RLS policy). Stored HTML is server-sanitized on write —
|
|
6
|
+
safe to render raw (`dangerouslySetInnerHTML`).
|
|
7
|
+
|
|
8
|
+
Path convention (`src/lib/content/paths.ts`, shared with menus + redirects):
|
|
9
|
+
`/pages/<handle>`, `/blogs/<handle>`, `/blogs/<blog>/<post>`. Handle renames
|
|
10
|
+
create URL redirects automatically — wire [redirects.md](redirects.md) into
|
|
11
|
+
your 404 handler and old content URLs keep working.
|
|
12
|
+
|
|
13
|
+
Policy pages every store carries (`privacy-policy`, `terms-of-service`,
|
|
14
|
+
`refund-policy`, `shipping-policy`) are seeded as **drafts** — they 404 here
|
|
15
|
+
until the merchant publishes them.
|
|
16
|
+
|
|
17
|
+
> Content is admin-authored; the shared dev tenant seeds no published
|
|
18
|
+
> pages/blogs, so the executable blocks prove the 404 contract and the
|
|
19
|
+
> happy-path shapes are pinned by `tests/store/content-store.test.ts` and
|
|
20
|
+
> `tests/contract/sdk-content.contract.test.ts` (which create fixtures with
|
|
21
|
+
> admin auth).
|
|
22
|
+
|
|
23
|
+
## GET /api/store/pages/:handle — one page
|
|
24
|
+
|
|
25
|
+
- **Purpose**: render a static page (`/pages/<handle>`).
|
|
26
|
+
- **Auth**: anon (`x-client-id`).
|
|
27
|
+
- **Request**: path handle only.
|
|
28
|
+
- **Response**:
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
{
|
|
32
|
+
"page": {
|
|
33
|
+
"id": "cpage_<hex>",
|
|
34
|
+
"handle": "about-us",
|
|
35
|
+
"title": "About us",
|
|
36
|
+
"body": "<h2>…</h2>", // sanitized HTML — render raw
|
|
37
|
+
"bodySummary": "…", // stripped, ~160-char word-boundary cut
|
|
38
|
+
"seo": {
|
|
39
|
+
"title": "About us", // fallback: title
|
|
40
|
+
"description": "…" // fallback: bodySummary
|
|
41
|
+
},
|
|
42
|
+
"publishedAt": "ISO-8601",
|
|
43
|
+
"createdAt": "ISO-8601",
|
|
44
|
+
"updatedAt": "ISO-8601"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **Errors**: `404 not_found` — unknown handle, draft, or deleted.
|
|
50
|
+
- **SDK**: `content.getPage(client, handle)`
|
|
51
|
+
- **Components**: static page template; footer policy links.
|
|
52
|
+
- **Settings**: page status (draft/published) in admin → Content → Pages.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Unknown handle → the clean 404 contract (drafts behave identically).
|
|
56
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
57
|
+
"$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID")
|
|
58
|
+
test "$STATUS" = 404
|
|
59
|
+
curl -s "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID" \
|
|
60
|
+
| grep -q '"code":"not_found"'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## GET /api/store/blogs/:handle/posts — blog listing
|
|
64
|
+
|
|
65
|
+
- **Purpose**: the blog index (`/blogs/<handle>`), paginated.
|
|
66
|
+
- **Auth**: anon (`x-client-id`).
|
|
67
|
+
- **Request**: query `{limit? (≤100, default 20), offset?}`. Published
|
|
68
|
+
only, `published_at` desc.
|
|
69
|
+
- **Response**:
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
{
|
|
73
|
+
"blog": { "handle": "news", "title": "News" },
|
|
74
|
+
"articles": [ /* Article[] — the exact shape below */ ],
|
|
75
|
+
"count": 12,
|
|
76
|
+
"offset": 0,
|
|
77
|
+
"limit": 20
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- **Errors**: `404 not_found` (unknown blog handle) ·
|
|
82
|
+
`400 validation_failed` (limit/offset out of range).
|
|
83
|
+
- **SDK**: `content.listBlogPosts(client, blogHandle, query?)`
|
|
84
|
+
- **Components**: blog index template.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
88
|
+
"$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts" -H "x-client-id: $CLIENT_ID")
|
|
89
|
+
test "$STATUS" = 404
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## GET /api/store/blogs/:handle/posts/:postHandle — one article
|
|
93
|
+
|
|
94
|
+
- **Purpose**: render one post (`/blogs/<blog>/<post>`).
|
|
95
|
+
- **Auth**: anon (`x-client-id`).
|
|
96
|
+
- **Response** — Shopify `Article` verbatim:
|
|
97
|
+
|
|
98
|
+
```jsonc
|
|
99
|
+
{
|
|
100
|
+
"article": {
|
|
101
|
+
"id": "bpost_<hex>",
|
|
102
|
+
"handle": "hello-world",
|
|
103
|
+
"title": "Hello world",
|
|
104
|
+
"contentHtml": "<p>…</p>", // sanitized HTML — render raw
|
|
105
|
+
"content": "…", // plain-text version
|
|
106
|
+
"excerpt": "…", // author-provided; falls back to truncated content
|
|
107
|
+
"image": { "url": "https://…", "altText": null }, // null when no hero image
|
|
108
|
+
"tags": ["news"],
|
|
109
|
+
"author": { "name": "Maria" }, // null when unset
|
|
110
|
+
"publishedAt": "ISO-8601",
|
|
111
|
+
"seo": { "title": "…", "description": "…" }, // fallbacks: title / excerpt
|
|
112
|
+
"blog": { "handle": "news", "title": "News" }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- **Errors**: `404 not_found` — unknown blog OR unknown/draft post.
|
|
118
|
+
- **SDK**: `content.getBlogPost(client, blogHandle, postHandle)`
|
|
119
|
+
- **Components**: article template; article cards reuse the listing shape.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
123
|
+
"$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts/doc-no-post-$RUN" \
|
|
124
|
+
-H "x-client-id: $CLIENT_ID")
|
|
125
|
+
test "$STATUS" = 404
|
|
126
|
+
```
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# Customers — profile, addresses, documents
|
|
2
|
+
|
|
3
|
+
The signed-in customer surface. Every endpoint here requires a **customer
|
|
4
|
+
session**: `authorization: Bearer <supabase jwt>` (minted by the
|
|
5
|
+
passwordless flow — see [auth.md](auth.md) — or a client-side supabase
|
|
6
|
+
password sign-in) **plus** the `x-client-id` tenant header the storefront
|
|
7
|
+
always sends. Missing/invalid JWT → `401 {code: "unauthenticated"}` on every
|
|
8
|
+
route below.
|
|
9
|
+
|
|
10
|
+
Lazy registration: `/customers/me` resolves the customer by
|
|
11
|
+
(client_id, JWT email) and **creates the row on first authenticated call**
|
|
12
|
+
(has_account=true, `account_status` per the store's approval policy) — a
|
|
13
|
+
fresh login never 404s on "me".
|
|
14
|
+
|
|
15
|
+
`customer.account_status` (`pending` | `approved`) is on every customer
|
|
16
|
+
payload; when the store's B2B approval policy is on, hide B2B content until
|
|
17
|
+
`approved`.
|
|
18
|
+
|
|
19
|
+
> The executable blocks below prove the **auth boundary** (401 contract) —
|
|
20
|
+
> the docs harness is anonymous, so happy paths are shown as `jsonc` shapes
|
|
21
|
+
> and are pinned by `tests/contract/customers.contract.test.ts` and
|
|
22
|
+
> `tests/store/customer-accounts-*.test.ts` with real sessions.
|
|
23
|
+
|
|
24
|
+
## POST /api/store/customers — guest-to-registered conversion
|
|
25
|
+
|
|
26
|
+
- **Purpose**: bind a password sign-up to a customer row. Flow:
|
|
27
|
+
`supabase.auth.signUp({email, password})` client-side → this call with the
|
|
28
|
+
same email. NOT needed for the passwordless code flow (verify already
|
|
29
|
+
lazy-creates the customer).
|
|
30
|
+
- **Auth**: Bearer JWT (+ `x-client-id`). Body email MUST equal the JWT
|
|
31
|
+
email.
|
|
32
|
+
- **Request**:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
// POST /api/store/customers
|
|
36
|
+
{
|
|
37
|
+
"email": "maria@example.com", // must match the session email
|
|
38
|
+
"first_name": "Maria", // optional
|
|
39
|
+
"last_name": "Petrova", // optional
|
|
40
|
+
"phone": "+359888123456", // optional
|
|
41
|
+
"company_name": "Acme OOD" // optional
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **Response**: `{customer}` — the full customer with `addresses` embedded
|
|
46
|
+
(empty on first create). See the shape under `/customers/me` below.
|
|
47
|
+
- **Errors**: `401 unauthenticated` · `403 email_mismatch` (body email ≠
|
|
48
|
+
session email) · `400 validation_failed`.
|
|
49
|
+
- **SDK**: `customers.createCustomer(client, input)`
|
|
50
|
+
- **Components**: account registration form (account pages family).
|
|
51
|
+
- **Settings**: store approval policy decides `account_status` of a NEW row
|
|
52
|
+
(`pending` when B2B approval is required, else `approved`).
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# No session → the documented 401 contract (the auth boundary, executable).
|
|
56
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/customers" \
|
|
57
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
58
|
+
-d '{"email": "doc-'$RUN'@doc.test"}')
|
|
59
|
+
test "$STATUS" = 401
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## GET /api/store/customers/me — the signed-in customer
|
|
63
|
+
|
|
64
|
+
- **Purpose**: hydrate account state after login / on account pages.
|
|
65
|
+
Lazy-creates the customer row on first call for this (tenant, email).
|
|
66
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
67
|
+
- **Request**: no params.
|
|
68
|
+
- **Response**:
|
|
69
|
+
|
|
70
|
+
```jsonc
|
|
71
|
+
{
|
|
72
|
+
"customer": {
|
|
73
|
+
"id": "uuid",
|
|
74
|
+
"client_id": "uuid",
|
|
75
|
+
"email": "maria@example.com",
|
|
76
|
+
"first_name": "Maria",
|
|
77
|
+
"last_name": "Petrova",
|
|
78
|
+
"phone": "+359888123456",
|
|
79
|
+
"company_name": null,
|
|
80
|
+
"company_eik": null, // Bulgarian company id (ЕИК) — invoice checkout
|
|
81
|
+
"vat_number": null,
|
|
82
|
+
"has_account": true,
|
|
83
|
+
"account_status": "approved", // "pending" | "approved" — B2B gating
|
|
84
|
+
"tags": [], // admin-only labels; read-only here
|
|
85
|
+
"metadata": null,
|
|
86
|
+
"created_by": null,
|
|
87
|
+
"created_at": "ISO-8601",
|
|
88
|
+
"updated_at": "ISO-8601",
|
|
89
|
+
"deleted_at": null,
|
|
90
|
+
"addresses": [ /* CustomerAddress[], created_at asc — shape below */ ]
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- **Errors**: `401 unauthenticated` · `400 missing_client_id`.
|
|
96
|
+
- **SDK**: `customers.getMe(client)`
|
|
97
|
+
- **Components**: account dashboard / header account state.
|
|
98
|
+
- **Settings**: approval policy (`account_status` on lazy create).
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/customers/me" \
|
|
102
|
+
-H "x-client-id: $CLIENT_ID")
|
|
103
|
+
test "$STATUS" = 401
|
|
104
|
+
# The error envelope carries the stable code:
|
|
105
|
+
curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
|
|
106
|
+
| grep -q '"code":"unauthenticated"'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## POST /api/store/customers/me — update own profile
|
|
110
|
+
|
|
111
|
+
- **Purpose**: profile edit (names, phone, company/VAT master data).
|
|
112
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
113
|
+
- **Request** (all optional; nullable fields clear with `null`):
|
|
114
|
+
|
|
115
|
+
```jsonc
|
|
116
|
+
{
|
|
117
|
+
"first_name": "Maria",
|
|
118
|
+
"last_name": "Petrova",
|
|
119
|
+
"phone": "+359888123456",
|
|
120
|
+
"company_name": "Acme OOD",
|
|
121
|
+
"company_eik": "123456789", // loose format — foreign B2B customers exist
|
|
122
|
+
"vat_number": "BG123456789",
|
|
123
|
+
"metadata": {}
|
|
124
|
+
// "tags" are admin-only — the server STRIPS them from this body.
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- **Response**: `{customer}` — the refreshed customer (shape above).
|
|
129
|
+
- **Errors**: `401 unauthenticated` · `400 validation_failed`.
|
|
130
|
+
- **SDK**: `customers.updateMe(client, input)`
|
|
131
|
+
- **Components**: account profile form; invoice-details step in checkout.
|
|
132
|
+
- **Settings**: `company_eik`/`vat_number` feed invoice-required checkout
|
|
133
|
+
(order-documents).
|
|
134
|
+
|
|
135
|
+
## GET /api/store/customers/me/addresses — list addresses
|
|
136
|
+
|
|
137
|
+
- **Purpose**: the address book. Returns **every** address (created_at asc)
|
|
138
|
+
— the envelope is nominal: `offset` is always 0 and `limit` equals
|
|
139
|
+
`count`; no query params are read.
|
|
140
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
141
|
+
- **Response**:
|
|
142
|
+
|
|
143
|
+
```jsonc
|
|
144
|
+
{
|
|
145
|
+
"addresses": [
|
|
146
|
+
{
|
|
147
|
+
"id": "uuid",
|
|
148
|
+
"client_id": "uuid",
|
|
149
|
+
"customer_id": "uuid",
|
|
150
|
+
"address_name": "Home",
|
|
151
|
+
"first_name": "Maria",
|
|
152
|
+
"last_name": "Petrova",
|
|
153
|
+
"company": null,
|
|
154
|
+
"address_1": "ul. Ivan Vazov 1",
|
|
155
|
+
"address_2": null,
|
|
156
|
+
"city": "Sofia",
|
|
157
|
+
"country_code": "bg", // stored lower-case
|
|
158
|
+
"province": null,
|
|
159
|
+
"postal_code": "1000",
|
|
160
|
+
"phone": "+359888123456",
|
|
161
|
+
"is_default_billing": false,
|
|
162
|
+
"is_default_shipping": true,
|
|
163
|
+
"metadata": null,
|
|
164
|
+
"created_at": "ISO-8601",
|
|
165
|
+
"updated_at": "ISO-8601",
|
|
166
|
+
"deleted_at": null
|
|
167
|
+
}
|
|
168
|
+
],
|
|
169
|
+
"count": 1,
|
|
170
|
+
"offset": 0,
|
|
171
|
+
"limit": 1
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- **Errors**: `401 unauthenticated`.
|
|
176
|
+
- **SDK**: `customers.listAddresses(client)`
|
|
177
|
+
- **Components**: address book; checkout address picker.
|
|
178
|
+
|
|
179
|
+
## POST /api/store/customers/me/addresses — create address
|
|
180
|
+
|
|
181
|
+
- **Purpose**: add to the address book. `is_default_billing` /
|
|
182
|
+
`is_default_shipping: true` clears the flag on every sibling first (one
|
|
183
|
+
default per kind).
|
|
184
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
185
|
+
- **Request**: all fields optional — same keys as the address shape above
|
|
186
|
+
(minus id/customer_id/timestamps). `country_code` is lower-cased.
|
|
187
|
+
- **Response**: `{customer}` — the **full refreshed customer** (addresses
|
|
188
|
+
embedded), NOT the created address alone; find it in
|
|
189
|
+
`customer.addresses`.
|
|
190
|
+
- **Errors**: `401 unauthenticated` · `400 validation_failed`.
|
|
191
|
+
- **SDK**: `customers.createAddress(client, input)`
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
195
|
+
"$BASE/api/store/customers/me/addresses" \
|
|
196
|
+
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
|
|
197
|
+
-d '{"city": "Sofia"}')
|
|
198
|
+
test "$STATUS" = 401
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## GET /api/store/customers/me/addresses/:id — one address
|
|
202
|
+
|
|
203
|
+
- **Purpose**: read one owned address (edit-form hydrate).
|
|
204
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
205
|
+
- **Response**: `{address}` — ONE address object (the only address
|
|
206
|
+
endpoint that returns `{address}` instead of `{customer}`).
|
|
207
|
+
- **Errors**: `401 unauthenticated` · `404 not_found` — unknown OR another
|
|
208
|
+
customer's address; ownership violations 404, they never 403.
|
|
209
|
+
- **SDK**: `customers.getAddress(client, addressId)`
|
|
210
|
+
|
|
211
|
+
## POST /api/store/customers/me/addresses/:id — update address
|
|
212
|
+
|
|
213
|
+
- **Purpose**: partial update; default flags clear siblings.
|
|
214
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
215
|
+
- **Request**: same keys as create, nullable to clear.
|
|
216
|
+
- **Response**: `{customer}` — full refreshed customer.
|
|
217
|
+
- **Errors**: `401 unauthenticated` · `404 not_found` ·
|
|
218
|
+
`400 validation_failed`.
|
|
219
|
+
- **SDK**: `customers.updateAddress(client, addressId, input)`
|
|
220
|
+
|
|
221
|
+
## DELETE /api/store/customers/me/addresses/:id — delete address
|
|
222
|
+
|
|
223
|
+
- **Purpose**: soft-delete an owned address.
|
|
224
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
225
|
+
- **Response**: `{customer}` — full refreshed customer (address gone).
|
|
226
|
+
- **Errors**: `401 unauthenticated` · `404 not_found`.
|
|
227
|
+
- **SDK**: `customers.deleteAddress(client, addressId)`
|
|
228
|
+
|
|
229
|
+
## GET /api/store/customers/me/documents — my documents (invoices)
|
|
230
|
+
|
|
231
|
+
- **Purpose**: the account "Invoices" page — the customer's issued
|
|
232
|
+
(non-void) order documents, `issued_at` desc. Ownership is INNER-join
|
|
233
|
+
enforced (session customer id AND client_id) — cross-customer reads are
|
|
234
|
+
impossible (adversarial-tested).
|
|
235
|
+
- **Auth**: Bearer JWT (+ `x-client-id`).
|
|
236
|
+
- **Request**: query `{limit? (≤200, default 20), offset?, doc_type?}`.
|
|
237
|
+
- **Response**:
|
|
238
|
+
|
|
239
|
+
```jsonc
|
|
240
|
+
{
|
|
241
|
+
"documents": [
|
|
242
|
+
{
|
|
243
|
+
"id": "uuid",
|
|
244
|
+
"order_id": "order_…",
|
|
245
|
+
"order_display_id": 1042,
|
|
246
|
+
"doc_type": "invoice",
|
|
247
|
+
"number": "0000000042",
|
|
248
|
+
"issued_at": "ISO-8601",
|
|
249
|
+
"pdf_url": "https://…/invoice.pdf",
|
|
250
|
+
"due_date": null,
|
|
251
|
+
"paid_at": "ISO-8601"
|
|
252
|
+
}
|
|
253
|
+
],
|
|
254
|
+
"count": 1,
|
|
255
|
+
"offset": 0,
|
|
256
|
+
"limit": 20
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
- **Errors**: `401 unauthenticated` · `400 validation_failed`.
|
|
261
|
+
- **SDK**: `customers.listMyDocuments(client, query?)`
|
|
262
|
+
- **Components**: account documents/invoices list.
|
|
263
|
+
- **Settings**: order-documents issuing config (doc types, numbering).
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
267
|
+
"$BASE/api/store/customers/me/documents" -H "x-client-id: $CLIENT_ID")
|
|
268
|
+
test "$STATUS" = 401
|
|
269
|
+
```
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Deploy & hosting
|
|
2
|
+
|
|
3
|
+
Cartbase builds and hosts your storefront. You send the app's source files
|
|
4
|
+
— one snapshot of the whole project — and Cartbase turns it into a
|
|
5
|
+
running site: first on a **permanent preview URL**, then, when you press
|
|
6
|
+
**Publish** (or call the publish endpoint), on the store's live domain.
|
|
7
|
+
You never touch build servers, DNS, or hosting configuration; the platform
|
|
8
|
+
provisions all of it on your first deploy.
|
|
9
|
+
|
|
10
|
+
Two URLs exist per store, both created automatically:
|
|
11
|
+
|
|
12
|
+
| Surface | URL | What serves there |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Preview | `https://preview--{store-slug}.cartbase.net` | The newest deploy, always — the link never changes |
|
|
15
|
+
| Live | `https://{store-slug}.cartbase.net` | The last **published** deploy, and nothing else |
|
|
16
|
+
|
|
17
|
+
A failed build never replaces what's live or what's on preview — the
|
|
18
|
+
previous successful deploy keeps serving. Custom domains for the live site
|
|
19
|
+
attach in the admin (Settings → Domains) and don't change anything on this
|
|
20
|
+
page.
|
|
21
|
+
|
|
22
|
+
**These are Admin API endpoints** — they operate the store, so they
|
|
23
|
+
authenticate with an admin session (sign-in cookie), not the storefront
|
|
24
|
+
headers the rest of this corpus uses. In practice you'll deploy through
|
|
25
|
+
the admin UI (**Storefront** in the sidebar), the Cartbase CLI, or an
|
|
26
|
+
agent connection — all three are wrappers over exactly these calls.
|
|
27
|
+
Users who belong to several stores name the target store with an
|
|
28
|
+
`x-cartbase-store: <client-id>` header.
|
|
29
|
+
|
|
30
|
+
## What a deploy is
|
|
31
|
+
|
|
32
|
+
A deploy is a **full snapshot**: the complete file tree of your storefront
|
|
33
|
+
app, uploaded in one request. Snapshot semantics cut both ways — a file
|
|
34
|
+
you stop sending is removed from the deployed site. There is no
|
|
35
|
+
partial-update mode; your working directory is the truth, every time.
|
|
36
|
+
|
|
37
|
+
Bundle rules (enforced server-side; a violation fails the whole deploy
|
|
38
|
+
with a readable `400` before anything is built):
|
|
39
|
+
|
|
40
|
+
- ≤ 1500 files, ≤ 3 MB per file, ≤ 25 MB total (base64-decoded sizes)
|
|
41
|
+
- `package.json` must exist at the bundle root — the app must be a
|
|
42
|
+
buildable Next.js project (`next build`)
|
|
43
|
+
- File content travels base64-encoded (`content_base64`), so binary
|
|
44
|
+
assets — images, fonts — work
|
|
45
|
+
- Never include: `node_modules/` (dependencies install at build time),
|
|
46
|
+
`.next/` (build output is generated), `.git/`, any `.env*` file
|
|
47
|
+
- Not allowed in storefront bundles: `middleware.*`, `app/api/` or
|
|
48
|
+
`pages/api/` routes, `vercel.json` — a storefront is pages plus the
|
|
49
|
+
`@cartbase/storefront` package; server seams and hosting config are
|
|
50
|
+
platform territory. The CORS proxy from the runbook is a
|
|
51
|
+
`next.config` rewrite, which is fine.
|
|
52
|
+
|
|
53
|
+
Error codes you can hit: `empty_bundle`, `bundle_too_large`,
|
|
54
|
+
`invalid_bundle_file`, `invalid_path`, `blocked_path`, `duplicate_path`,
|
|
55
|
+
`invalid_encoding`, `file_too_large`, `missing_package_json`.
|
|
56
|
+
|
|
57
|
+
## Environment — provided, not configured
|
|
58
|
+
|
|
59
|
+
Hosted storefronts receive the three runbook inputs automatically at
|
|
60
|
+
build time; do **not** put them in the bundle (`.env*` files are blocked
|
|
61
|
+
anyway):
|
|
62
|
+
|
|
63
|
+
| Variable | Value |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `NEXT_PUBLIC_CARTBASE_URL` | The store's API origin |
|
|
66
|
+
| `NEXT_PUBLIC_CARTBASE_CLIENT_ID` | The store's client id |
|
|
67
|
+
| `NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY` | The store's publishable key, when one exists |
|
|
68
|
+
|
|
69
|
+
Only these public values ever reach a storefront build — secret keys are
|
|
70
|
+
never injected, so code that expects one is a design error.
|
|
71
|
+
|
|
72
|
+
## Deploy to preview
|
|
73
|
+
|
|
74
|
+
`POST /api/admin/storefront/deploys` — the one ingestion door. The first
|
|
75
|
+
call on a store also provisions its hosting (takes a few extra seconds);
|
|
76
|
+
every later call is just a deploy.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# doc-noexec — admin-session auth; run from an authenticated context.
|
|
80
|
+
curl -s -X POST "$BASE/api/admin/storefront/deploys" \
|
|
81
|
+
-H "content-type: application/json" \
|
|
82
|
+
-d '{
|
|
83
|
+
"files": [
|
|
84
|
+
{ "path": "package.json", "content_base64": "<base64>" },
|
|
85
|
+
{ "path": "app/layout.js", "content_base64": "<base64>" },
|
|
86
|
+
{ "path": "app/page.js", "content_base64": "<base64>" }
|
|
87
|
+
],
|
|
88
|
+
"message": "homepage copy update"
|
|
89
|
+
}'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Response `201`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"deploy": {
|
|
97
|
+
"id": "sfd_01J…",
|
|
98
|
+
"target": "preview",
|
|
99
|
+
"status": "building",
|
|
100
|
+
"url": "https://preview--my-store.cartbase.net",
|
|
101
|
+
"message": "homepage copy update",
|
|
102
|
+
"file_count": 3,
|
|
103
|
+
"created_at": "2026-08-03T22:41:00.000Z"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`message` (≤ 500 chars) is your label in the deploy history — write what
|
|
109
|
+
changed. `source` (`api` | `cli` | `mcp` | `admin` | `agent`) tags where
|
|
110
|
+
the deploy came from; omit it unless you're building tooling.
|
|
111
|
+
|
|
112
|
+
Statuses move `queued → building → ready` (or `failed` / `canceled`).
|
|
113
|
+
Poll the overview endpoint until the deploy you created is `ready`, then
|
|
114
|
+
open the preview URL. Builds of a small app land in roughly a minute;
|
|
115
|
+
`failed` rows carry a readable `error_message`.
|
|
116
|
+
|
|
117
|
+
## Overview — hosting status + deploy history
|
|
118
|
+
|
|
119
|
+
`GET /api/admin/storefront` returns the hosting record and the deploy
|
|
120
|
+
ledger, newest first:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# doc-noexec — admin-session auth.
|
|
124
|
+
curl -s "$BASE/api/admin/storefront"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"storefront": {
|
|
130
|
+
"id": "sfp_01J…",
|
|
131
|
+
"status": "ready",
|
|
132
|
+
"production_url": "https://my-store.cartbase.net",
|
|
133
|
+
"preview_url": "https://preview--my-store.cartbase.net",
|
|
134
|
+
"last_error": null,
|
|
135
|
+
"created_at": "…"
|
|
136
|
+
},
|
|
137
|
+
"deploys": [
|
|
138
|
+
{
|
|
139
|
+
"id": "sfd_01J…",
|
|
140
|
+
"target": "preview",
|
|
141
|
+
"status": "ready",
|
|
142
|
+
"message": "homepage copy update",
|
|
143
|
+
"url": "https://preview--my-store.cartbase.net",
|
|
144
|
+
"source": "cli",
|
|
145
|
+
"file_count": 3,
|
|
146
|
+
"total_bytes": 1930,
|
|
147
|
+
"error_message": null,
|
|
148
|
+
"promoted_deploy_id": null,
|
|
149
|
+
"created_at": "…",
|
|
150
|
+
"updated_at": "…"
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`storefront` is `null` until the store's first deploy. `status:
|
|
157
|
+
"provisioning"` / `"failed"` (with `last_error`) describe hosting setup,
|
|
158
|
+
not builds; a failed provisioning resumes automatically on the next
|
|
159
|
+
deploy attempt.
|
|
160
|
+
|
|
161
|
+
## Publish — and rollback
|
|
162
|
+
|
|
163
|
+
`POST /api/admin/storefront/publish` promotes a **ready preview deploy**
|
|
164
|
+
to the live domain. This is the only path to production — nothing
|
|
165
|
+
deploys straight to live, no matter who's asking.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
# doc-noexec — admin-session auth.
|
|
169
|
+
# Bare POST publishes the newest ready preview:
|
|
170
|
+
curl -s -X POST "$BASE/api/admin/storefront/publish"
|
|
171
|
+
|
|
172
|
+
# Naming an older deploy id IS the rollback mechanism:
|
|
173
|
+
curl -s -X POST "$BASE/api/admin/storefront/publish" \
|
|
174
|
+
-H "content-type: application/json" \
|
|
175
|
+
-d '{ "deploy_id": "sfd_01H…older…" }'
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Response `201` is a new ledger row with `target: "production"` and
|
|
179
|
+
`promoted_deploy_id` pointing at the preview it promoted. Publishing is
|
|
180
|
+
near-instant — the snapshot was already built; the live domain switches
|
|
181
|
+
to it without a rebuild.
|
|
182
|
+
|
|
183
|
+
Rollback is not a separate feature: publish any earlier `ready` deploy
|
|
184
|
+
from the history and the live site is that snapshot again. Roll forward
|
|
185
|
+
the same way.
|
|
186
|
+
|
|
187
|
+
## Workflow summary
|
|
188
|
+
|
|
189
|
+
1. Build locally against your store (the [runbook](BUILD-A-STOREFRONT.md)).
|
|
190
|
+
2. Deploy → check the permanent preview URL.
|
|
191
|
+
3. Iterate — every deploy replaces preview, live is untouched.
|
|
192
|
+
4. Publish when it's right; the deploy history is your undo button.
|