create-brainerce-store 1.43.2 → 1.44.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 +18 -9
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +19 -15
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +97 -98
- package/templates/nextjs/base/src/app/layout.tsx.ejs +199 -195
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +39 -0
- package/templates/nextjs/base/src/components/content/header-account.tsx +55 -0
- package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +13 -7
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.
|
|
34
|
+
version: "1.44.0",
|
|
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,34 @@ var ALLOWED_PACKAGE_MANAGERS = [
|
|
|
172
172
|
"bun"
|
|
173
173
|
];
|
|
174
174
|
var BRAINERCE_RUNTIME_DEPS = Object.freeze({
|
|
175
|
-
// 1.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
175
|
+
// 1.30 adds the storefront seam for geo-IP region resolution
|
|
176
|
+
// (getAutoRegion) + non-binding tax preview (estimateTax). 1.28 cut the
|
|
177
|
+
// PriceList API + introduced the FX display overlay
|
|
178
|
+
// (Product.displayPrice / displayCurrency / displaySalePrice attached
|
|
179
|
+
// additively when getProducts is called with a regionId). Scaffolded stores
|
|
180
|
+
// must pin >=1.30 to get the auto-region + tax-estimate methods.
|
|
181
|
+
brainerce: "^1.30.0",
|
|
181
182
|
"isomorphic-dompurify": "^3.8.0"
|
|
182
183
|
});
|
|
183
184
|
|
|
184
185
|
// ../cli-shared/src/deps.ts
|
|
186
|
+
var SAFE_INSTALL_TOKEN = /^[@a-zA-Z0-9._/^~*+=:-]+$/;
|
|
185
187
|
async function installDependencies(projectDir, pkgManager, opts = {}) {
|
|
186
188
|
if (!ALLOWED_PACKAGE_MANAGERS.includes(pkgManager)) {
|
|
187
189
|
throw new Error(`Unsupported package manager: ${pkgManager}`);
|
|
188
190
|
}
|
|
189
191
|
const subcommand = opts.subcommand ?? (pkgManager === "yarn" ? "" : "install");
|
|
190
192
|
const extraArgs = opts.args ?? [];
|
|
193
|
+
const isWindows = process.platform === "win32";
|
|
194
|
+
const argTokens = [subcommand, ...extraArgs].filter((t) => t !== "");
|
|
195
|
+
if (isWindows) {
|
|
196
|
+
for (const token of argTokens) {
|
|
197
|
+
if (!SAFE_INSTALL_TOKEN.test(token)) {
|
|
198
|
+
throw new Error(`Unsafe install argument: ${token}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
191
202
|
return new Promise((resolve, reject) => {
|
|
192
|
-
const isWindows = process.platform === "win32";
|
|
193
|
-
const argTokens = [subcommand, ...extraArgs].filter((t) => t !== "");
|
|
194
203
|
const child = isWindows ? (0, import_child_process.spawn)(`${pkgManager} ${argTokens.join(" ")}`.trim(), {
|
|
195
204
|
cwd: projectDir,
|
|
196
205
|
stdio: ["ignore", "ignore", "pipe"],
|
package/package.json
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
# Brainerce Sales Channel — preferred env var name.
|
|
2
|
-
NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID=<%= connectionId %>
|
|
3
|
-
# Legacy alias kept for backwards compatibility — both are accepted by the SDK.
|
|
4
|
-
# @deprecated will be removed when SDK 2.0 ships.
|
|
5
|
-
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
|
|
6
|
-
|
|
7
|
-
# Store info (pre-fetched during setup to avoid flash on first load)
|
|
8
|
-
NEXT_PUBLIC_STORE_NAME=<%- storeNameEnv %>
|
|
9
|
-
NEXT_PUBLIC_STORE_CURRENCY=<%- currencyEnv %>
|
|
10
|
-
|
|
11
|
-
# Backend API URL (server-side only — used by BFF proxy and SSR, never exposed to browser)
|
|
12
|
-
BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
|
|
13
|
-
|
|
14
|
-
# Public
|
|
15
|
-
|
|
1
|
+
# Brainerce Sales Channel — preferred env var name.
|
|
2
|
+
NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID=<%= connectionId %>
|
|
3
|
+
# Legacy alias kept for backwards compatibility — both are accepted by the SDK.
|
|
4
|
+
# @deprecated will be removed when SDK 2.0 ships.
|
|
5
|
+
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
|
|
6
|
+
|
|
7
|
+
# Store info (pre-fetched during setup to avoid flash on first load)
|
|
8
|
+
NEXT_PUBLIC_STORE_NAME=<%- storeNameEnv %>
|
|
9
|
+
NEXT_PUBLIC_STORE_CURRENCY=<%- currencyEnv %>
|
|
10
|
+
|
|
11
|
+
# Backend API URL (server-side only — used by BFF proxy and SSR, never exposed to browser)
|
|
12
|
+
BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
|
|
13
|
+
|
|
14
|
+
# Public API origin for the AI chat widget (public, unauthenticated endpoints only).
|
|
15
|
+
# The widget streams chat directly from the browser, so this one IS public.
|
|
16
|
+
NEXT_PUBLIC_BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
|
|
17
|
+
|
|
18
|
+
# Public site URL — used for sitemap, robots.txt, and SEO metadata
|
|
19
|
+
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
|
@@ -1,98 +1,97 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
getVar(envContent, '
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
updated = setVar(updated, '
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(`✓
|
|
97
|
-
console.log(
|
|
98
|
-
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.');
|
|
@@ -1,195 +1,199 @@
|
|
|
1
|
-
<% if (i18nEnabled) { %>
|
|
2
|
-
import type { Metadata } from 'next';
|
|
3
|
-
<%- fontImport %>
|
|
4
|
-
import { StoreProvider } from '@/providers/store-provider';
|
|
5
|
-
import { AnnouncementBar } from '@/components/content/announcement-bar';
|
|
6
|
-
import { SiteHeader } from '@/components/content/site-header';
|
|
7
|
-
import { SiteFooter } from '@/components/content/site-footer';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import '
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'@
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// seeded
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
client.content.
|
|
67
|
-
client.content.
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
.replace(
|
|
85
|
-
.replace(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
import {
|
|
106
|
-
|
|
107
|
-
import {
|
|
108
|
-
import {
|
|
109
|
-
import {
|
|
110
|
-
import {
|
|
111
|
-
import '
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
<
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
1
|
+
<% if (i18nEnabled) { %>
|
|
2
|
+
import type { Metadata } from 'next';
|
|
3
|
+
<%- fontImport %>
|
|
4
|
+
import { StoreProvider } from '@/providers/store-provider';
|
|
5
|
+
import { AnnouncementBar } from '@/components/content/announcement-bar';
|
|
6
|
+
import { SiteHeader } from '@/components/content/site-header';
|
|
7
|
+
import { SiteFooter } from '@/components/content/site-footer';
|
|
8
|
+
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
9
|
+
import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
|
|
10
|
+
import { getDirection, supportedLocales } from '@/i18n';
|
|
11
|
+
import { getNonce } from '@/lib/nonce';
|
|
12
|
+
import '../globals.css';
|
|
13
|
+
|
|
14
|
+
<%- fontVariable %>
|
|
15
|
+
|
|
16
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
17
|
+
|
|
18
|
+
export const metadata: Metadata = {
|
|
19
|
+
metadataBase: new URL(baseUrl),
|
|
20
|
+
title: {
|
|
21
|
+
default: <%- storeNameJs %>,
|
|
22
|
+
template: <%- titleTemplateJs %>,
|
|
23
|
+
},
|
|
24
|
+
description: <%- storeNameJs %>,
|
|
25
|
+
alternates: {
|
|
26
|
+
canonical: '/',
|
|
27
|
+
},
|
|
28
|
+
openGraph: {
|
|
29
|
+
siteName: <%- storeNameJs %>,
|
|
30
|
+
type: 'website',
|
|
31
|
+
},
|
|
32
|
+
robots: {
|
|
33
|
+
index: true,
|
|
34
|
+
follow: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const organizationJsonLd = {
|
|
39
|
+
'@context': 'https://schema.org',
|
|
40
|
+
'@type': 'Organization',
|
|
41
|
+
name: <%- storeNameJs %>,
|
|
42
|
+
url: baseUrl,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function generateStaticParams() {
|
|
46
|
+
return supportedLocales.map((locale) => ({ locale }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default async function RootLayout({
|
|
50
|
+
children,
|
|
51
|
+
params,
|
|
52
|
+
}: {
|
|
53
|
+
children: React.ReactNode;
|
|
54
|
+
params: Promise<{ locale: string }>;
|
|
55
|
+
}) {
|
|
56
|
+
const { locale } = await params;
|
|
57
|
+
const dir = getDirection(locale);
|
|
58
|
+
const nonce = await getNonce();
|
|
59
|
+
|
|
60
|
+
// Merchant-driven layout chrome — fetched server-side. Each call falls back
|
|
61
|
+
// to null/[] on 404 so the layout never crashes when the merchant hasn't
|
|
62
|
+
// seeded a particular content type yet. New stores ship with default rows
|
|
63
|
+
// seeded by the backend (StoresService.seedDefaultContent).
|
|
64
|
+
const client = getServerClient(locale);
|
|
65
|
+
const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
|
|
66
|
+
client.content.announcement.list(locale).catch(() => []),
|
|
67
|
+
client.content.header.get('main', locale).catch(() => null),
|
|
68
|
+
client.content.footer.get('main', locale).catch(() => null),
|
|
69
|
+
// SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
|
|
70
|
+
// render with the real currency, feature flags, and i18n config at frame 0
|
|
71
|
+
// — without this, Googlebot sees USD/defaults baked into the HTML.
|
|
72
|
+
fetchStoreInfo(locale),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<html lang={locale} dir={dir}>
|
|
77
|
+
<head>
|
|
78
|
+
<script
|
|
79
|
+
type="application/ld+json"
|
|
80
|
+
nonce={nonce}
|
|
81
|
+
suppressHydrationWarning
|
|
82
|
+
dangerouslySetInnerHTML={{
|
|
83
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
84
|
+
.replace(/</g, '\\u003c')
|
|
85
|
+
.replace(/>/g, '\\u003e')
|
|
86
|
+
.replace(/&/g, '\\u0026'),
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
</head>
|
|
90
|
+
<body className={font.className}>
|
|
91
|
+
<StoreProvider locale={locale} initialStoreInfo={storeInfo}>
|
|
92
|
+
<div className="min-h-screen flex flex-col">
|
|
93
|
+
<AnnouncementBar announcements={announcements} />
|
|
94
|
+
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
95
|
+
<main className="flex-1">{children}</main>
|
|
96
|
+
<SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
|
|
97
|
+
</div>
|
|
98
|
+
<BrainerceBotWidget />
|
|
99
|
+
</StoreProvider>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
<% } else { %>
|
|
105
|
+
import type { Metadata } from 'next';
|
|
106
|
+
<%- fontImport %>
|
|
107
|
+
import { StoreProvider } from '@/providers/store-provider';
|
|
108
|
+
import { AnnouncementBar } from '@/components/content/announcement-bar';
|
|
109
|
+
import { SiteHeader } from '@/components/content/site-header';
|
|
110
|
+
import { SiteFooter } from '@/components/content/site-footer';
|
|
111
|
+
import { BrainerceBotWidget } from '@/components/brainerce-bot';
|
|
112
|
+
import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
|
|
113
|
+
import { getNonce } from '@/lib/nonce';
|
|
114
|
+
import './globals.css';
|
|
115
|
+
|
|
116
|
+
<%- fontVariable %>
|
|
117
|
+
|
|
118
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
119
|
+
|
|
120
|
+
export const metadata: Metadata = {
|
|
121
|
+
metadataBase: new URL(baseUrl),
|
|
122
|
+
title: {
|
|
123
|
+
default: <%- storeNameJs %>,
|
|
124
|
+
template: <%- titleTemplateJs %>,
|
|
125
|
+
},
|
|
126
|
+
description: <%- storeNameJs %>,
|
|
127
|
+
alternates: {
|
|
128
|
+
canonical: '/',
|
|
129
|
+
},
|
|
130
|
+
openGraph: {
|
|
131
|
+
siteName: <%- storeNameJs %>,
|
|
132
|
+
locale: '<%= ogLocale %>',
|
|
133
|
+
type: 'website',
|
|
134
|
+
},
|
|
135
|
+
robots: {
|
|
136
|
+
index: true,
|
|
137
|
+
follow: true,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const organizationJsonLd = {
|
|
142
|
+
'@context': 'https://schema.org',
|
|
143
|
+
'@type': 'Organization',
|
|
144
|
+
name: <%- storeNameJs %>,
|
|
145
|
+
url: baseUrl,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export default async function RootLayout({
|
|
149
|
+
children,
|
|
150
|
+
}: {
|
|
151
|
+
children: React.ReactNode;
|
|
152
|
+
}) {
|
|
153
|
+
const nonce = await getNonce();
|
|
154
|
+
|
|
155
|
+
// Merchant-driven layout chrome — fetched server-side. Each call falls back
|
|
156
|
+
// to null/[] on 404 so the layout never crashes when the merchant hasn't
|
|
157
|
+
// seeded a particular content type yet. New stores ship with default rows
|
|
158
|
+
// seeded by the backend (StoresService.seedDefaultContent).
|
|
159
|
+
const client = getServerClient();
|
|
160
|
+
const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
|
|
161
|
+
client.content.announcement.list().catch(() => []),
|
|
162
|
+
client.content.header.get('main').catch(() => null),
|
|
163
|
+
client.content.footer.get('main').catch(() => null),
|
|
164
|
+
// SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
|
|
165
|
+
// render with the real currency, feature flags, and i18n config at frame 0
|
|
166
|
+
// — without this, Googlebot sees USD/defaults baked into the HTML.
|
|
167
|
+
fetchStoreInfo(),
|
|
168
|
+
]);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<html lang="<%= language %>" dir="<%= direction %>">
|
|
172
|
+
<head>
|
|
173
|
+
<script
|
|
174
|
+
type="application/ld+json"
|
|
175
|
+
nonce={nonce}
|
|
176
|
+
suppressHydrationWarning
|
|
177
|
+
dangerouslySetInnerHTML={{
|
|
178
|
+
__html: JSON.stringify(organizationJsonLd)
|
|
179
|
+
.replace(/</g, '\\u003c')
|
|
180
|
+
.replace(/>/g, '\\u003e')
|
|
181
|
+
.replace(/&/g, '\\u0026'),
|
|
182
|
+
}}
|
|
183
|
+
/>
|
|
184
|
+
</head>
|
|
185
|
+
<body className={font.className}>
|
|
186
|
+
<StoreProvider initialStoreInfo={storeInfo}>
|
|
187
|
+
<div className="min-h-screen flex flex-col">
|
|
188
|
+
<AnnouncementBar announcements={announcements} />
|
|
189
|
+
<SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
|
|
190
|
+
<main className="flex-1">{children}</main>
|
|
191
|
+
<SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
|
|
192
|
+
</div>
|
|
193
|
+
<BrainerceBotWidget />
|
|
194
|
+
</StoreProvider>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
<% } %>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mounts the Brainerce AI chat widget (the store's shopping assistant).
|
|
7
|
+
*
|
|
8
|
+
* Everything about the bot — name, avatar, colors, greeting, starter
|
|
9
|
+
* questions, capabilities, guardrails — is configured by the merchant in the
|
|
10
|
+
* Brainerce dashboard (Customers → Storefront Bot). This component renders
|
|
11
|
+
* nothing until the bot is switched Live there, so it is always safe to keep
|
|
12
|
+
* mounted.
|
|
13
|
+
*/
|
|
14
|
+
export function BrainerceBotWidget() {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const connectionId =
|
|
17
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
18
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID;
|
|
19
|
+
if (!connectionId) return;
|
|
20
|
+
|
|
21
|
+
let bot: { destroy(): void } | null = null;
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
void import('brainerce/bot').then(({ BrainerceBot }) =>
|
|
24
|
+
BrainerceBot.mount({
|
|
25
|
+
connectionId,
|
|
26
|
+
baseUrl: process.env.NEXT_PUBLIC_BRAINERCE_API_URL || undefined,
|
|
27
|
+
}).then((mounted) => {
|
|
28
|
+
if (cancelled) mounted?.destroy();
|
|
29
|
+
else bot = mounted;
|
|
30
|
+
}),
|
|
31
|
+
);
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
bot?.destroy();
|
|
35
|
+
};
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
@@ -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
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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"
|