create-brainerce-store 1.80.0 → 1.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +375 -9
- package/messages/en.json +13 -6
- package/messages/he.json +13 -6
- package/package.json +1 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +32 -2
- package/templates/nextjs/base/package.json.ejs +53 -52
- package/templates/nextjs/base/scripts/connect.mjs +452 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +50 -0
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +5 -5
- package/templates/nextjs/designs/atelier/messages-patch/he.json +5 -5
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +50 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +46 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
|
@@ -1,52 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "<%= projectName %>",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"scripts": {
|
|
6
|
-
"dev": "next dev",
|
|
7
|
-
"build": "next build",
|
|
8
|
-
"start": "next start",
|
|
9
|
-
"lint": "next lint",
|
|
10
|
-
"setup": "node scripts/fetch-store-info.mjs"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"react
|
|
17
|
-
"
|
|
18
|
-
"@radix-ui/react-
|
|
19
|
-
"@radix-ui/react-
|
|
20
|
-
"@radix-ui/react-
|
|
21
|
-
"@radix-ui/react-
|
|
22
|
-
"@radix-ui/react-
|
|
23
|
-
"@radix-ui/react-
|
|
24
|
-
"@radix-ui/react-
|
|
25
|
-
"@radix-ui/react-
|
|
26
|
-
"@radix-ui/react-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"@types/
|
|
38
|
-
"@types/react
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"eslint
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= projectName %>",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start",
|
|
9
|
+
"lint": "next lint",
|
|
10
|
+
"setup": "node scripts/fetch-store-info.mjs",
|
|
11
|
+
"connect": "node scripts/connect.mjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"brainerce": "<%= brainerceVersion %>",
|
|
15
|
+
"next": "^15.3.4",
|
|
16
|
+
"react": "^19.0.0",
|
|
17
|
+
"react-dom": "^19.0.0",
|
|
18
|
+
"@radix-ui/react-accordion": "^1.2.0",
|
|
19
|
+
"@radix-ui/react-checkbox": "^1.1.1",
|
|
20
|
+
"@radix-ui/react-dialog": "^1.1.2",
|
|
21
|
+
"@radix-ui/react-label": "^2.1.0",
|
|
22
|
+
"@radix-ui/react-radio-group": "^1.2.0",
|
|
23
|
+
"@radix-ui/react-select": "^2.1.1",
|
|
24
|
+
"@radix-ui/react-separator": "^1.1.0",
|
|
25
|
+
"@radix-ui/react-slot": "^1.1.0",
|
|
26
|
+
"@radix-ui/react-tabs": "^1.1.0",
|
|
27
|
+
"@radix-ui/react-tooltip": "^1.1.4",
|
|
28
|
+
"class-variance-authority": "^0.7.1",
|
|
29
|
+
"clsx": "^2.1.1",
|
|
30
|
+
"lucide-react": "^0.462.0",
|
|
31
|
+
"tailwind-merge": "^2.5.2",
|
|
32
|
+
"tailwindcss-animate": "^1.0.7",
|
|
33
|
+
"isomorphic-dompurify": "<%= isomorphicDompurifyVersion %>",
|
|
34
|
+
"sharp": "^0.35.3"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^20.0.0",
|
|
38
|
+
"@types/react": "^19.0.0",
|
|
39
|
+
"@types/react-dom": "^19.0.0",
|
|
40
|
+
"autoprefixer": "^10.4.0",
|
|
41
|
+
"eslint": "^9.0.0",
|
|
42
|
+
"eslint-config-next": "^15.3.4",
|
|
43
|
+
"postcss": "^8.4.49",
|
|
44
|
+
"tailwindcss": "^3.4.0",
|
|
45
|
+
"typescript": "^5.4.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"onlyBuiltDependencies": ["sharp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Connect this storefront to a Brainerce sales channel, and optionally fill it
|
|
4
|
+
* with a starter catalog.
|
|
5
|
+
*
|
|
6
|
+
* Run it when the build is finished, not before:
|
|
7
|
+
*
|
|
8
|
+
* npm run connect
|
|
9
|
+
* npm run connect -- --seed-products '[{"name":"Burr Grinder","basePrice":389}]'
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS IS A SEPARATE STEP. Nothing in a storefront's code depends on WHICH
|
|
12
|
+
* store it points at — the pages, the routing, the cart, the checkout and every
|
|
13
|
+
* SDK call are identical either way. Only one environment variable differs. So
|
|
14
|
+
* a run scaffolded with `--defer-connection` builds the whole storefront
|
|
15
|
+
* uninterrupted and comes here at the end, instead of stopping for an approval
|
|
16
|
+
* before a single file exists.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ WHY SEEDING LIVES HERE TOO. The approval leaves a short single-use grant on
|
|
19
|
+
* /api/device-auth/seed keyed to the device code, and this script is the only
|
|
20
|
+
* thing that holds that code. Seeding from here is also strictly better than
|
|
21
|
+
* seeding at scaffold time: by now the storefront exists, so the products can
|
|
22
|
+
* match what was actually built rather than what was guessed before any file
|
|
23
|
+
* was written. Without this the deferred path could only ever produce an empty
|
|
24
|
+
* store, and the merchant's first look at their own site is a blank grid.
|
|
25
|
+
*
|
|
26
|
+
* One click in a browser, and this writes the id into `.env.local`. A store and
|
|
27
|
+
* a sales channel are created for you if you have neither.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ NEVER PROMPTS. This is run by coding agents in shells with no TTY, where a
|
|
30
|
+
* prompt does not fail — it hangs. Everything is printed and polled.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import { resolve } from 'node:path';
|
|
36
|
+
|
|
37
|
+
const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
|
|
38
|
+
const ENV_PATH = resolve(process.cwd(), '.env.local');
|
|
39
|
+
const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
|
|
40
|
+
|
|
41
|
+
// The seed route's own limits, mirrored here so a bad payload is refused before
|
|
42
|
+
// the grant is touched.
|
|
43
|
+
//
|
|
44
|
+
// ⛔ THIS IS A HAND-KEPT COPY. The canonical one is
|
|
45
|
+
// `packages/cli-shared/src/seed-products.ts`, which the scaffolder itself
|
|
46
|
+
// imports; this file cannot, because it ships standalone into the user's
|
|
47
|
+
// project where that package does not exist. When a limit moves, it moves
|
|
48
|
+
// there first and this follows. All of it mirrors SeedDeviceAuthDto /
|
|
49
|
+
// SeedProductDto in apps/backend/src/modules/device-auth/dto/device-auth.dto.ts,
|
|
50
|
+
// which is what actually enforces them.
|
|
51
|
+
const SEED_MIN_PRODUCTS = 1;
|
|
52
|
+
const SEED_MAX_PRODUCTS = 20;
|
|
53
|
+
const SEED_MAX_NAME = 191;
|
|
54
|
+
const SEED_MAX_DESCRIPTION = 5000;
|
|
55
|
+
const SEED_FIELDS = ['name', 'basePrice', 'description'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read `--seed-products <json>` from argv, or `BRAINERCE_SEED_PRODUCTS`.
|
|
59
|
+
*
|
|
60
|
+
* The environment variable is not a nicety: `npm run connect -- --seed-products '[...]'`
|
|
61
|
+
* loses its quoting in PowerShell, which is the default shell for a large share
|
|
62
|
+
* of the agents that run this, and a JSON array mangled into eight arguments
|
|
63
|
+
* fails in a way nobody can read. The variable is the escape hatch for those.
|
|
64
|
+
*/
|
|
65
|
+
function readSeedArg(argv) {
|
|
66
|
+
const flag = argv.indexOf('--seed-products');
|
|
67
|
+
if (flag !== -1) return argv[flag + 1] ?? '';
|
|
68
|
+
const inline = argv.find((a) => a.startsWith('--seed-products='));
|
|
69
|
+
if (inline) return inline.slice('--seed-products='.length);
|
|
70
|
+
return process.env.BRAINERCE_SEED_PRODUCTS ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse and check the seed payload locally, before anything is sent.
|
|
75
|
+
*
|
|
76
|
+
* The seeding grant is single use and dies with the approval window, so a
|
|
77
|
+
* payload the API would reject must never reach it: there is no second attempt
|
|
78
|
+
* inside the run, and a second attempt would cost the user a second approval.
|
|
79
|
+
* Every rule here is one the backend enforces anyway — this only moves the
|
|
80
|
+
* refusal to a point where it is still free.
|
|
81
|
+
*
|
|
82
|
+
* The backend runs `forbidNonWhitelisted`, so ONE stray key (`sku`, `type`,
|
|
83
|
+
* `images` are the ones agents reach for) rejects the entire request rather
|
|
84
|
+
* than the row that carried it. That is why unknown keys are an error here and
|
|
85
|
+
* not something quietly dropped.
|
|
86
|
+
*
|
|
87
|
+
* Returns `{ products }` or `{ error }`. Pure: no I/O, no process state.
|
|
88
|
+
*/
|
|
89
|
+
function parseSeedProducts(raw) {
|
|
90
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
91
|
+
return {
|
|
92
|
+
error:
|
|
93
|
+
'--seed-products was passed with no value. Give it a JSON array of products, quoted as one shell argument, or drop the flag entirely to connect without seeding.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
error: `--seed-products is not valid JSON (${error.message}). Pass the array as a single quoted argument, or put it in BRAINERCE_SEED_PRODUCTS if your shell keeps breaking the quoting.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A bare array is the documented shape. An object with a `products` key is
|
|
107
|
+
// the body the seed route itself takes, and an agent copying from the route
|
|
108
|
+
// reference will send that, so accept it rather than refusing a payload that
|
|
109
|
+
// is right in every way that matters.
|
|
110
|
+
const list = Array.isArray(parsed)
|
|
111
|
+
? parsed
|
|
112
|
+
: parsed && typeof parsed === 'object' && Array.isArray(parsed.products)
|
|
113
|
+
? parsed.products
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
if (!list) {
|
|
117
|
+
return {
|
|
118
|
+
error:
|
|
119
|
+
'--seed-products must be a JSON array of products, or an object with a "products" array. A single product object on its own is not accepted; wrap it in an array.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (list.length < SEED_MIN_PRODUCTS) {
|
|
123
|
+
return {
|
|
124
|
+
error:
|
|
125
|
+
'--seed-products was an empty list. Seeding needs at least one product; if the user has not described what they sell, leave the flag off and build against the empty catalog.',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (list.length > SEED_MAX_PRODUCTS) {
|
|
129
|
+
return {
|
|
130
|
+
error: `--seed-products carries ${list.length} products and the limit is ${SEED_MAX_PRODUCTS}. This is a starter catalog, not an import: pick the ${SEED_MAX_PRODUCTS} that best show the store, and point the merchant at the dashboard or Apps > Browse > Migration Tool for the rest.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const products = [];
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
137
|
+
const entry = list[i];
|
|
138
|
+
const at = `--seed-products product ${i + 1}`;
|
|
139
|
+
|
|
140
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
141
|
+
return {
|
|
142
|
+
error: `${at} is not an object. Each entry looks like {"name":"...","basePrice":0}.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const unknown = Object.keys(entry).filter((k) => !SEED_FIELDS.includes(k));
|
|
147
|
+
if (unknown.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
error: `${at} carries ${unknown.map((k) => `"${k}"`).join(', ')}, which the seed route does not accept and will not ignore — one unknown key rejects the whole request. Only ${SEED_FIELDS.join(', ')} are allowed: no sku, no type, no options or variants, no categories, no images. Every seeded product lands as a simple, active product.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
154
|
+
return { error: `${at} has no usable "name". It must be a non-empty string.` };
|
|
155
|
+
}
|
|
156
|
+
if (entry.name.length > SEED_MAX_NAME) {
|
|
157
|
+
return {
|
|
158
|
+
error: `${at} ("${entry.name.slice(0, 40)}...") has a name of ${entry.name.length} characters and the limit is ${SEED_MAX_NAME}.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!('basePrice' in entry)) {
|
|
163
|
+
return {
|
|
164
|
+
error: `${at} ("${entry.name}") has no "basePrice". Every seeded product needs one, as a number: {"name":"${entry.name}","basePrice":0} is the minimum shape.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (typeof entry.basePrice === 'string') {
|
|
168
|
+
// The failure that actually happens: a price quoted as a string. The API
|
|
169
|
+
// validates with @IsNumber(), which does not coerce, so "149" is refused.
|
|
170
|
+
return {
|
|
171
|
+
error: `${at} ("${entry.name}") has a "basePrice" quoted as text. Send ${entry.basePrice.trim() === '' ? '149' : entry.basePrice} without the quotes — the API does not coerce a quoted price, it rejects it.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (typeof entry.basePrice !== 'number' || !Number.isFinite(entry.basePrice)) {
|
|
175
|
+
return {
|
|
176
|
+
error: `${at} ("${entry.name}") has a "basePrice" that is not a number. It must be a plain finite number, not null, not a string and not an expression.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (entry.basePrice < 0) {
|
|
180
|
+
return { error: `${at} ("${entry.name}") has a negative "basePrice".` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ('description' in entry) {
|
|
184
|
+
if (typeof entry.description !== 'string') {
|
|
185
|
+
return {
|
|
186
|
+
error: `${at} ("${entry.name}") has a "description" that is not a string. Omit the key entirely rather than sending null.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (entry.description.length > SEED_MAX_DESCRIPTION) {
|
|
190
|
+
return {
|
|
191
|
+
error: `${at} ("${entry.name}") has a description of ${entry.description.length} characters and the limit is ${SEED_MAX_DESCRIPTION}.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const product = { name: entry.name, basePrice: entry.basePrice };
|
|
197
|
+
if ('description' in entry) product.description = entry.description;
|
|
198
|
+
products.push(product);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { products };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Only ever hand the OS a URL on the host we asked. It arrives in a network
|
|
205
|
+
* response, so it is untrusted input on the way to a process launch. */
|
|
206
|
+
function openable(value) {
|
|
207
|
+
try {
|
|
208
|
+
const url = new URL(value);
|
|
209
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
|
|
210
|
+
const base = new URL(API_BASE).hostname.replace(/^api(-staging)?\./, '');
|
|
211
|
+
return url.hostname === base || url.hostname.endsWith(`.${base}`);
|
|
212
|
+
} catch {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function openBrowser(url) {
|
|
218
|
+
const [cmd, args] =
|
|
219
|
+
process.platform === 'win32'
|
|
220
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
221
|
+
: process.platform === 'darwin'
|
|
222
|
+
? ['open', [url]]
|
|
223
|
+
: ['xdg-open', [url]];
|
|
224
|
+
try {
|
|
225
|
+
// No shell, argument array: the URL cannot become a command.
|
|
226
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
227
|
+
child.on('error', () => {});
|
|
228
|
+
child.unref();
|
|
229
|
+
return true;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Rewrite in place so hand-added variables survive. A blind overwrite here
|
|
236
|
+
* would silently drop a payment key someone pasted in an hour earlier. */
|
|
237
|
+
function writeEnv(connectionId) {
|
|
238
|
+
let body = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '';
|
|
239
|
+
for (const key of KEYS) {
|
|
240
|
+
const line = `${key}=${connectionId}`;
|
|
241
|
+
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
|
242
|
+
body = pattern.test(body) ? body.replace(pattern, line) : `${body.trimEnd()}\n${line}\n`;
|
|
243
|
+
}
|
|
244
|
+
writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function readError(res) {
|
|
248
|
+
try {
|
|
249
|
+
const body = await res.json();
|
|
250
|
+
const message = body?.message;
|
|
251
|
+
return Array.isArray(message) ? message.join('; ') : (message ?? `HTTP ${res.status}`);
|
|
252
|
+
} catch {
|
|
253
|
+
return `HTTP ${res.status}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Spend the seeding grant.
|
|
259
|
+
*
|
|
260
|
+
* ⛔ `deviceCode` is a 256-bit bearer credential and it stops here. It is the
|
|
261
|
+
* poll credential as well as the seed credential, so anything that put it on
|
|
262
|
+
* stdout or in a log would hand a reader inside the ten-minute window the
|
|
263
|
+
* ability to seed this store. It is never printed and never interpolated into
|
|
264
|
+
* an error string.
|
|
265
|
+
*
|
|
266
|
+
* Called once, with no retry. The route allows 5 requests a minute and the
|
|
267
|
+
* grant is single use, so a retry after an ambiguous failure would report "that
|
|
268
|
+
* window has closed" over a write that may well have landed.
|
|
269
|
+
*/
|
|
270
|
+
async function runSeed(deviceCode, products) {
|
|
271
|
+
let res;
|
|
272
|
+
try {
|
|
273
|
+
res = await fetch(`${API_BASE}/api/device-auth/seed`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ deviceCode, products }),
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error('');
|
|
280
|
+
console.error(` Could not reach the seeding endpoint (${error.message}).`);
|
|
281
|
+
console.error(
|
|
282
|
+
' The request may or may not have landed, so do not claim either way: check the catalog'
|
|
283
|
+
);
|
|
284
|
+
console.error(
|
|
285
|
+
' in the dashboard and add what is missing there. Do not run connect again to retry.'
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
const message = await readError(res);
|
|
292
|
+
// A 400 from the route's own guard means the grant is gone. A 400 from the
|
|
293
|
+
// validation pipe means the controller never ran and the grant is intact —
|
|
294
|
+
// different facts, and telling the user the window closed when it has not
|
|
295
|
+
// is its own kind of wrong.
|
|
296
|
+
const windowClosed = message.startsWith('That seeding window has closed');
|
|
297
|
+
console.error('');
|
|
298
|
+
if (windowClosed) {
|
|
299
|
+
console.error(' The seeding window is closed — the grant was already spent, or more than');
|
|
300
|
+
console.error(' ten minutes passed since the approval. The connection itself is fine.');
|
|
301
|
+
console.error(' Add products in the dashboard; do not run connect again just to seed.');
|
|
302
|
+
} else {
|
|
303
|
+
console.error(` The seed request was refused before the grant was touched: ${message}`);
|
|
304
|
+
console.error(' The store is connected and still empty. Report this message as written and');
|
|
305
|
+
console.error(' add the products in the dashboard rather than retrying.');
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let body;
|
|
311
|
+
try {
|
|
312
|
+
body = await res.json();
|
|
313
|
+
} catch {
|
|
314
|
+
console.error('');
|
|
315
|
+
console.error(' The seed endpoint answered with something that is not JSON. Products may');
|
|
316
|
+
console.error(' have been created. Check the catalog in the dashboard rather than assuming');
|
|
317
|
+
console.error(' either outcome.');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const created = Array.isArray(body.created) ? body.created : [];
|
|
322
|
+
const failed = Array.isArray(body.failed) ? body.failed : [];
|
|
323
|
+
|
|
324
|
+
// Both halves, always. A per-product failure does not abort the rest, so a
|
|
325
|
+
// partial write is a normal outcome — and reporting one as a clean win is how
|
|
326
|
+
// a merchant finds three of five products missing a week later.
|
|
327
|
+
console.log('');
|
|
328
|
+
if (created.length > 0) {
|
|
329
|
+
console.log(` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(', ')}`);
|
|
330
|
+
}
|
|
331
|
+
for (const f of failed) {
|
|
332
|
+
console.log(` Not created: ${f.name} — ${f.reason}`);
|
|
333
|
+
}
|
|
334
|
+
if (failed.length > 0) {
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(
|
|
337
|
+
` ${created.length} of ${products.length} product(s) went in; ${failed.length} refused.`
|
|
338
|
+
);
|
|
339
|
+
console.log(
|
|
340
|
+
' Report BOTH halves by name. The window is now spent, so the refused ones have to be'
|
|
341
|
+
);
|
|
342
|
+
console.log(' added in the dashboard.');
|
|
343
|
+
} else if (created.length > 0) {
|
|
344
|
+
console.log(' Anything more comes from the dashboard, or Apps > Browse > Migration Tool.');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function main() {
|
|
349
|
+
const seedRaw = readSeedArg(process.argv.slice(2));
|
|
350
|
+
let seedProducts = null;
|
|
351
|
+
if (seedRaw !== null) {
|
|
352
|
+
const parsed = parseSeedProducts(seedRaw);
|
|
353
|
+
if (parsed.error) {
|
|
354
|
+
// Refuse before the approval, not after: a payload rejected on the other
|
|
355
|
+
// side of a click has already cost the user the click.
|
|
356
|
+
console.error(parsed.error);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
seedProducts = parsed.products;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (process.env.BRAINERCE_CONNECTION_ID) {
|
|
363
|
+
// Already known (CI, or a channel someone created by hand). Nothing to approve.
|
|
364
|
+
writeEnv(process.env.BRAINERCE_CONNECTION_ID.trim());
|
|
365
|
+
console.log('Wrote BRAINERCE_CONNECTION_ID from the environment into .env.local.');
|
|
366
|
+
if (seedProducts) {
|
|
367
|
+
// ⛔ Seeding is authenticated by the device code from an approval. There is
|
|
368
|
+
// no approval on this path, so there is nothing to seed with — say so
|
|
369
|
+
// rather than connecting and silently dropping the catalog.
|
|
370
|
+
console.error('');
|
|
371
|
+
console.error(' Seeding needs a browser approval and this run already had a connection id,');
|
|
372
|
+
console.error(' so no products were created. Add them in the dashboard, or in an agent');
|
|
373
|
+
console.error(' session use the Brainerce MCP server to create products directly.');
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let start;
|
|
379
|
+
try {
|
|
380
|
+
const res = await fetch(`${API_BASE}/api/device-auth/start`, {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
headers: { 'Content-Type': 'application/json' },
|
|
383
|
+
body: JSON.stringify({ clientName: process.env.BRAINERCE_CLIENT_NAME || 'this storefront' }),
|
|
384
|
+
});
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
console.error(`Could not start the approval (HTTP ${res.status}).`);
|
|
387
|
+
console.error('Create a sales channel in the dashboard, then set the id in .env.local.');
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
start = await res.json();
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error(`Could not reach Brainerce: ${error?.message ?? error}`);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const opened =
|
|
397
|
+
openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
|
|
398
|
+
console.log('');
|
|
399
|
+
console.log(
|
|
400
|
+
opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:'
|
|
401
|
+
);
|
|
402
|
+
console.log(` ${start.verificationUriComplete}`);
|
|
403
|
+
// The code is the only thing a person can actually check: it proves the
|
|
404
|
+
// request came from this run and not from someone else's.
|
|
405
|
+
console.log(` The page should show the code ${start.userCode}. Check that it matches.`);
|
|
406
|
+
console.log('');
|
|
407
|
+
console.log('Waiting for approval. A store and a sales channel are created if you have none.');
|
|
408
|
+
|
|
409
|
+
const deadline = Date.now() + start.expiresIn * 1000;
|
|
410
|
+
let interval = (start.interval || 5) * 1000;
|
|
411
|
+
|
|
412
|
+
while (Date.now() < deadline) {
|
|
413
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
414
|
+
let result;
|
|
415
|
+
try {
|
|
416
|
+
const res = await fetch(`${API_BASE}/api/device-auth/poll`, {
|
|
417
|
+
method: 'POST',
|
|
418
|
+
headers: { 'Content-Type': 'application/json' },
|
|
419
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
420
|
+
});
|
|
421
|
+
result = await res.json();
|
|
422
|
+
} catch {
|
|
423
|
+
continue; // A blip mid-poll is not fatal; the deadline is the real bound.
|
|
424
|
+
}
|
|
425
|
+
if (result.status === 'approved' && result.connectionId) {
|
|
426
|
+
// Write the id BEFORE seeding. Seeding is the part that can fail, and a
|
|
427
|
+
// failed catalog must never cost the connection that already succeeded.
|
|
428
|
+
writeEnv(result.connectionId);
|
|
429
|
+
console.log('');
|
|
430
|
+
console.log('Connected. The sales channel id is in .env.local.');
|
|
431
|
+
if (seedProducts) await runSeed(start.deviceCode, seedProducts);
|
|
432
|
+
console.log('');
|
|
433
|
+
console.log('Start the storefront with: npm run dev');
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (result.status === 'denied') {
|
|
437
|
+
console.error('The connection request was declined. Nothing was created.');
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
if (result.status === 'expired') {
|
|
441
|
+
console.error('The code expired before it was approved. Run this again.');
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
// Polled inside the interval: back off rather than hammering.
|
|
445
|
+
if (result.status === 'slow_down') interval += 1000;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
console.error('Timed out waiting for approval. Run this again for a fresh code.');
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
main();
|
|
@@ -31,6 +31,7 @@ import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
|
31
31
|
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
32
32
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
33
33
|
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
|
|
34
35
|
import { cn } from '@/core/lib/utils';
|
|
35
36
|
import { isValidCheckoutId } from '@/core/lib/safe-redirect';
|
|
36
37
|
import { trackBeginCheckout } from '@/core/lib/tracking';
|
|
@@ -52,6 +53,7 @@ function CheckoutContent() {
|
|
|
52
53
|
const t = useTranslations('checkout');
|
|
53
54
|
const tc = useTranslations('common');
|
|
54
55
|
const tr = useTranslations('reservation');
|
|
56
|
+
const tp = useTranslations('productDetail');
|
|
55
57
|
|
|
56
58
|
const [step, setStep] = useState<CheckoutStep>('address');
|
|
57
59
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
@@ -77,6 +79,7 @@ function CheckoutContent() {
|
|
|
77
79
|
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
78
80
|
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
79
81
|
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
82
|
+
const [bumpError, setBumpError] = useState<AddToCartError | null>(null);
|
|
80
83
|
const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
|
|
81
84
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
82
85
|
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
@@ -354,6 +357,7 @@ function CheckoutContent() {
|
|
|
354
357
|
// Handle bump toggle
|
|
355
358
|
async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
|
|
356
359
|
if (!cart?.id || bumpLoading) return;
|
|
360
|
+
setBumpError(null);
|
|
357
361
|
try {
|
|
358
362
|
setBumpLoading(bumpId);
|
|
359
363
|
const client = getClient();
|
|
@@ -370,6 +374,10 @@ function CheckoutContent() {
|
|
|
370
374
|
}
|
|
371
375
|
await refreshCart();
|
|
372
376
|
} catch (err) {
|
|
377
|
+
// A bump is an extra cart line, so it can be refused by the 50-line cap
|
|
378
|
+
// like any other add. Logging alone left the checkbox springing back with
|
|
379
|
+
// no explanation, at the least forgiving point in the flow.
|
|
380
|
+
setBumpError(toAddToCartError(err));
|
|
373
381
|
console.error('Failed to toggle order bump:', err);
|
|
374
382
|
} finally {
|
|
375
383
|
setBumpLoading(null);
|
|
@@ -1044,6 +1052,16 @@ function CheckoutContent() {
|
|
|
1044
1052
|
loading={bumpLoading === bump.id}
|
|
1045
1053
|
/>
|
|
1046
1054
|
))}
|
|
1055
|
+
|
|
1056
|
+
{/*
|
|
1057
|
+
Why the bump was refused. Without it the checkbox just springs
|
|
1058
|
+
back and the shopper is left guessing at checkout.
|
|
1059
|
+
*/}
|
|
1060
|
+
{bumpError && (
|
|
1061
|
+
<p className="text-destructive text-xs" role="alert">
|
|
1062
|
+
{bumpError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
|
|
1063
|
+
</p>
|
|
1064
|
+
)}
|
|
1047
1065
|
</div>
|
|
1048
1066
|
)}
|
|
1049
1067
|
|