create-brainerce-store 1.84.0 → 1.85.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.
Files changed (42) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +82 -11
  21. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  22. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  23. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  24. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  25. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  26. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  27. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  28. package/templates/nextjs/designs/atelier/globals.css +408 -402
  29. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  30. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  31. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  34. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  35. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  36. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  37. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  38. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  39. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  40. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  41. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  42. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
@@ -32,11 +32,16 @@
32
32
 
33
33
  import { readFileSync, writeFileSync, existsSync } from 'node:fs';
34
34
  import { spawn } from 'node:child_process';
35
- import { resolve } from 'node:path';
35
+ import { dirname, resolve } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { setVar } from './env-file.mjs';
36
38
 
37
39
  const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
38
40
  const ENV_PATH = resolve(process.cwd(), '.env.local');
39
41
  const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
42
+ /** `setup`, resolved next to THIS file rather than from cwd, so it is found
43
+ * however the script was invoked. */
44
+ const FETCH_STORE_INFO = resolve(dirname(fileURLToPath(import.meta.url)), 'fetch-store-info.mjs');
40
45
 
41
46
  // The seed route's own limits, mirrored here so a bad payload is refused before
42
47
  // the grant is touched.
@@ -233,15 +238,68 @@ function openBrowser(url) {
233
238
  }
234
239
 
235
240
  /** 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. */
