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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cartbase",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Scaffold a Cartbase storefront: npm create cartbase my-store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 — static and synchronous, never
122
- awaits a fetch (first-hit consent race otherwise).
123
- 2. Tracking mounts (`<MetaPixel>`, `<GA4>`, GTM) gated on the consent
124
- state and fed by the integrations tracking block, never by env vars.
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 (backfilled from
138
- `products.collection_id`) contains the three fixture 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.
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.