create-brainerce-store 1.43.1 → 1.43.3

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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.43.1",
34
+ version: "1.43.3",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
@@ -172,25 +172,33 @@ var ALLOWED_PACKAGE_MANAGERS = [
172
172
  "bun"
173
173
  ];
174
174
  var BRAINERCE_RUNTIME_DEPS = Object.freeze({
175
- // 1.27 = first published cut with client.content namespace (FAQ / Footer /
176
- // Header / Announcement / RichText / Page) + getDirectionForLocale helper.
177
- // Scaffolded stores need this minimum to use the content components shipped
178
- // under src/components/content/ and the homepage RichTextBlock mount-point.
179
- // 1.26 had the namespace in source but was published before the cut.
180
- brainerce: "^1.27.0",
175
+ // 1.28 = first cut after the PriceList API removal + the FX-driven display
176
+ // pricing rework (Product.displayPrice / displayCurrency / displaySalePrice
177
+ // attached additively when getProducts is called with a regionId). Older
178
+ // 1.27 had PriceList/Resolved* surface that no longer exists on the
179
+ // backend, so scaffolded stores must pin >=1.28 to compile.
180
+ brainerce: "^1.28.0",
181
181
  "isomorphic-dompurify": "^3.8.0"
182
182
  });
183
183
 
184
184
  // ../cli-shared/src/deps.ts