241
+ * would silently drop a payment key someone pasted in an hour earlier.
242
+ *
243
+ * Written through the same `setVar` as `setup`, so every line this project's
244
+ * scripts produce has one shape. A `vc_*` id is plain ASCII and would be safe
245
+ * bare, but two writers with two formats is how `setup` came to read the
246
+ * scaffolder's quotes as part of the URL. */
237
247
  function writeEnv(connectionId) {
238
248
  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`;
249
+ for (const key of KEYS) body = setVar(body, key, connectionId);
250
+ writeFileSync(ENV_PATH, body);
251
+ }
252
+
253
+ /**
254
+ * Pull the store's real name and currency into .env.local — the same thing
255
+ * `npm run setup` does, run as a child so a failure there cannot take this
256
+ * process (and the connection it just wrote) down with it. The script prints
257
+ * its own diagnostics; only the outcome is needed here.
258
+ *
259
+ * Resolves to true when the values were written. Never throws, never hangs:
260
+ * the child's one network call carries its own timeout.
261
+ */
262
+ function refreshStoreInfo() {
263
+ return new Promise((done) => {
264
+ let child;
265
+ try {
266
+ child = spawn(process.execPath, [FETCH_STORE_INFO], { stdio: 'inherit' });
267
+ } catch {
268
+ done(false);
269
+ return;
270
+ }
271
+ child.on('error', () => done(false));
272
+ child.on('exit', (code) => done(code === 0));
273
+ });
274
+ }
275
+
276
+ /**
277
+ * The part people miss. NEXT_PUBLIC_* values are inlined into the bundle when
278
+ * `next build` runs, so a build made before this connection carries the
279
+ * placeholder name and USD for good, whatever .env.local says now. A store
280
+ * scaffolded with `--defer-connection` shipped as "storefront" — the directory
281
+ * name, in the title, the Open Graph card and Google's Organization entity —
282
+ * for exactly this reason. Said unmissably, every time, on both outcomes.
283
+ */
284
+ function printRebuildNotice(refreshed) {
285
+ console.log('');
286
+ if (refreshed) {
287
+ console.log(' The store name and currency are now in .env.local.');
288
+ } else {
289
+ console.error(' ⛔ The store name and currency could NOT be fetched (see above). The connection');
290
+ console.error(' id itself is written. Until this succeeds the storefront calls itself');
291
+ console.error(' by its scaffold-time placeholder name. Run this once the API is reachable:');
292
+ console.error('');
293
+ console.error(' npm run setup');
243
294
  }
244
- writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
295
+ console.log('');
296
+ console.log(' ⛔ REBUILD REQUIRED. NEXT_PUBLIC_* values are baked into the bundle at build');
297
+ console.log(' time, so anything built before this point still carries the placeholder');
298
+ console.log(' name and currency. Before deploying run:');
299
+ console.log('');
300
+ console.log(' npm run build');
301
+ console.log('');
302
+ console.log(' and restart any running `npm run dev` so it re-reads .env.local.');
245
303
  }
246
304
 
247
305
  async function readError(res) {
@@ -321,12 +379,41 @@ async function runSeed(deviceCode, products) {
321
379
  const created = Array.isArray(body.created) ? body.created : [];
322
380
  const failed = Array.isArray(body.failed) ? body.failed : [];
323
381
 
382
+ // Three outcomes, not two. `created` is every product that EXISTS; since the
383
+ // seed route started publishing to the channel it also says, per product,
384
+ // whether that publish landed (`published: boolean`, with `publishError`
385
+ // when it did not). A product that exists but is not on this channel is
386
+ // invisible on the storefront and equally invisible in a "Seeded N" line,
387
+ // which is the same false success the created/failed split already guards
388
+ // against. Older backends omit `published`; then only the two halves exist.
389
+ const reportsPublish = created.some((p) => typeof p.published === 'boolean');
390
+ const live = reportsPublish ? created.filter((p) => p.published !== false) : created;
391
+ const unpublished = reportsPublish ? created.filter((p) => p.published === false) : [];
392
+
324
393
  // Both halves, always. A per-product failure does not abort the rest, so a
325
394
  // partial write is a normal outcome — and reporting one as a clean win is how
326
395
  // a merchant finds three of five products missing a week later.
327
396
  console.log('');
328
- if (created.length > 0) {
329
- console.log(` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(', ')}`);
397
+ if (live.length > 0) {
398
+ console.log(
399
+ ` Seeded${reportsPublish ? ' and published' : ''} ${live.length} product(s): ${live
400
+ .map((p) => p.name)
401
+ .join(', ')}`
402
+ );
403
+ }
404
+ for (const p of unpublished) {
405
+ console.log(
406
+ ` Created but NOT published to this sales channel: ${p.name} — ${
407
+ p.publishError || 'the publish did not land'
408
+ }`
409
+ );
410
+ }
411
+ if (unpublished.length > 0) {
412
+ console.log(
413
+ ` ${unpublished.length} product(s) exist in the store but will not show on the storefront.`
414
+ );
415
+ console.log(' Do NOT re-add them. Publish them from the dashboard: Products > the product >');
416
+ console.log(' sales channels > toggle this channel on.');
330
417
  }
331
418
  for (const f of failed) {
332
419
  console.log(` Not created: ${f.name} — ${f.reason}`);
@@ -345,6 +432,7 @@ async function runSeed(deviceCode, products) {
345
432
  }
346
433
  }
347
434
 
435
+ /** Returns the process exit code. */
348
436
  async function main() {
349
437
  const seedRaw = readSeedArg(process.argv.slice(2));
350
438
  let seedProducts = null;
@@ -354,7 +442,7 @@ async function main() {
354
442
  // Refuse before the approval, not after: a payload rejected on the other
355
443
  // side of a click has already cost the user the click.
356
444
  console.error(parsed.error);
357
- process.exit(1);
445
+ return 1;
358
446
  }
359
447
  seedProducts = parsed.products;
360
448
  }
@@ -372,7 +460,8 @@ async function main() {
372
460
  console.error(' so no products were created. Add them in the dashboard, or in an agent');
373
461
  console.error(' session use the Brainerce MCP server to create products directly.');
374
462
  }
375
- return;
463
+ printRebuildNotice(await refreshStoreInfo());
464
+ return 0;
376
465
  }
377
466
 
378
467
  let start;
@@ -385,12 +474,12 @@ async function main() {
385
474
  if (!res.ok) {
386
475
  console.error(`Could not start the approval (HTTP ${res.status}).`);
387
476
  console.error('Create a sales channel in the dashboard, then set the id in .env.local.');
388
- process.exit(1);
477
+ return 1;
389
478
  }
390
479
  start = await res.json();
391
480
  } catch (error) {
392
481
  console.error(`Could not reach Brainerce: ${error?.message ?? error}`);
393
- process.exit(1);
482
+ return 1;
394
483
  }
395
484
 
396
485
  const opened =
@@ -429,24 +518,32 @@ async function main() {
429
518
  console.log('');
430
519
  console.log('Connected. The sales channel id is in .env.local.');
431
520
  if (seedProducts) await runSeed(start.deviceCode, seedProducts);
521
+ // The scaffold guessed the name (the directory) and the currency (USD);
522
+ // this is where the real ones arrive. Runs AFTER seeding so a slow or
523
+ // failed fetch cannot eat the ten-minute seeding window.
524
+ console.log('');
525
+ printRebuildNotice(await refreshStoreInfo());
432
526
  console.log('');
433
527
  console.log('Start the storefront with: npm run dev');
434
- return;
528
+ return 0;
435
529
  }
436
530
  if (result.status === 'denied') {
437
531
  console.error('The connection request was declined. Nothing was created.');
438
- process.exit(1);
532
+ return 1;
439
533
  }
440
534
  if (result.status === 'expired') {
441
535
  console.error('The code expired before it was approved. Run this again.');
442
- process.exit(1);
536
+ return 1;
443
537
  }
444
538
  // Polled inside the interval: back off rather than hammering.
445
539
  if (result.status === 'slow_down') interval += 1000;
446
540
  }
447
541
 
448
542
  console.error('Timed out waiting for approval. Run this again for a fresh code.');
449
- process.exit(1);
543
+ return 1;
450
544
  }
451
545
 
452
- main();
546
+ // `process.exitCode`, never `process.exit()`: on Node 24 / Windows an exit
547
+ // called right after a `fetch` response trips a libuv assertion
548
+ // (`UV_HANDLE_CLOSING`, exit 127) in place of the intended code.
549
+ process.exitCode = await main();
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Read and write single values in `.env.local` the way Next.js reads them.
3
+ *
4
+ * ⛔ `toEnvLiteral` MIRRORS `toEnvLiteral` in the scaffolder
5
+ * (`packages/create-brainerce-store/src/scaffold.ts`), which writes the
6
+ * initial `.env.local`, and `parseEnvLiteral` is its exact inverse. The three
7
+ * must move together: the scaffolder, `npm run setup` and `npm run connect`
8
+ * all write this file, and Next.js reads it, so they have to agree on what a
9
+ * value looks like. This used to be broken in both directions — the scaffold
10
+ * wrote `KEY="value"` and `setup` read the quotes as part of the value
11
+ * (`"https://api.brainerce.com"/api/vc/...` → "Failed to parse URL"), while
12
+ * `setup` wrote values bare, so a name with a `#` or a `$` in it was cut off
13
+ * or expanded.
14
+ *
15
+ * What the decoder actually does. Next.js loads `.env.local` through the
16
+ * dotenv 16 bundled inside `@next/env`, plus dotenv-expand, and that parser
17
+ * does LESS than people assume (verified against the bundled source and by
18
+ * loading a probe file through `loadEnvConfig`):
19
+ * - one layer of matching quotes (`"…"`, `'…'`, `` `…` ``) is stripped;
20
+ * - inside DOUBLE quotes only `\n` and `\r` are unescaped;
21
+ * - `\"` and `\\` are NOT unescaped in any form — the backslash stays;
22
+ * - dotenv-expand turns `\$` into `$` in every form and expands a bare
23
+ * `$VAR`, so `$` is the one character that must always be escaped;
24
+ * - an unquoted value ends at the first `#` and is trimmed.
25
+ */
26
+
27
+ /** Control chars never belong in a store name and would break the
28
+ * KEY=VALUE line structure even inside quotes. */
29
+ function stripControlChars(value) {
30
+ return String(value).replace(/\p{Cc}/gu, '');
31
+ }
32
+
33
+ /**
34
+ * Encode `value` so that Next.js reads back exactly `value`.
35
+ *
36
+ * Escape `$`, then pick the quote character the value does not contain:
37
+ * double quotes by default (refused when the value carries a literal `\n` /
38
+ * `\r` sequence, which double quotes would turn into a real newline), single
39
+ * quotes next, backticks last. A value containing all three quote characters
40
+ * cannot be represented losslessly; its backticks are dropped rather than
41
+ * failing the whole setup on a store name.
42
+ */
43
+ export function toEnvLiteral(value) {
44
+ const clean = stripControlChars(value).replace(/\$/g, '\\$');
45
+ if (!clean.includes('"') && !/\\[nr]/.test(clean)) return `"${clean}"`;
46
+ if (!clean.includes("'")) return `'${clean}'`;
47
+ return `\`${clean.replace(/`/g, '')}\``;
48
+ }
49
+
50
+ /**
51
+ * Decode the right-hand side of a `KEY=...` line exactly as `@next/env` does.
52
+ *
53
+ * The regex is dotenv's own value group: a quoted form is only accepted when
54
+ * nothing but whitespace or a `# comment` follows the closing quote, otherwise
55
+ * the whole thing is an unquoted value that ends at the first `#`. Then the
56
+ * same post-processing: trim, strip one layer of matching quotes, unescape
57
+ * `\n` / `\r` for double quotes only, and turn `\$` back into `$`.
58
+ * `$VAR` expansion itself is not replicated: `toEnvLiteral` escapes every `$`,
59
+ * so nothing these scripts write ever expands.
60
+ */
61
+ export function parseEnvLiteral(raw) {
62
+ const match = String(raw).match(
63
+ /^(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$/
64
+ );
65
+ let value = ((match && match[1]) || '').trim();
66
+ const quote = value[0];
67
+ value = value.replace(/^(['"`])([\s\S]*)\1$/, '$2');
68
+ if (quote === '"') value = value.replace(/\\n/g, '\n').replace(/\\r/g, '\r');
69
+ return value.replace(/\\\$/g, '$');
70
+ }
71
+
72
+ /** The decoded value of `key` in `content`, or `null` when the line is absent. */
73
+ export function getVar(content, key) {
74
+ const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
75
+ return match ? parseEnvLiteral(match[1]) : null;
76
+ }
77
+
78
+ /**
79
+ * Set `key` to `value` in `content`, rewriting the existing line in place so
80
+ * hand-added variables and comments around it survive, or appending it. The
81
+ * value is always written through `toEnvLiteral`.
82
+ */
83
+ export function setVar(content, key, value) {
84
+ const line = `${key}=${toEnvLiteral(value)}`;
85
+ const regex = new RegExp(`^${key}=.*$`, 'm');
86
+ if (regex.test(content)) {
87
+ // Replacer FUNCTION, not string: a store name containing `$&` or `$1`
88
+ // would otherwise be interpreted as a replacement pattern.
89
+ return content.replace(regex, () => line);
90
+ }
91
+ const body = content.trimEnd();
92
+ return body ? `${body}\n${line}\n` : `${line}\n`;
93
+ }
@@ -1,104 +1,130 @@
1
1
  /* eslint-disable no-console */
2
2
  /**
3
- * Setup script: fetches store info from Brainerce using the connection ID
4
- * and saves NEXT_PUBLIC_STORE_NAME (and other public fields) to .env.local.
3
+ * Setup script: fetches store info from Brainerce using the sales channel id
4
+ * and saves NEXT_PUBLIC_STORE_NAME and NEXT_PUBLIC_STORE_CURRENCY to .env.local.
5
5
  *
6
- * Run: node scripts/fetch-store-info.mjs
6
+ * Run: node scripts/fetch-store-info.mjs (npm run setup)
7
+ *
8
+ * `npm run connect` runs this automatically after writing the channel id, so
9
+ * a store scaffolded with `--defer-connection` stops calling itself by its
10
+ * directory name the moment it is connected. Run it again whenever the store
11
+ * is renamed or changes currency in the dashboard.
12
+ *
13
+ * ⛔ NEXT_PUBLIC_* values are inlined into the bundle at BUILD time. Writing
14
+ * them here changes nothing that is already built: rebuild (`npm run build`)
15
+ * before deploying, and restart a running dev server. The storefront prefers
16
+ * the live store name and currency from the API wherever it can (server
17
+ * components, metadata, JSON-LD); these values are the fallback the client
18
+ * bundle carries for the moments before that data arrives.
19
+ *
20
+ * Values are read and written through ./env-file.mjs, which mirrors the
21
+ * scaffolder's own encoding — see that file before changing how a line looks.
22
+ *
23
+ * Exits through `process.exitCode`, never `process.exit()`: on Node 24 /
24
+ * Windows an exit called right after a `fetch` response trips a libuv
25
+ * assertion (`UV_HANDLE_CLOSING`, exit 127) instead of the intended code.
7
26
  */
8
27
 
9
- import { readFileSync, writeFileSync, existsSync } from 'fs';
10
- import { join } from 'path';
28
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+ import { getVar, setVar } from './env-file.mjs';
11
31
 
12
32
  const envPath = join(process.cwd(), '.env.local');
13
33
 
14
- if (!existsSync(envPath)) {
15
- console.error(
16
- '❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID set.'
17
- );
18
- process.exit(1);
19
- }
34
+ /** Returns the process exit code. */
35
+ async function main() {
36
+ if (!existsSync(envPath)) {
37
+ console.error(
38
+ '❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID set.'
39
+ );
40
+ return 1;
41
+ }
20
42
 
21
- const envContent = readFileSync(envPath, 'utf-8');
43
+ const envContent = readFileSync(envPath, 'utf-8');
22
44
 
23
- function getVar(content, key) {
24
- const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
25
- return match ? match[1].trim() : null;
26
- }
45
+ // Read either env var name. The new one is preferred; the old one is a soft
46
+ // alias kept for backwards compatibility — the SDK accepts both.
47
+ const connectionId =
48
+ getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID') ||
49
+ getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
50
+ const apiUrl = (
51
+ process.env.BRAINERCE_API_URL ||
52
+ getVar(envContent, 'BRAINERCE_API_URL') ||
53
+ 'https://api.brainerce.com'
54
+ ).replace(/\/$/, '');
27
55
 
28
- function setVar(content, key, value) {
29
- const regex = new RegExp(`^${key}=.*$`, 'm');
30
- if (regex.test(content)) {
31
- return content.replace(regex, `${key}=${value}`);
56
+ if (!connectionId) {
57
+ console.error('❌ NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID is not set in .env.local');
58
+ return 1;
59
+ }
60
+ if (connectionId.startsWith('vc_REPLACE_ME')) {
61
+ console.error(
62
+ '❌ .env.local still holds the placeholder id from --defer-connection. Run `npm run connect` first.'
63
+ );
64
+ return 1;
32
65
  }
33
- return content.trimEnd() + `\n${key}=${value}\n`;
34
- }
35
66
 
36
- // Read either env var name. The new one is preferred; the old one is a soft
37
- // alias kept for backwards compatibility — the SDK accepts both.
38
- const connectionId =
39
- getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID') ||
40
- getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
41
- const apiUrl = (getVar(envContent, 'BRAINERCE_API_URL') || 'https://api.brainerce.com').replace(
42
- /\/$/,
43
- ''
44
- );
67
+ console.log(`Fetching store info for sales channel: ${connectionId} ...`);
45
68
 
46
- if (!connectionId) {
47
- console.error('❌ NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID is not set in .env.local');
48
- process.exit(1);
49
- }
69
+ let storeInfo;
70
+ try {
71
+ // /api/vc/* enforces an Origin check: TEST channels accept local/tunnel
72
+ // hosts, LIVE channels require the configured domain. Prefer an explicitly
73
+ // configured site URL (the real domain for a LIVE store); localhost is only
74
+ // a last resort, and it is correct here in a way it is NOT correct at
75
+ // runtime — this script runs on the developer's own machine, not on the
76
+ // host the storefront is deployed to.
77
+ const siteUrl =
78
+ process.env.SITE_URL ||
79
+ getVar(envContent, 'SITE_URL') ||
80
+ process.env.NEXT_PUBLIC_SITE_URL ||
81
+ getVar(envContent, 'NEXT_PUBLIC_SITE_URL') ||
82
+ 'http://localhost:3000';
83
+ // Bounded: `npm run connect` runs this in agent shells with no TTY, where a
84
+ // hung request never gets a Ctrl-C.
85
+ const res = await fetch(`${apiUrl}/api/vc/${encodeURIComponent(connectionId)}/info`, {
86
+ headers: { Origin: siteUrl },
87
+ signal: AbortSignal.timeout(20_000),
88
+ });
89
+ if (!res.ok) {
90
+ console.error(`❌ API returned ${res.status}: ${await res.text()}`);
91
+ return 1;
92
+ }
93
+ storeInfo = await res.json();
94
+ } catch (err) {
95
+ console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
96
+ return 1;
97
+ }
50
98
 
51
- console.log(`Fetching store info for sales channel: ${connectionId} ...`);
99
+ const name = storeInfo.name;
100
+ const currency = storeInfo.currency;
52
101
 
53
- let storeInfo;
54
- try {
55
- // /api/vc/* enforces an Origin check: TEST channels accept local/tunnel
56
- // hosts, LIVE channels require the configured domain. Prefer an explicitly
57
- // configured site URL (the real domain for a LIVE store); localhost is only
58
- // a last resort, and it is correct here in a way it is NOT correct at
59
- // runtime — this script runs on the developer's own machine, not on the
60
- // host the storefront is deployed to.
61
- const siteUrl =
62
- process.env.SITE_URL ||
63
- getVar(envContent, 'SITE_URL') ||
64
- process.env.NEXT_PUBLIC_SITE_URL ||
65
- getVar(envContent, 'NEXT_PUBLIC_SITE_URL') ||
66
- 'http://localhost:3000';
67
- const res = await fetch(`${apiUrl}/api/vc/${connectionId}/info`, {
68
- headers: { Origin: siteUrl },
69
- });
70
- if (!res.ok) {
71
- console.error(`❌ API returned ${res.status}: ${await res.text()}`);
72
- process.exit(1);
102
+ if (!name) {
103
+ console.error('❌ Store info response has no `name` field:', storeInfo);
104
+ return 1;
105
+ }
106
+ // Currency is NOT NULL in the backend schema — a response without it
107
+ // is a real backend bug, not a "use the default" signal. Fail loud rather
108
+ // than silently leaving the env stale (which would bake the previous
109
+ // value into the next client bundle build).
110
+ if (!currency) {
111
+ console.error('❌ Store info response has no `currency` field:', storeInfo);
112
+ return 1;
73
113
  }
74
- storeInfo = await res.json();
75
- } catch (err) {
76
- console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
77
- process.exit(1);
78
- }
79
-
80
- const name = storeInfo.name;
81
- const currency = storeInfo.currency;
82
114
 
83
- if (!name) {
84
- console.error('❌ Store info response has no `name` field:', storeInfo);
85
- process.exit(1);
86
- }
87
- // Currency is NOT NULL in the backend schema — a response without it
88
- // is a real backend bug, not a "use the default" signal. Fail loud rather
89
- // than silently leaving the env stale (which would bake the previous
90
- // value into the next client bundle build).
91
- if (!currency) {
92
- console.error('❌ Store info response has no `currency` field:', storeInfo);
93
- process.exit(1);
94
- }
115
+ let updated = envContent;
116
+ updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
117
+ updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
95
118
 
96
- let updated = envContent;
97
- updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
98
- updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
119
+ writeFileSync(envPath, updated, 'utf-8');
99
120
 
100
- writeFileSync(envPath, updated, 'utf-8');
121
+ console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
122
+ console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
123
+ console.log('');
124
+ console.log('Done. These are NEXT_PUBLIC_* values, inlined at build time:');
125
+ console.log(' - restart the dev server (`npm run dev`) to see them locally;');
126
+ console.log(' - run `npm run build` before deploying, or the deployed bundle keeps the old ones.');
127
+ return 0;
128
+ }
101
129
 
102
- console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
103
- console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
104
- console.log('Done. Restart the dev server for changes to take effect.');
130
+ process.exitCode = await main();
@@ -9,7 +9,8 @@ function isSecure(): boolean {
9
9
 
10
10
  /**
11
11
  * Password-reset callback handler.
12
- * The email link redirects here with ?token=... from the backend.
12
+ * The email link lands on /reset-password?token=..., whose server component
13
+ * redirects here so the token can be moved into a cookie.
13
14
  * We store the token in an httpOnly cookie and redirect to /reset-password (clean URL).
14
15
  * This mirrors the OAuth callback pattern — the token never reaches client JS.
15
16
  */
@@ -21,8 +21,9 @@ export default function ForgotPasswordPage() {
21
21
  setLoading(true);
22
22
  setError(null);
23
23
  const client = getClient();
24
- const resetUrl = `${window.location.origin}/api/auth/reset-callback`;
25
- await client.forgotPassword(email, { resetUrl });
24
+ // The reset link's host is chosen by the server (the channel's domain),
25
+ // never by this page; the email lands on /reset-password?token=...
26
+ await client.forgotPassword(email);
26
27
  setSent(true);
27
28
  } catch (err) {
28
29
  const message =