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
@@ -1,345 +1,374 @@
1
- <% if (i18nEnabled) { %>
2
- import type { Metadata } from 'next';
3
- import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
4
- import { StoreProvider } from '@/core/providers/store-provider';
5
- import { AnnouncementBar } from '@/ui/layout/announcement-bar';
6
- import { SiteHeader } from '@/ui/layout/site-header';
7
- import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
8
- import { SiteFooter } from '@/ui/layout/site-footer';
9
- import { CartDrawer } from '@/ui/cart/cart-drawer';
10
- import { BrainerceBotWidget } from '@/components/brainerce-bot';
11
- import { TrackingBootstrap } from '@/components/tracking-bootstrap';
12
- import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
13
- import { resolveRegion } from '@/core/lib/region.server';
14
- import { getDirection, getMessages, supportedLocales } from '@/i18n';
15
- import { getNonce } from '@/core/lib/nonce';
16
- import { getCanonicalSiteUrl } from '@/core/lib/site-url';
17
- import '../globals.css';
18
-
19
- // Atelier: Assistant (clean Hebrew grotesque) for body, Frank Ruhl Libre
20
- // (Hebrew serif — the gallery voice) for display. Exposed as CSS variables
21
- // consumed by tailwind's font-sans / font-serif and the h1-h3 base styles.
22
- const sans = Assistant({
23
- subsets: ['hebrew', 'latin'],
24
- weight: ['400', '500', '600', '700'],
25
- variable: '--font-sans',
26
- });
27
- const serif = Frank_Ruhl_Libre({
28
- subsets: ['hebrew', 'latin'],
29
- weight: ['400', '500', '700'],
30
- variable: '--font-serif',
31
- });
32
-
33
- // Resolved per request rather than read from a hardcoded env default: the
34
- // scaffolder cannot know the deploy domain, so `metadataBase` is the one
35
- // value that must never be guessed. See core/lib/site-url.ts.
36
- export async function generateMetadata(): Promise<Metadata> {
37
- const baseUrl = await getCanonicalSiteUrl();
38
- return {
39
- metadataBase: new URL(baseUrl),
40
- title: {
41
- default: <%- storeNameJs %>,
42
- template: <%- titleTemplateJs %>,
43
- },
44
- description: <%- storeNameJs %>,
45
- alternates: {
46
- canonical: '/',
47
- },
48
- openGraph: {
49
- siteName: <%- storeNameJs %>,
50
- type: 'website',
51
- },
52
- robots: {
53
- index: true,
54
- follow: true,
55
- },
56
- };
57
- }
58
-
59
- function buildOrganizationJsonLd(baseUrl: string) {
60
- return {
61
- '@context': 'https://schema.org',
62
- '@type': 'Organization',
63
- name: <%- storeNameJs %>,
64
- url: baseUrl,
65
- };
66
- }
67
-
68
- export function generateStaticParams() {
69
- return supportedLocales.map((locale) => ({ locale }));
70
- }
71
-
72
- export default async function RootLayout({
73
- children,
74
- params,
75
- }: {
76
- children: React.ReactNode;
77
- params: Promise<{ locale: string }>;
78
- }) {
79
- const { locale } = await params;
80
- const dir = getDirection(locale);
81
- const nonce = await getNonce();
82
- const baseUrl = await getCanonicalSiteUrl();
83
-
84
- // Merchant-driven layout chrome — fetched server-side. Each call falls back
85
- // to null/[] on 404 so the layout never crashes when the merchant hasn't
86
- // seeded a particular content type yet. New stores ship with default rows
87
- // seeded by the backend (StoresService.seedDefaultContent).
88
- const client = await getServerClient(locale);
89
- const [announcements, siteHeader, siteFooter, storeInfo, messages, discountBanners, regionInfo] =
90
- await Promise.all([
91
- client.content.announcement.list(locale).catch(() => []),
92
- client.content.header.get('main', locale).catch(() => null),
93
- client.content.footer.get('main', locale).catch(() => null),
94
- // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
95
- // render with the real currency, feature flags, and i18n config at frame 0
96
- // — without this, Googlebot sees USD/defaults baked into the HTML.
97
- fetchStoreInfo(locale),
98
- // SSR-fetch UI message strings so translated text renders at frame 0 —
99
- // without this, every page (and every locale switch) shows raw
100
- // "namespace.key" strings until the client-only fetch resolves.
101
- getMessages(locale),
102
- // Store-wide discount banners. Fetched here rather than on the home
103
- // page so they render below the header on EVERY page. `.catch(() => [])`
104
- // keeps a store with no active discounts (and an API hiccup) silent:
105
- // the strip itself returns null on an empty array.
106
- client.getDiscountBanners().catch(() => []),
107
- // Which region prices this request: the shopper's cookie, else the edge
108
- // geo header, else the store default. Resolved here so SSR and the client
109
- // agree from frame 0 and every page shares one round trip (it is
110
- // `cache()`-wrapped). Never throws: a store with no regions answers
111
- // `{ regions: [], region: null }` and every consumer then sends no
112
- // `regionId`, which is what this storefront did before regions existed.
113
- resolveRegion(),
114
- ]);
115
-
116
- return (
117
- <html lang={locale} dir={dir}>
118
- <head>
119
- <script
120
- type="application/ld+json"
121
- nonce={nonce}
122
- suppressHydrationWarning
123
- dangerouslySetInnerHTML={{
124
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
125
- .replace(/</g, '\\u003c')
126
- .replace(/>/g, '\\u003e')
127
- .replace(/&/g, '\\u0026'),
128
- }}
129
- />
130
- {/* Google Search Console verification — token set by the merchant in
131
- the dashboard (channel settings → Google site verification). Also
132
- what the Merchant Center website claim checks for. Renders nothing
133
- until a token is configured. */}
134
- {storeInfo?.seo?.googleSiteVerification ? (
135
- <meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
136
- ) : null}
137
- {/* RSS autodiscovery — feed readers and aggregators find the blog feed
138
- from any page. The route itself lives at /blog/rss.xml. */}
139
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
140
- {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
141
- SPA route changes. Cookieless (no consent banner needed); the
142
- merchant can toggle it per sales channel in the dashboard. */}
143
- <script
144
- defer
145
- src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
146
- data-channel={
147
- process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
148
- }
149
- nonce={nonce}
150
- suppressHydrationWarning
151
- />
152
- </head>
153
- <body className={`${sans.variable} ${serif.variable} font-sans`}>
154
- <StoreProvider
155
- locale={locale}
156
- initialStoreInfo={storeInfo}
157
- initialMessages={messages}
158
- initialRegions={regionInfo.regions}
159
- initialRegion={regionInfo.region}
160
- initialCountry={regionInfo.country}
161
- >
162
- {/* Mounted ahead of the page tree on purpose: React runs effects in
163
- tree order, so booting the tags here guarantees fbq/ttq exist by
164
- the time a page component fires its first e-commerce event. */}
165
- <TrackingBootstrap />
166
- <div className="min-h-screen flex flex-col">
167
- <AnnouncementBar announcements={announcements} />
168
- <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
169
- <DiscountBannerStrip banners={discountBanners} />
170
- <main className="flex-1">{children}</main>
171
- <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} locale={locale} />
172
- </div>
173
- <CartDrawer />
174
- <BrainerceBotWidget />
175
- </StoreProvider>
176
- </body>
177
- </html>
178
- );
179
- }
180
- <% } else { %>
181
- import type { Metadata } from 'next';
182
- import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
183
- import { StoreProvider } from '@/core/providers/store-provider';
184
- import { AnnouncementBar } from '@/ui/layout/announcement-bar';
185
- import { SiteHeader } from '@/ui/layout/site-header';
186
- import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
187
- import { SiteFooter } from '@/ui/layout/site-footer';
188
- import { CartDrawer } from '@/ui/cart/cart-drawer';
189
- import { BrainerceBotWidget } from '@/components/brainerce-bot';
190
- import { TrackingBootstrap } from '@/components/tracking-bootstrap';
191
- import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
192
- import { resolveRegion } from '@/core/lib/region.server';
193
- import { getNonce } from '@/core/lib/nonce';
194
- import { getCanonicalSiteUrl } from '@/core/lib/site-url';
195
- import './globals.css';
196
-
197
- // Atelier: Assistant (clean Hebrew grotesque) for body, Frank Ruhl Libre
198
- // (Hebrew serif — the gallery voice) for display. Exposed as CSS variables
199
- // consumed by tailwind's font-sans / font-serif and the h1-h3 base styles.
200
- const sans = Assistant({
201
- subsets: ['hebrew', 'latin'],
202
- weight: ['400', '500', '600', '700'],
203
- variable: '--font-sans',
204
- });
205
- const serif = Frank_Ruhl_Libre({
206
- subsets: ['hebrew', 'latin'],
207
- weight: ['400', '500', '700'],
208
- variable: '--font-serif',
209
- });
210
-
211
- // Resolved per request rather than read from a hardcoded env default: the
212
- // scaffolder cannot know the deploy domain, so `metadataBase` is the one
213
- // value that must never be guessed. See core/lib/site-url.ts.
214
- export async function generateMetadata(): Promise<Metadata> {
215
- const baseUrl = await getCanonicalSiteUrl();
216
- return {
217
- metadataBase: new URL(baseUrl),
218
- title: {
219
- default: <%- storeNameJs %>,
220
- template: <%- titleTemplateJs %>,
221
- },
222
- description: <%- storeNameJs %>,
223
- alternates: {
224
- canonical: '/',
225
- },
226
- openGraph: {
227
- siteName: <%- storeNameJs %>,
228
- locale: '<%= ogLocale %>',
229
- type: 'website',
230
- },
231
- robots: {
232
- index: true,
233
- follow: true,
234
- },
235
- };
236
- }
237
-
238
- function buildOrganizationJsonLd(baseUrl: string) {
239
- return {
240
- '@context': 'https://schema.org',
241
- '@type': 'Organization',
242
- name: <%- storeNameJs %>,
243
- url: baseUrl,
244
- };
245
- }
246
-
247
- export default async function RootLayout({
248
- children,
249
- }: {
250
- children: React.ReactNode;
251
- }) {
252
- const nonce = await getNonce();
253
- const baseUrl = await getCanonicalSiteUrl();
254
-
255
- // Merchant-driven layout chrome — fetched server-side. Each call falls back
256
- // to null/[] on 404 so the layout never crashes when the merchant hasn't
257
- // seeded a particular content type yet. New stores ship with default rows
258
- // seeded by the backend (StoresService.seedDefaultContent).
259
- const client = await getServerClient();
260
- const [announcements, siteHeader, siteFooter, storeInfo, discountBanners, regionInfo] =
261
- await Promise.all([
262
- client.content.announcement.list().catch(() => []),
263
- client.content.header.get('main').catch(() => null),
264
- client.content.footer.get('main').catch(() => null),
265
- // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
266
- // render with the real currency, feature flags, and i18n config at frame 0
267
- // — without this, Googlebot sees USD/defaults baked into the HTML.
268
- fetchStoreInfo(),
269
- // Store-wide discount banners. Fetched here rather than on the home
270
- // page so they render below the header on EVERY page. `.catch(() => [])`
271
- // keeps a store with no active discounts (and an API hiccup) silent:
272
- // the strip itself returns null on an empty array.
273
- client.getDiscountBanners().catch(() => []),
274
- // Which region prices this request: the shopper's cookie, else the edge
275
- // geo header, else the store default. Resolved here so SSR and the client
276
- // agree from frame 0 and every page shares one round trip (it is
277
- // `cache()`-wrapped). Never throws: a store with no regions answers
278
- // `{ regions: [], region: null }` and every consumer then sends no
279
- // `regionId`, which is what this storefront did before regions existed.
280
- resolveRegion(),
281
- ]);
282
-
283
- return (
284
- <html lang="<%= language %>" dir="<%= direction %>">
285
- <head>
286
- <script
287
- type="application/ld+json"
288
- nonce={nonce}
289
- suppressHydrationWarning
290
- dangerouslySetInnerHTML={{
291
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
292
- .replace(/</g, '\\u003c')
293
- .replace(/>/g, '\\u003e')
294
- .replace(/&/g, '\\u0026'),
295
- }}
296
- />
297
- {/* Google Search Console verification — token set by the merchant in
298
- the dashboard (channel settings → Google site verification). Also
299
- what the Merchant Center website claim checks for. Renders nothing
300
- until a token is configured. */}
301
- {storeInfo?.seo?.googleSiteVerification ? (
302
- <meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
303
- ) : null}
304
- {/* RSS autodiscovery — feed readers and aggregators find the blog feed
305
- from any page. The route itself lives at /blog/rss.xml. */}
306
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
307
- {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
308
- SPA route changes. Cookieless (no consent banner needed); the
309
- merchant can toggle it per sales channel in the dashboard. */}
310
- <script
311
- defer
312
- src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
313
- data-channel={
314
- process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
315
- }
316
- nonce={nonce}
317
- suppressHydrationWarning
318
- />
319
- </head>
320
- <body className={`${sans.variable} ${serif.variable} font-sans`}>
321
- <StoreProvider
322
- initialStoreInfo={storeInfo}
323
- initialRegions={regionInfo.regions}
324
- initialRegion={regionInfo.region}
325
- initialCountry={regionInfo.country}
326
- >
327
- {/* Mounted ahead of the page tree on purpose: React runs effects in
328
- tree order, so booting the tags here guarantees fbq/ttq exist by
329
- the time a page component fires its first e-commerce event. */}
330
- <TrackingBootstrap />
331
- <div className="min-h-screen flex flex-col">
332
- <AnnouncementBar announcements={announcements} />
333
- <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
334
- <DiscountBannerStrip banners={discountBanners} />
335
- <main className="flex-1">{children}</main>
336
- <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
337
- </div>
338
- <CartDrawer />
339
- <BrainerceBotWidget />
340
- </StoreProvider>
341
- </body>
342
- </html>
343
- );
344
- }
345
- <% } %>
1
+ <% if (i18nEnabled) { %>
2
+ import type { Metadata } from 'next';
3
+ import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
4
+ import { StoreProvider } from '@/core/providers/store-provider';
5
+ import { AnnouncementBar } from '@/ui/layout/announcement-bar';
6
+ import { SiteHeader } from '@/ui/layout/site-header';
7
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
8
+ import { SiteFooter } from '@/ui/layout/site-footer';
9
+ import { CartDrawer } from '@/ui/cart/cart-drawer';
10
+ import { BrainerceBotWidget } from '@/components/brainerce-bot';
11
+ import { TrackingBootstrap } from '@/components/tracking-bootstrap';
12
+ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
13
+ import { resolveRegion } from '@/core/lib/region.server';
14
+ import { getDirection, getMessages, supportedLocales } from '@/i18n';
15
+ import { getNonce } from '@/core/lib/nonce';
16
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
17
+ import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
18
+ import { buildMetaDescription } from '@/core/lib/seo';
19
+ import '../globals.css';
20
+
21
+ // Atelier: Assistant (clean Hebrew grotesque) for body, Frank Ruhl Libre
22
+ // (Hebrew serif — the gallery voice) for display. Exposed as CSS variables
23
+ // consumed by tailwind's font-sans / font-serif and the h1-h3 base styles.
24
+ const sans = Assistant({
25
+ subsets: ['hebrew', 'latin'],
26
+ weight: ['400', '500', '600', '700'],
27
+ variable: '--font-sans',
28
+ });
29
+ const serif = Frank_Ruhl_Libre({
30
+ subsets: ['hebrew', 'latin'],
31
+ weight: ['400', '500', '700'],
32
+ variable: '--font-serif',
33
+ });
34
+
35
+ // Resolved per request rather than read from a hardcoded env default: the
36
+ // scaffolder cannot know the deploy domain, so `metadataBase` is the one
37
+ // value that must never be guessed. See core/lib/site-url.ts.
38
+ //
39
+ // The store NAME is resolved the same way, and for the same reason: under
40
+ // `--defer-connection` the scaffolder only knew the directory name. Live
41
+ // `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
42
+ // the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
43
+ // scaffold literal — see core/lib/store-name.ts.
44
+ export async function generateMetadata({
45
+ params,
46
+ }: {
47
+ params: Promise<{ locale: string }>;
48
+ }): Promise<Metadata> {
49
+ const { locale } = await params;
50
+ const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo(locale)]);
51
+ const storeName = resolveStoreName(storeInfo);
52
+ return {
53
+ metadataBase: new URL(baseUrl),
54
+ title: {
55
+ default: storeName,
56
+ template: buildTitleTemplate(storeName),
57
+ },
58
+ description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
59
+ alternates: {
60
+ canonical: '/',
61
+ },
62
+ openGraph: {
63
+ siteName: storeName,
64
+ type: 'website',
65
+ },
66
+ robots: {
67
+ index: true,
68
+ follow: true,
69
+ },
70
+ };
71
+ }
72
+
73
+ function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
74
+ return {
75
+ '@context': 'https://schema.org',
76
+ '@type': 'Organization',
77
+ name: storeName,
78
+ url: baseUrl,
79
+ };
80
+ }
81
+
82
+ export function generateStaticParams() {
83
+ return supportedLocales.map((locale) => ({ locale }));
84
+ }
85
+
86
+ export default async function RootLayout({
87
+ children,
88
+ params,
89
+ }: {
90
+ children: React.ReactNode;
91
+ params: Promise<{ locale: string }>;
92
+ }) {
93
+ const { locale } = await params;
94
+ const dir = getDirection(locale);
95
+ const nonce = await getNonce();
96
+ const baseUrl = await getCanonicalSiteUrl();
97
+
98
+ // Merchant-driven layout chrome — fetched server-side. Each call falls back
99
+ // to null/[] on 404 so the layout never crashes when the merchant hasn't
100
+ // seeded a particular content type yet. New stores ship with default rows
101
+ // seeded by the backend (StoresService.seedDefaultContent).
102
+ const client = await getServerClient(locale);
103
+ const [announcements, siteHeader, siteFooter, storeInfo, messages, discountBanners, regionInfo] =
104
+ await Promise.all([
105
+ client.content.announcement.list(locale).catch(() => []),
106
+ client.content.header.get('main', locale).catch(() => null),
107
+ client.content.footer.get('main', locale).catch(() => null),
108
+ // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
109
+ // render with the real currency, feature flags, and i18n config at frame 0
110
+ // — without this, Googlebot sees USD/defaults baked into the HTML.
111
+ fetchStoreInfo(locale),
112
+ // SSR-fetch UI message strings so translated text renders at frame 0 —
113
+ // without this, every page (and every locale switch) shows raw
114
+ // "namespace.key" strings until the client-only fetch resolves.
115
+ getMessages(locale),
116
+ // Store-wide discount banners. Fetched here rather than on the home
117
+ // page so they render below the header on EVERY page. `.catch(() => [])`
118
+ // keeps a store with no active discounts (and an API hiccup) silent:
119
+ // the strip itself returns null on an empty array.
120
+ client.getDiscountBanners().catch(() => []),
121
+ // Which region prices this request: the shopper's cookie, else the edge
122
+ // geo header, else the store default. Resolved here so SSR and the client
123
+ // agree from frame 0 and every page shares one round trip (it is
124
+ // `cache()`-wrapped). Never throws: a store with no regions answers
125
+ // `{ regions: [], region: null }` and every consumer then sends no
126
+ // `regionId`, which is what this storefront did before regions existed.
127
+ resolveRegion(),
128
+ ]);
129
+ // One resolved name for the JSON-LD, the header and the footer. Live data
130
+ // first; the scaffold-time literal is only the last fallback.
131
+ const storeName = resolveStoreName(storeInfo);
132
+
133
+ return (
134
+ <html lang={locale} dir={dir}>
135
+ <head>
136
+ <script
137
+ type="application/ld+json"
138
+ nonce={nonce}
139
+ suppressHydrationWarning
140
+ dangerouslySetInnerHTML={{
141
+ __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
142
+ .replace(/</g, '\\u003c')
143
+ .replace(/>/g, '\\u003e')
144
+ .replace(/&/g, '\\u0026'),
145
+ }}
146
+ />
147
+ {/* Google Search Console verification — token set by the merchant in
148
+ the dashboard (channel settings → Google site verification). Also
149
+ what the Merchant Center website claim checks for. Renders nothing
150
+ until a token is configured. */}
151
+ {storeInfo?.seo?.googleSiteVerification ? (
152
+ <meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
153
+ ) : null}
154
+ {/* RSS autodiscovery — feed readers and aggregators find the blog feed
155
+ from any page. The route itself lives at /blog/rss.xml. */}
156
+ <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
157
+ {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
158
+ SPA route changes. Cookieless (no consent banner needed); the
159
+ merchant can toggle it per sales channel in the dashboard. */}
160
+ <script
161
+ defer
162
+ src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
163
+ data-channel={
164
+ process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
165
+ }
166
+ nonce={nonce}
167
+ suppressHydrationWarning
168
+ />
169
+ </head>
170
+ <body className={`${sans.variable} ${serif.variable} font-sans`}>
171
+ <StoreProvider
172
+ locale={locale}
173
+ initialStoreInfo={storeInfo}
174
+ initialMessages={messages}
175
+ initialRegions={regionInfo.regions}
176
+ initialRegion={regionInfo.region}
177
+ initialCountry={regionInfo.country}
178
+ >
179
+ {/* Mounted ahead of the page tree on purpose: React runs effects in
180
+ tree order, so booting the tags here guarantees fbq/ttq exist by
181
+ the time a page component fires its first e-commerce event. */}
182
+ <TrackingBootstrap />
183
+ <div className="min-h-screen flex flex-col">
184
+ <AnnouncementBar announcements={announcements} />
185
+ <SiteHeader header={siteHeader} storeName={storeName} />
186
+ <DiscountBannerStrip banners={discountBanners} />
187
+ <main className="flex-1">{children}</main>
188
+ <SiteFooter footer={siteFooter} storeName={storeName} locale={locale} />
189
+ </div>
190
+ <CartDrawer />
191
+ <BrainerceBotWidget />
192
+ </StoreProvider>
193
+ </body>
194
+ </html>
195
+ );
196
+ }
197
+ <% } else { %>
198
+ import type { Metadata } from 'next';
199
+ import { Assistant, Frank_Ruhl_Libre } from 'next/font/google';
200
+ import { StoreProvider } from '@/core/providers/store-provider';
201
+ import { AnnouncementBar } from '@/ui/layout/announcement-bar';
202
+ import { SiteHeader } from '@/ui/layout/site-header';
203
+ import { DiscountBannerStrip } from '@/ui/home/discount-banner-strip';
204
+ import { SiteFooter } from '@/ui/layout/site-footer';
205
+ import { CartDrawer } from '@/ui/cart/cart-drawer';
206
+ import { BrainerceBotWidget } from '@/components/brainerce-bot';
207
+ import { TrackingBootstrap } from '@/components/tracking-bootstrap';
208
+ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
209
+ import { resolveRegion } from '@/core/lib/region.server';
210
+ import { getNonce } from '@/core/lib/nonce';
211
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
212
+ import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
213
+ import { buildMetaDescription } from '@/core/lib/seo';
214
+ import './globals.css';
215
+
216
+ // Atelier: Assistant (clean Hebrew grotesque) for body, Frank Ruhl Libre
217
+ // (Hebrew serif — the gallery voice) for display. Exposed as CSS variables
218
+ // consumed by tailwind's font-sans / font-serif and the h1-h3 base styles.
219
+ const sans = Assistant({
220
+ subsets: ['hebrew', 'latin'],
221
+ weight: ['400', '500', '600', '700'],
222
+ variable: '--font-sans',
223
+ });
224
+ const serif = Frank_Ruhl_Libre({
225
+ subsets: ['hebrew', 'latin'],
226
+ weight: ['400', '500', '700'],
227
+ variable: '--font-serif',
228
+ });
229
+
230
+ // Resolved per request rather than read from a hardcoded env default: the
231
+ // scaffolder cannot know the deploy domain, so `metadataBase` is the one
232
+ // value that must never be guessed. See core/lib/site-url.ts.
233
+ //
234
+ // The store NAME is resolved the same way, and for the same reason: under
235
+ // `--defer-connection` the scaffolder only knew the directory name. Live
236
+ // `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
237
+ // the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
238
+ // scaffold literal — see core/lib/store-name.ts.
239
+ export async function generateMetadata(): Promise<Metadata> {
240
+ const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo()]);
241
+ const storeName = resolveStoreName(storeInfo);
242
+ return {
243
+ metadataBase: new URL(baseUrl),
244
+ title: {
245
+ default: storeName,
246
+ template: buildTitleTemplate(storeName),
247
+ },
248
+ description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
249
+ alternates: {
250
+ canonical: '/',
251
+ },
252
+ openGraph: {
253
+ siteName: storeName,
254
+ locale: '<%= ogLocale %>',
255
+ type: 'website',
256
+ },
257
+ robots: {
258
+ index: true,
259
+ follow: true,
260
+ },
261
+ };
262
+ }
263
+
264
+ function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
265
+ return {
266
+ '@context': 'https://schema.org',
267
+ '@type': 'Organization',
268
+ name: storeName,
269
+ url: baseUrl,
270
+ };
271
+ }
272
+
273
+ export default async function RootLayout({
274
+ children,
275
+ }: {
276
+ children: React.ReactNode;
277
+ }) {
278
+ const nonce = await getNonce();
279
+ const baseUrl = await getCanonicalSiteUrl();
280
+
281
+ // Merchant-driven layout chrome — fetched server-side. Each call falls back
282
+ // to null/[] on 404 so the layout never crashes when the merchant hasn't
283
+ // seeded a particular content type yet. New stores ship with default rows
284
+ // seeded by the backend (StoresService.seedDefaultContent).
285
+ const client = await getServerClient();
286
+ const [announcements, siteHeader, siteFooter, storeInfo, discountBanners, regionInfo] =
287
+ await Promise.all([
288
+ client.content.announcement.list().catch(() => []),
289
+ client.content.header.get('main').catch(() => null),
290
+ client.content.footer.get('main').catch(() => null),
291
+ // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
292
+ // render with the real currency, feature flags, and i18n config at frame 0
293
+ // — without this, Googlebot sees USD/defaults baked into the HTML.
294
+ fetchStoreInfo(),
295
+ // Store-wide discount banners. Fetched here rather than on the home
296
+ // page so they render below the header on EVERY page. `.catch(() => [])`
297
+ // keeps a store with no active discounts (and an API hiccup) silent:
298
+ // the strip itself returns null on an empty array.
299
+ client.getDiscountBanners().catch(() => []),
300
+ // Which region prices this request: the shopper's cookie, else the edge
301
+ // geo header, else the store default. Resolved here so SSR and the client
302
+ // agree from frame 0 and every page shares one round trip (it is
303
+ // `cache()`-wrapped). Never throws: a store with no regions answers
304
+ // `{ regions: [], region: null }` and every consumer then sends no
305
+ // `regionId`, which is what this storefront did before regions existed.
306
+ resolveRegion(),
307
+ ]);
308
+ // One resolved name for the JSON-LD, the header and the footer. Live data
309
+ // first; the scaffold-time literal is only the last fallback.
310
+ const storeName = resolveStoreName(storeInfo);
311
+
312
+ return (
313
+ <html lang="<%= language %>" dir="<%= direction %>">
314
+ <head>
315
+ <script
316
+ type="application/ld+json"
317
+ nonce={nonce}
318
+ suppressHydrationWarning
319
+ dangerouslySetInnerHTML={{
320
+ __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
321
+ .replace(/</g, '\\u003c')
322
+ .replace(/>/g, '\\u003e')
323
+ .replace(/&/g, '\\u0026'),
324
+ }}
325
+ />
326
+ {/* Google Search Console verification — token set by the merchant in
327
+ the dashboard (channel settings → Google site verification). Also
328
+ what the Merchant Center website claim checks for. Renders nothing
329
+ until a token is configured. */}
330
+ {storeInfo?.seo?.googleSiteVerification ? (
331
+ <meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
332
+ ) : null}
333
+ {/* RSS autodiscovery — feed readers and aggregators find the blog feed
334
+ from any page. The route itself lives at /blog/rss.xml. */}
335
+ <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
336
+ {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
337
+ SPA route changes. Cookieless (no consent banner needed); the
338
+ merchant can toggle it per sales channel in the dashboard. */}
339
+ <script
340
+ defer
341
+ src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
342
+ data-channel={
343
+ process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
344
+ }
345
+ nonce={nonce}
346
+ suppressHydrationWarning
347
+ />
348
+ </head>
349
+ <body className={`${sans.variable} ${serif.variable} font-sans`}>
350
+ <StoreProvider
351
+ initialStoreInfo={storeInfo}
352
+ initialRegions={regionInfo.regions}
353
+ initialRegion={regionInfo.region}
354
+ initialCountry={regionInfo.country}
355
+ >
356
+ {/* Mounted ahead of the page tree on purpose: React runs effects in
357
+ tree order, so booting the tags here guarantees fbq/ttq exist by
358
+ the time a page component fires its first e-commerce event. */}
359
+ <TrackingBootstrap />
360
+ <div className="min-h-screen flex flex-col">
361
+ <AnnouncementBar announcements={announcements} />
362
+ <SiteHeader header={siteHeader} storeName={storeName} />
363
+ <DiscountBannerStrip banners={discountBanners} />
364
+ <main className="flex-1">{children}</main>
365
+ <SiteFooter footer={siteFooter} storeName={storeName} />
366
+ </div>
367
+ <CartDrawer />
368
+ <BrainerceBotWidget />
369
+ </StoreProvider>
370
+ </body>
371
+ </html>
372
+ );
373
+ }
374
+ <% } %>