185
+ var SAFE_INSTALL_TOKEN = /^[@a-zA-Z0-9._/^~*+=:-]+$/;
185
186
  async function installDependencies(projectDir, pkgManager, opts = {}) {
186
187
  if (!ALLOWED_PACKAGE_MANAGERS.includes(pkgManager)) {
187
188
  throw new Error(`Unsupported package manager: ${pkgManager}`);
188
189
  }
189
190
  const subcommand = opts.subcommand ?? (pkgManager === "yarn" ? "" : "install");
190
191
  const extraArgs = opts.args ?? [];
192
+ const isWindows = process.platform === "win32";
193
+ const argTokens = [subcommand, ...extraArgs].filter((t) => t !== "");
194
+ if (isWindows) {
195
+ for (const token of argTokens) {
196
+ if (!SAFE_INSTALL_TOKEN.test(token)) {
197
+ throw new Error(`Unsafe install argument: ${token}`);
198
+ }
199
+ }
200
+ }
191
201
  return new Promise((resolve, reject) => {
192
- const isWindows = process.platform === "win32";
193
- const argTokens = [subcommand, ...extraArgs].filter((t) => t !== "");
194
202
  const child = isWindows ? (0, import_child_process.spawn)(`${pkgManager} ${argTokens.join(" ")}`.trim(), {
195
203
  cwd: projectDir,
196
204
  stdio: ["ignore", "ignore", "pipe"],
@@ -555,7 +563,10 @@ async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production)
555
563
  const timeout = setTimeout(() => controller.abort(), 1e4);
556
564
  let res;
557
565
  try {
558
- res = await fetch(url, { signal: controller.signal });
566
+ res = await fetch(url, {
567
+ signal: controller.signal,
568
+ headers: { Origin: "http://localhost" }
569
+ });
559
570
  } catch (err) {
560
571
  if (err.name === "AbortError") {
561
572
  throw new Error(`Request to ${baseUrl} timed out`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.43.1",
3
+ "version": "1.43.3",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -1,91 +1,97 @@
1
- /* global process, console, fetch */
2
- /* eslint-disable no-console */
3
- /**
4
- * Setup script: fetches store info from Brainerce using the connection ID
5
- * and saves NEXT_PUBLIC_STORE_NAME (and other public fields) to .env.local.
6
- *
7
- * Run: node scripts/fetch-store-info.mjs
8
- */
9
-
10
- import { readFileSync, writeFileSync, existsSync } from 'fs';
11
- import { join } from 'path';
12
-
13
- const envPath = join(process.cwd(), '.env.local');
14
-
15
- if (!existsSync(envPath)) {
16
- console.error(
17
- '❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID set.'
18
- );
19
- process.exit(1);
20
- }
21
-
22
- const envContent = readFileSync(envPath, 'utf-8');
23
-
24
- function getVar(content, key) {
25
- const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
26
- return match ? match[1].trim() : null;
27
- }
28
-
29
- function setVar(content, key, value) {
30
- const regex = new RegExp(`^${key}=.*$`, 'm');
31
- if (regex.test(content)) {
32
- return content.replace(regex, `${key}=${value}`);
33
- }
34
- return content.trimEnd() + `\n${key}=${value}\n`;
35
- }
36
-
37
- // Read either env var name. The new one is preferred; the old one is a soft
38
- // alias kept for backwards compatibility — the SDK accepts both.
39
- const connectionId =
40
- getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID') ||
41
- getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
42
- const apiUrl = (getVar(envContent, 'BRAINERCE_API_URL') || 'https://api.brainerce.com').replace(
43
- /\/$/,
44
- ''
45
- );
46
-
47
- if (!connectionId) {
48
- console.error('❌ NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID is not set in .env.local');
49
- process.exit(1);
50
- }
51
-
52
- console.log(`Fetching store info for sales channel: ${connectionId} ...`);
53
-
54
- let storeInfo;
55
- try {
56
- const res = await fetch(`${apiUrl}/api/vc/${connectionId}/info`);
57
- if (!res.ok) {
58
- console.error(`❌ API returned ${res.status}: ${await res.text()}`);
59
- process.exit(1);
60
- }
61
- storeInfo = await res.json();
62
- } catch (err) {
63
- console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
64
- process.exit(1);
65
- }
66
-
67
- const name = storeInfo.name;
68
- const currency = storeInfo.currency;
69
-
70
- if (!name) {
71
- console.error('❌ Store info response has no `name` field:', storeInfo);
72
- process.exit(1);
73
- }
74
- // Currency is NOT NULL in the backend schema — a response without it
75
- // is a real backend bug, not a "use the default" signal. Fail loud rather
76
- // than silently leaving the env stale (which would bake the previous
77
- // value into the next client bundle build).
78
- if (!currency) {
79
- console.error('❌ Store info response has no `currency` field:', storeInfo);
80
- process.exit(1);
81
- }
82
-
83
- let updated = envContent;
84
- updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
85
- updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
86
-
87
- writeFileSync(envPath, updated, 'utf-8');
88
-
89
- console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
90
- console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
91
- console.log('Done. Restart the dev server for changes to take effect.');
1
+ /* eslint-disable no-console */
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.
5
+ *
6
+ * Run: node scripts/fetch-store-info.mjs
7
+ */
8
+
9
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
10
+ import { join } from 'path';
11
+
12
+ const envPath = join(process.cwd(), '.env.local');
13
+
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
+ }
20
+
21
+ const envContent = readFileSync(envPath, 'utf-8');
22
+
23
+ function getVar(content, key) {
24
+ const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
25
+ return match ? match[1].trim() : null;
26
+ }
27
+
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}`);
32
+ }
33
+ return content.trimEnd() + `\n${key}=${value}\n`;
34
+ }
35
+
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
+ );
45
+
46
+ if (!connectionId) {
47
+ console.error('❌ NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID is not set in .env.local');
48
+ process.exit(1);
49
+ }
50
+
51
+ console.log(`Fetching store info for sales channel: ${connectionId} ...`);
52
+
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 the project's
57
+ // own NEXT_PUBLIC_SITE_URL (the real domain for a LIVE store), falling back
58
+ // to localhost for local dev against a TEST channel.
59
+ const siteUrl = getVar(envContent, 'NEXT_PUBLIC_SITE_URL') || 'http://localhost:3000';
60
+ const res = await fetch(`${apiUrl}/api/vc/${connectionId}/info`, {
61
+ headers: { Origin: siteUrl },
62
+ });
63
+ if (!res.ok) {
64
+ console.error(`❌ API returned ${res.status}: ${await res.text()}`);
65
+ process.exit(1);
66
+ }
67
+ storeInfo = await res.json();
68
+ } catch (err) {
69
+ console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
70
+ process.exit(1);
71
+ }
72
+
73
+ const name = storeInfo.name;
74
+ const currency = storeInfo.currency;
75
+
76
+ if (!name) {
77
+ console.error('❌ Store info response has no `name` field:', storeInfo);
78
+ process.exit(1);
79
+ }
80
+ // Currency is NOT NULL in the backend schema — a response without it
81
+ // is a real backend bug, not a "use the default" signal. Fail loud rather
82
+ // than silently leaving the env stale (which would bake the previous
83
+ // value into the next client bundle build).
84
+ if (!currency) {
85
+ console.error('❌ Store info response has no `currency` field:', storeInfo);
86
+ process.exit(1);
87
+ }
88
+
89
+ let updated = envContent;
90
+ updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
91
+ updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
92
+
93
+ writeFileSync(envPath, updated, 'utf-8');
94
+
95
+ console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
96
+ console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
97
+ console.log('Done. Restart the dev server for changes to take effect.');
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Auth-aware account control for the site header.
5
+ *
6
+ * The header itself is a server component fed by merchant-configured Content,
7
+ * so it can't read client auth state. This small client island bridges that:
8
+ * it links to `/account` when the visitor is signed in (the account page shows
9
+ * profile + orders + sign-out) and to `/login` otherwise.
10
+ *
11
+ * The label/icon are i18n-driven via the `nav` namespace (`login` / `account`)
12
+ * so RTL + translated stores stay consistent. Text is hidden on mobile to match
13
+ * the cart icon's icon-only treatment; the icon + aria-label keep it accessible.
14
+ */
15
+ import * as React from 'react';
16
+ import { Link } from '@/lib/navigation';
17
+ import { useAuth } from '@/providers/store-provider';
18
+ import { useTranslations } from '@/lib/translations';
19
+
20
+ const UserIcon = (props: React.SVGProps<SVGSVGElement>) => (
21
+ <svg
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ strokeWidth={2}
26
+ strokeLinecap="round"
27
+ strokeLinejoin="round"
28
+ aria-hidden="true"
29
+ {...props}
30
+ >
31
+ <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
32
+ <circle cx="12" cy="7" r="4" />
33
+ </svg>
34
+ );
35
+
36
+ export function HeaderAccount() {
37
+ const { isLoggedIn } = useAuth();
38
+ const t = useTranslations('nav');
39
+
40
+ // Guests land on /login; signed-in visitors go to /account (which itself
41
+ // bounces guests back to /login, so the link is safe even mid auth-resolve).
42
+ const href = isLoggedIn ? '/account' : '/login';
43
+ const label = isLoggedIn ? t('account') : t('login');
44
+
45
+ return (
46
+ <Link
47
+ href={href}
48
+ aria-label={label}
49
+ className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 items-center justify-center gap-1.5 rounded-md px-2 transition-colors"
50
+ >
51
+ <UserIcon className="h-5 w-5" />
52
+ <span className="hidden text-sm font-medium sm:inline">{label}</span>
53
+ </Link>
54
+ );
55
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import * as React from 'react';
19
19
  import type { Content } from 'brainerce';
20
+ import { HeaderAccount } from './header-account';
20
21
 
21
22
  interface SiteHeaderProps {
22
23
  /** Pre-fetched header payload (server-side). `null` triggers static fallback. */
@@ -77,13 +78,16 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
77
78
  <a href="/" className="text-lg font-semibold tracking-tight" aria-label={brandLabel}>
78
79
  {brandLabel}
79
80
  </a>
80
- <a
81
- href="/cart"
82
- aria-label="Cart"
83
- className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 w-9 items-center justify-center rounded-md transition-colors"
84
- >
85
- <CartIcon className="h-5 w-5" />
86
- </a>
81
+ <div className="flex items-center gap-2">
82
+ <HeaderAccount />
83
+ <a
84
+ href="/cart"
85
+ aria-label="Cart"
86
+ className="text-foreground/80 hover:text-foreground hover:bg-muted/60 inline-flex h-9 w-9 items-center justify-center rounded-md transition-colors"
87
+ >
88
+ <CartIcon className="h-5 w-5" />
89
+ </a>
90
+ </div>
87
91
  </div>
88
92
  </header>
89
93
  );
@@ -129,6 +133,8 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
129
133
  </a>
130
134
  ) : null}
131
135
 
136
+ <HeaderAccount />
137
+
132
138
  <a
133
139
  href="/cart"
134
140
  aria-label="Cart"
@@ -18,6 +18,41 @@ interface ProductCardProps {
18
18
  className?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Pick the price + currency to render for a given product (PRD §23 FX overlay).
23
+ *
24
+ * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
25
+ * region currency differs from the store currency, the backend attaches
26
+ * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
27
+ * to the canonical store-currency `basePrice` / `salePrice`. Either way the
28
+ * cart still charges in the store currency — this only affects display.
29
+ */
30
+ function pickDisplayPrice(
31
+ product: Product,
32
+ fallbackCurrency: string
33
+ ): { price: number | undefined; salePrice: number | null; currency: string } {
34
+ if (product.displayPrice != null && product.displayCurrency) {
35
+ const sale =
36
+ product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
37
+ return {
38
+ // `displayPrice` is the base price converted to the region currency —
39
+ // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
40
+ price: parseFloat(product.displayPrice),
41
+ salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
42
+ currency: product.displayCurrency,
43
+ };
44
+ }
45
+ // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
46
+ // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
47
+ // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
48
+ const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
49
+ return {
50
+ price: originalPrice,
51
+ salePrice: isOnSale ? effective : null,
52
+ currency: fallbackCurrency,
53
+ };
54
+ }
55
+
21
56
  function VariantPriceRange({ product }: { product: Product }) {
22
57
  const currency = useCurrency();
23
58
 
@@ -51,6 +86,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
51
86
  const tProd = useTranslations('products');
52
87
  const router = useRouter();
53
88
  const { refreshCart } = useCart();
89
+ const fallbackCurrency = useCurrency();
90
+ // FX overlay (PRD §23): prefer the region-converted display values when the
91
+ // storefront passed regionId to getProducts. Otherwise fall back to the
92
+ // canonical store-currency basePrice / salePrice.
93
+ const display = pickDisplayPrice(product, fallbackCurrency);
54
94
  const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
55
95
  const mainImage = product.images?.[0];
56
96
  const imageUrl = mainImage?.url || null;
@@ -215,7 +255,12 @@ export function ProductCard({ product, className }: ProductCardProps) {
215
255
  {isVariable ? (
216
256
  <VariantPriceRange product={product} />
217
257
  ) : (
218
- <PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
258
+ <PriceDisplay
259
+ price={display.price ?? originalPrice}
260
+ salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
261
+ currency={display.currency}
262
+ size="sm"
263
+ />
219
264
  )}
220
265
 
221
266
  {/* Stock */}