create-cartbase 0.1.13 → 0.1.15
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/README.md +4 -0
- package/dist/enclosing-project.js +82 -0
- package/dist/index.js +5 -1
- package/package.json +1 -1
- package/template/app/docs/BUILD-A-STOREFRONT.md +8 -4
- package/template/app/docs/collections.md +167 -167
- package/template/app/docs/components.md +1290 -1211
- package/template/app/docs/consent.md +18 -8
- package/template/app/docs/customers.md +6 -8
- package/template/app/docs/integrations.md +4 -2
- package/template/app/docs/products.md +316 -306
- package/template/app/package.json +1 -1
- package/template/app/src/app/layout.tsx +11 -7
- package/template/app/src/app/order/[id]/confirmed/page.tsx +4 -0
- package/template/app/src/app/page.tsx +4 -1
- package/template/app/src/app/products/[handle]/page.tsx +15 -2
- package/template/app/src/app/providers.tsx +37 -27
- package/template/app/src/app/search/page.tsx +4 -1
- package/template/app/src/lib/config.ts +11 -0
package/README.md
CHANGED
|
@@ -19,3 +19,7 @@ app at a local or staging platform; without it the app talks to the
|
|
|
19
19
|
platform. Deploy with the
|
|
20
20
|
[`cartbase`](https://www.npmjs.com/package/cartbase) CLI: `cartbase login`,
|
|
21
21
|
then `cartbase deploy`.
|
|
22
|
+
|
|
23
|
+
Run it in a folder of its own. Inside an existing project, Next.js takes
|
|
24
|
+
that project's folder as the workspace root; the scaffold notices and
|
|
25
|
+
prints the one line to add to `next.config.ts` if that is what you meant.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* WHERE THE SCAFFOLD LANDS MATTERS TO NEXT.JS, so the scaffold says so.
|
|
6
|
+
*
|
|
7
|
+
* Next infers the workspace root from the lockfiles it finds walking up from
|
|
8
|
+
* the app (`next/dist/lib/find-root.js`, Next 16.2.4): the highest lockfile
|
|
9
|
+
* wins, the walk stops at the Git repository that contains the app, and a
|
|
10
|
+
* lockfile at or above the home directory never counts. A store scaffolded
|
|
11
|
+
* INSIDE another project therefore gets that project's folder as its root,
|
|
12
|
+
* and once the store has its own lockfile Next warns on every start that it
|
|
13
|
+
* "inferred your workspace root". The scaffold cannot know whether the
|
|
14
|
+
* merchant meant a workspace member or a stray folder, so it names the
|
|
15
|
+
* situation and both ways out at the moment they can still choose
|
|
16
|
+
* (store-package card, finding 4).
|
|
17
|
+
*/
|
|
18
|
+
/** The lockfiles Next.js treats as workspace-root markers, its own list. */
|
|
19
|
+
export const ROOT_MARKERS = [
|
|
20
|
+
"pnpm-lock.yaml",
|
|
21
|
+
"package-lock.json",
|
|
22
|
+
"yarn.lock",
|
|
23
|
+
"bun.lock",
|
|
24
|
+
"bun.lockb",
|
|
25
|
+
];
|
|
26
|
+
/** Is `dir` `of` itself, or one of its ancestors? */
|
|
27
|
+
function isAncestorOrSelf(dir, of) {
|
|
28
|
+
const rel = path.relative(dir, of);
|
|
29
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
30
|
+
}
|
|
31
|
+
/** The nearest folder, from `start` upward, that is a Git repository or worktree. */
|
|
32
|
+
function gitBoundary(start) {
|
|
33
|
+
let current = start;
|
|
34
|
+
for (;;) {
|
|
35
|
+
if (fs.existsSync(path.join(current, ".git")))
|
|
36
|
+
return current;
|
|
37
|
+
const parent = path.dirname(current);
|
|
38
|
+
if (parent === current)
|
|
39
|
+
return null;
|
|
40
|
+
current = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The project the target folder would sit inside, by Next's own rule, or
|
|
45
|
+
* null when Next would take the app's own folder as the root. The target
|
|
46
|
+
* itself need not exist yet: the scaffold asks before it creates it.
|
|
47
|
+
*/
|
|
48
|
+
export function findEnclosingProject(target, options = {}) {
|
|
49
|
+
const home = options.homeDir === undefined ? os.homedir() : options.homeDir;
|
|
50
|
+
const parent = path.dirname(path.resolve(target));
|
|
51
|
+
const boundary = gitBoundary(parent);
|
|
52
|
+
let current = parent;
|
|
53
|
+
for (;;) {
|
|
54
|
+
// Never the home directory or above: Next ignores a marker there.
|
|
55
|
+
if (home && isAncestorOrSelf(current, home))
|
|
56
|
+
return null;
|
|
57
|
+
// Never above the repository that contains the app.
|
|
58
|
+
if (boundary && current !== boundary && isAncestorOrSelf(current, boundary))
|
|
59
|
+
return null;
|
|
60
|
+
for (const marker of ROOT_MARKERS) {
|
|
61
|
+
if (fs.existsSync(path.join(current, marker)))
|
|
62
|
+
return { dir: current, marker };
|
|
63
|
+
}
|
|
64
|
+
const next = path.dirname(current);
|
|
65
|
+
if (next === current)
|
|
66
|
+
return null;
|
|
67
|
+
current = next;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** What the scaffold prints when the folder sits inside another project. */
|
|
71
|
+
export function enclosingProjectNote(target, found) {
|
|
72
|
+
const app = path.basename(path.resolve(target));
|
|
73
|
+
return [
|
|
74
|
+
`Note: ${app}/ sits inside another project (${found.marker} in ${found.dir}).`,
|
|
75
|
+
`Next.js takes that folder as the workspace root and, once ${app}/ has its own`,
|
|
76
|
+
`lockfile, warns on every start that it inferred the root. Either it is a`,
|
|
77
|
+
`member of that workspace, so name the root in next.config.ts:`,
|
|
78
|
+
` turbopack: { root: ${JSON.stringify(found.dir)} }`,
|
|
79
|
+
`or it is a project of its own, so create it outside that folder, or run`,
|
|
80
|
+
`git init inside it, which is where Next stops looking.`,
|
|
81
|
+
].join("\n");
|
|
82
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { enclosingProjectNote, findEnclosingProject } from "./enclosing-project.js";
|
|
5
6
|
function parseArgs(argv) {
|
|
6
7
|
const flags = new Map();
|
|
7
8
|
let dir;
|
|
@@ -82,6 +83,9 @@ Given as a flag it is written into .env.local; otherwise a placeholder is.
|
|
|
82
83
|
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
83
84
|
const key = flag(flags, "key");
|
|
84
85
|
fs.writeFileSync(path.join(target, ".env.local"), envFile({ key, url: flag(flags, "url"), clientId: flag(flags, "client-id") }));
|
|
86
|
+
// A store scaffolded inside another project inherits that project's
|
|
87
|
+
// workspace root in Next's eyes; said once, here, while it is cheap to move.
|
|
88
|
+
const enclosing = findEnclosingProject(target);
|
|
85
89
|
console.log(`
|
|
86
90
|
Created ${path.basename(target)}/
|
|
87
91
|
|
|
@@ -97,6 +101,6 @@ Next steps:
|
|
|
97
101
|
The complete storefront reference is in docs/ — hand CLAUDE.md (or
|
|
98
102
|
AGENTS.md) to your coding agent and it has everything. Deploy with the
|
|
99
103
|
Cartbase CLI: cartbase login, then cartbase deploy.
|
|
100
|
-
`);
|
|
104
|
+
${enclosing ? `\n${enclosingProjectNote(target, enclosing)}\n` : ""}`);
|
|
101
105
|
}
|
|
102
106
|
main();
|
package/package.json
CHANGED
|
@@ -118,10 +118,14 @@ Fetch once at layout level, cache per the docs' cache headers:
|
|
|
118
118
|
Order inside `<body>` matters (contracts in [components.md](components.md)
|
|
119
119
|
and [consent.md](consent.md)):
|
|
120
120
|
|
|
121
|
-
1. `<ConsentInit>` FIRST child of body —
|
|
122
|
-
awaits a fetch (first-hit consent race
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
1. `<ConsentInit required={consent.enabled}>` FIRST child of body —
|
|
122
|
+
synchronous, never awaits a fetch in the browser (first-hit consent race
|
|
123
|
+
otherwise); the layout resolves the consent config on the server and
|
|
124
|
+
passes the switch. A store that collects consent starts visitors denied
|
|
125
|
+
until they choose; a store with the banner off starts them granted.
|
|
126
|
+
2. `<StorefrontTags client={client}>` — every tag the store configured,
|
|
127
|
+
fed by the integrations tracking block, never by env vars, each gated by
|
|
128
|
+
the same switch (`tracking.consent_required`).
|
|
125
129
|
3. Navigation from [menus.md](menus.md) — `main-menu` / `footer` handles;
|
|
126
130
|
an unknown handle 404s and must render as "no nav", never crash.
|
|
127
131
|
|
|
@@ -1,167 +1,167 @@
|
|
|
1
|
-
# Collections
|
|
2
|
-
|
|
3
|
-
Curated product groupings (manual or smart). The membership listing —
|
|
4
|
-
`/collections/:id/products` — is the collection page's data source: it reads
|
|
5
|
-
the membership JOIN (multi-collection products appear in every collection
|
|
6
|
-
they belong to), honors the collection's `default_sort`, and accepts a
|
|
7
|
-
per-request `order` override. Money is EUR decimal major units.
|
|
8
|
-
|
|
9
|
-
SDK module: `@cartbase/storefront/api/collections`.
|
|
10
|
-
|
|
11
|
-
**Channel scope (Shopify publish-to-channel semantics):** a collection with
|
|
12
|
-
sales-channel links is visible ONLY on those channels; a collection with no
|
|
13
|
-
links is visible everywhere. Pass your channel as `sales_channel_id` — the
|
|
14
|
-
list excludes scoped-away collections, and the membership listing 404s them.
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## GET /api/store/collections
|
|
19
|
-
|
|
20
|
-
- **Purpose** — list collections (navigation, collection index pages).
|
|
21
|
-
- **Auth** — anon: `x-client-id` required.
|
|
22
|
-
- **Request**
|
|
23
|
-
|
|
24
|
-
```jsonc
|
|
25
|
-
// query (all optional)
|
|
26
|
-
{
|
|
27
|
-
"q": "essen", // case-insensitive substring on title
|
|
28
|
-
"handle": "essentials", // exact — THE handle lookup (no /:handle route)
|
|
29
|
-
"sales_channel_id": "sc_…", // channel scope (see above)
|
|
30
|
-
"limit": 50, // 1–200, default 50
|
|
31
|
-
"offset": 0
|
|
32
|
-
}
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
- **Response** — `{ collections, count, offset, limit }`, ordered by title:
|
|
36
|
-
|
|
37
|
-
```jsonc
|
|
38
|
-
{
|
|
39
|
-
"collections": [
|
|
40
|
-
{
|
|
41
|
-
"id": "pcol_01tst00000000000000000001",
|
|
42
|
-
"title": "Essentials",
|
|
43
|
-
"handle": "essentials",
|
|
44
|
-
"type": "manual", // "manual" | "smart"
|
|
45
|
-
"description": null,
|
|
46
|
-
"image_url": null,
|
|
47
|
-
"default_sort": "manual", // used by /products when no order override
|
|
48
|
-
"conditions": [], // smart-collection rules (admin-authored)
|
|
49
|
-
"match": "all", // smart matching: "all" | "any"
|
|
50
|
-
"seo_title": null, // null = fall back to title
|
|
51
|
-
"seo_description": null, // null = fall back to description
|
|
52
|
-
"metadata": null,
|
|
53
|
-
"created_at": "2026-07-01T00:00:00.000Z",
|
|
54
|
-
"updated_at": "2026-07-01T00:00:00.000Z"
|
|
55
|
-
}
|
|
56
|
-
],
|
|
57
|
-
"count": 1, "offset": 0, "limit": 50
|
|
58
|
-
}
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
- **Working curl** — the seeded catalog carries the `essentials` collection:
|
|
62
|
-
|
|
63
|
-
```bash
|
|
64
|
-
COLLECTIONS=$(curl -sf "$BASE/api/store/collections?handle=essentials" \
|
|
65
|
-
-H "x-client-id: $CLIENT_ID")
|
|
66
|
-
echo "$COLLECTIONS" | grep -q '"collections"'
|
|
67
|
-
echo "$COLLECTIONS" | grep -q '"handle":"essentials"'
|
|
68
|
-
COL_ID=$(echo "$COLLECTIONS" | grep -o '"id":"pcol_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
69
|
-
test -n "$COL_ID"
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`.
|
|
73
|
-
- **SDK** — `listCollections(client, query?)`.
|
|
74
|
-
- **Components** — navigation, collection index grid.
|
|
75
|
-
- **Settings** — collection channel links; smart-collection conditions
|
|
76
|
-
(membership recomputes on rule/product change).
|
|
77
|
-
|
|
78
|
-
---
|
|
79
|
-
|
|
80
|
-
## GET /api/store/collections/:id
|
|
81
|
-
|
|
82
|
-
- **Purpose** — retrieve one collection (header/SEO block of a collection
|
|
83
|
-
page). By-handle lookup goes through the list (`?handle=`).
|
|
84
|
-
- **Auth** — anon: `x-client-id` required.
|
|
85
|
-
- **Request** — no query. NOTE (code truth): the single read takes no
|
|
86
|
-
`sales_channel_id` — channel scope applies to the list and the membership
|
|
87
|
-
listing, not here.
|
|
88
|
-
- **Response** — `{ "collection": { ...same shape as list rows... } }`
|
|
89
|
-
- **Working curl**
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
COLLECTION=$(curl -sf "$BASE/api/store/collections/$COL_ID" -H "x-client-id: $CLIENT_ID")
|
|
93
|
-
echo "$COLLECTION" | grep -q '"collection"'
|
|
94
|
-
echo "$COLLECTION" | grep -q '"default_sort"'
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
- **Errors** — 404 `not_found`.
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
101
|
-
"$BASE/api/store/collections/pcol_doesnotexist$RUN" -H "x-client-id: $CLIENT_ID")
|
|
102
|
-
test "$STATUS" = 404
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
- **SDK** — `retrieveCollection(client, collectionId)`.
|
|
106
|
-
- **Components** — collection page header.
|
|
107
|
-
- **Settings** — SEO overrides.
|
|
108
|
-
|
|
109
|
-
---
|
|
110
|
-
|
|
111
|
-
## GET /api/store/collections/:id/products
|
|
112
|
-
|
|
113
|
-
- **Purpose** — the collection page's product grid: membership join,
|
|
114
|
-
published products only, ordered by the collection's `default_sort` with
|
|
115
|
-
an optional `order` override.
|
|
116
|
-
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
|
|
117
|
-
- **Request**
|
|
118
|
-
|
|
119
|
-
```jsonc
|
|
120
|
-
// query (all optional)
|
|
121
|
-
{
|
|
122
|
-
"order": "price_asc", // override: manual | title_asc | title_desc |
|
|
123
|
-
// price_asc | price_desc | newest | oldest |
|
|
124
|
-
// best_selling (90-day aggregate).
|
|
125
|
-
// Unknown values are IGNORED (default_sort used).
|
|
126
|
-
"sales_channel_id": "sc_…", // a collection scoped to OTHER channels 404s
|
|
127
|
-
"currency_code": "eur", // pricing context → calculated_price
|
|
128
|
-
"region_id": "reg_…",
|
|
129
|
-
"limit": 50, // 1–100, default 50
|
|
130
|
-
"offset": 0
|
|
131
|
-
}
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
- **Response** — `{ products, count, offset, limit }` — products carry the
|
|
135
|
-
FULL canonical product shape (see products.md), incl. `calculated_price`
|
|
136
|
-
when a pricing context is given. `count` is the visible membership size.
|
|
137
|
-
- **Working curl** — seeded membership
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
```bash
|
|
141
|
-
MEMBERS=$(curl -sf "$BASE/api/store/collections/$COL_ID/products?currency_code=eur" \
|
|
142
|
-
-H "x-client-id: $CLIENT_ID")
|
|
143
|
-
echo "$MEMBERS" | grep -q '"products"'
|
|
144
|
-
echo "$MEMBERS" | grep -q '"handle":"linen-shirt"'
|
|
145
|
-
echo "$MEMBERS" | grep -q '"calculated_price"'
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
# Sort override: price_asc puts the Wool Beanie (EUR 23) before the Linen
|
|
150
|
-
# Shirt (EUR 45) — asserted as RELATIVE order so unrelated rows can't break it.
|
|
151
|
-
curl -sf "$BASE/api/store/collections/$COL_ID/products?order=price_asc" \
|
|
152
|
-
-H "x-client-id: $CLIENT_ID" | node -e "
|
|
153
|
-
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
|
|
154
|
-
const j=JSON.parse(Buffer.concat(c));
|
|
155
|
-
const h=j.products.map(p=>p.handle);
|
|
156
|
-
const a=h.indexOf('wool-beanie'), b=h.indexOf('linen-shirt');
|
|
157
|
-
if(a<0||b<0||a>b){console.error('price_asc order wrong: '+h.join(','));process.exit(1)}
|
|
158
|
-
})"
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
- **Errors** — 404 `not_found` (unknown collection, or scoped away from the
|
|
162
|
-
given `sales_channel_id`), 400 `validation_failed`, 400 `invalid_region`.
|
|
163
|
-
- **SDK** — `listCollectionProducts(client, collectionId, query?)`.
|
|
164
|
-
- **Components** — product card grid + sort dropdown (emit the `order`
|
|
165
|
-
values above).
|
|
166
|
-
- **Settings** — collection `default_sort` + manual position order
|
|
167
|
-
(drag-reorder in admin); price lists; channel links.
|
|
1
|
+
# Collections
|
|
2
|
+
|
|
3
|
+
Curated product groupings (manual or smart). The membership listing —
|
|
4
|
+
`/collections/:id/products` — is the collection page's data source: it reads
|
|
5
|
+
the membership JOIN (multi-collection products appear in every collection
|
|
6
|
+
they belong to), honors the collection's `default_sort`, and accepts a
|
|
7
|
+
per-request `order` override. Money is EUR decimal major units.
|
|
8
|
+
|
|
9
|
+
SDK module: `@cartbase/storefront/api/collections`.
|
|
10
|
+
|
|
11
|
+
**Channel scope (Shopify publish-to-channel semantics):** a collection with
|
|
12
|
+
sales-channel links is visible ONLY on those channels; a collection with no
|
|
13
|
+
links is visible everywhere. Pass your channel as `sales_channel_id` — the
|
|
14
|
+
list excludes scoped-away collections, and the membership listing 404s them.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## GET /api/store/collections
|
|
19
|
+
|
|
20
|
+
- **Purpose** — list collections (navigation, collection index pages).
|
|
21
|
+
- **Auth** — anon: `x-client-id` required.
|
|
22
|
+
- **Request**
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
// query (all optional)
|
|
26
|
+
{
|
|
27
|
+
"q": "essen", // case-insensitive substring on title
|
|
28
|
+
"handle": "essentials", // exact — THE handle lookup (no /:handle route)
|
|
29
|
+
"sales_channel_id": "sc_…", // channel scope (see above)
|
|
30
|
+
"limit": 50, // 1–200, default 50
|
|
31
|
+
"offset": 0
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **Response** — `{ collections, count, offset, limit }`, ordered by title:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"collections": [
|
|
40
|
+
{
|
|
41
|
+
"id": "pcol_01tst00000000000000000001",
|
|
42
|
+
"title": "Essentials",
|
|
43
|
+
"handle": "essentials",
|
|
44
|
+
"type": "manual", // "manual" | "smart"
|
|
45
|
+
"description": null,
|
|
46
|
+
"image_url": null,
|
|
47
|
+
"default_sort": "manual", // used by /products when no order override
|
|
48
|
+
"conditions": [], // smart-collection rules (admin-authored)
|
|
49
|
+
"match": "all", // smart matching: "all" | "any"
|
|
50
|
+
"seo_title": null, // null = fall back to title
|
|
51
|
+
"seo_description": null, // null = fall back to description
|
|
52
|
+
"metadata": null,
|
|
53
|
+
"created_at": "2026-07-01T00:00:00.000Z",
|
|
54
|
+
"updated_at": "2026-07-01T00:00:00.000Z"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"count": 1, "offset": 0, "limit": 50
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **Working curl** — the seeded catalog carries the `essentials` collection:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
COLLECTIONS=$(curl -sf "$BASE/api/store/collections?handle=essentials" \
|
|
65
|
+
-H "x-client-id: $CLIENT_ID")
|
|
66
|
+
echo "$COLLECTIONS" | grep -q '"collections"'
|
|
67
|
+
echo "$COLLECTIONS" | grep -q '"handle":"essentials"'
|
|
68
|
+
COL_ID=$(echo "$COLLECTIONS" | grep -o '"id":"pcol_[^"]*"' | head -1 | cut -d'"' -f4)
|
|
69
|
+
test -n "$COL_ID"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Errors** — 400 `missing_client_id`, 400 `validation_failed`.
|
|
73
|
+
- **SDK** — `listCollections(client, query?)`.
|
|
74
|
+
- **Components** — navigation, collection index grid.
|
|
75
|
+
- **Settings** — collection channel links; smart-collection conditions
|
|
76
|
+
(membership recomputes on rule/product change).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## GET /api/store/collections/:id
|
|
81
|
+
|
|
82
|
+
- **Purpose** — retrieve one collection (header/SEO block of a collection
|
|
83
|
+
page). By-handle lookup goes through the list (`?handle=`).
|
|
84
|
+
- **Auth** — anon: `x-client-id` required.
|
|
85
|
+
- **Request** — no query. NOTE (code truth): the single read takes no
|
|
86
|
+
`sales_channel_id` — channel scope applies to the list and the membership
|
|
87
|
+
listing, not here.
|
|
88
|
+
- **Response** — `{ "collection": { ...same shape as list rows... } }`
|
|
89
|
+
- **Working curl**
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
COLLECTION=$(curl -sf "$BASE/api/store/collections/$COL_ID" -H "x-client-id: $CLIENT_ID")
|
|
93
|
+
echo "$COLLECTION" | grep -q '"collection"'
|
|
94
|
+
echo "$COLLECTION" | grep -q '"default_sort"'
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- **Errors** — 404 `not_found`.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
|
101
|
+
"$BASE/api/store/collections/pcol_doesnotexist$RUN" -H "x-client-id: $CLIENT_ID")
|
|
102
|
+
test "$STATUS" = 404
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- **SDK** — `retrieveCollection(client, collectionId)`.
|
|
106
|
+
- **Components** — collection page header.
|
|
107
|
+
- **Settings** — SEO overrides.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## GET /api/store/collections/:id/products
|
|
112
|
+
|
|
113
|
+
- **Purpose** — the collection page's product grid: membership join,
|
|
114
|
+
published products only, ordered by the collection's `default_sort` with
|
|
115
|
+
an optional `order` override.
|
|
116
|
+
- **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
|
|
117
|
+
- **Request**
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
// query (all optional)
|
|
121
|
+
{
|
|
122
|
+
"order": "price_asc", // override: manual | title_asc | title_desc |
|
|
123
|
+
// price_asc | price_desc | newest | oldest |
|
|
124
|
+
// best_selling (90-day aggregate).
|
|
125
|
+
// Unknown values are IGNORED (default_sort used).
|
|
126
|
+
"sales_channel_id": "sc_…", // a collection scoped to OTHER channels 404s
|
|
127
|
+
"currency_code": "eur", // pricing context → calculated_price
|
|
128
|
+
"region_id": "reg_…",
|
|
129
|
+
"limit": 50, // 1–100, default 50
|
|
130
|
+
"offset": 0
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
- **Response** — `{ products, count, offset, limit }` — products carry the
|
|
135
|
+
FULL canonical product shape (see products.md), incl. `calculated_price`
|
|
136
|
+
when a pricing context is given. `count` is the visible membership size.
|
|
137
|
+
- **Working curl** — the seeded membership contains the three fixture
|
|
138
|
+
products:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
MEMBERS=$(curl -sf "$BASE/api/store/collections/$COL_ID/products?currency_code=eur" \
|
|
142
|
+
-H "x-client-id: $CLIENT_ID")
|
|
143
|
+
echo "$MEMBERS" | grep -q '"products"'
|
|
144
|
+
echo "$MEMBERS" | grep -q '"handle":"linen-shirt"'
|
|
145
|
+
echo "$MEMBERS" | grep -q '"calculated_price"'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# Sort override: price_asc puts the Wool Beanie (EUR 23) before the Linen
|
|
150
|
+
# Shirt (EUR 45) — asserted as RELATIVE order so unrelated rows can't break it.
|
|
151
|
+
curl -sf "$BASE/api/store/collections/$COL_ID/products?order=price_asc" \
|
|
152
|
+
-H "x-client-id: $CLIENT_ID" | node -e "
|
|
153
|
+
const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
|
|
154
|
+
const j=JSON.parse(Buffer.concat(c));
|
|
155
|
+
const h=j.products.map(p=>p.handle);
|
|
156
|
+
const a=h.indexOf('wool-beanie'), b=h.indexOf('linen-shirt');
|
|
157
|
+
if(a<0||b<0||a>b){console.error('price_asc order wrong: '+h.join(','));process.exit(1)}
|
|
158
|
+
})"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- **Errors** — 404 `not_found` (unknown collection, or scoped away from the
|
|
162
|
+
given `sales_channel_id`), 400 `validation_failed`, 400 `invalid_region`.
|
|
163
|
+
- **SDK** — `listCollectionProducts(client, collectionId, query?)`.
|
|
164
|
+
- **Components** — product card grid + sort dropdown (emit the `order`
|
|
165
|
+
values above).
|
|
166
|
+
- **Settings** — collection `default_sort` + manual position order
|
|
167
|
+
(drag-reorder in admin); price lists; channel links.
|