create-brainerce-store 1.88.1 → 1.89.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 (44) hide show
  1. package/dist/index.js +3 -2
  2. package/messages/en.json +21 -0
  3. package/messages/he.json +21 -0
  4. package/package.json +3 -2
  5. package/templates/nextjs/base/.eslintrc.json +1 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +35 -0
  7. package/templates/nextjs/base/TRANSLATIONS.md +6 -0
  8. package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
  9. package/templates/nextjs/base/scripts/connect.mjs +6 -0
  10. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
  11. package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
  12. package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
  13. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
  15. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
  16. package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
  17. package/templates/nextjs/base/src/app/page.tsx +109 -75
  18. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
  20. package/templates/nextjs/base/src/app/products/page.tsx +184 -19
  21. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
  23. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
  24. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
  26. package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
  27. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
  28. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
  29. package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
  30. package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
  31. package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
  32. package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
  33. package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
  34. package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
  35. package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
  36. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
  39. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
  42. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
  43. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
  44. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
@@ -13,6 +13,7 @@ import { resolveRegion } from '@/core/lib/region.server';
13
13
  import { getDirection, getMessages, supportedLocales } from '@/i18n';
14
14
  import { getNonce } from '@/core/lib/nonce';
15
15
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
16
+ import { getServerNamespace } from '@/core/lib/server-messages';
16
17
  import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
17
18
  import { buildMetaDescription } from '@/core/lib/seo';
18
19
  import '../globals.css';
@@ -43,9 +44,6 @@ export async function generateMetadata({
43
44
  template: buildTitleTemplate(storeName),
44
45
  },
45
46
  description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
46
- alternates: {
47
- canonical: '/',
48
- },
49
47
  openGraph: {
50
48
  siteName: storeName,
51
49
  type: 'website',
@@ -57,15 +55,6 @@ export async function generateMetadata({
57
55
  };
58
56
  }
59
57
 
60
- function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
61
- return {
62
- '@context': 'https://schema.org',
63
- '@type': 'Organization',
64
- name: storeName,
65
- url: baseUrl,
66
- };
67
- }
68
-
69
58
  export function generateStaticParams() {
70
59
  return supportedLocales.map((locale) => ({ locale }));
71
60
  }
@@ -80,7 +69,6 @@ export default async function RootLayout({
80
69
  const { locale } = await params;
81
70
  const dir = getDirection(locale);
82
71
  const nonce = await getNonce();
83
- const baseUrl = await getCanonicalSiteUrl();
84
72
 
85
73
  // Merchant-driven layout chrome — fetched server-side. Each call falls back
86
74
  // to null/[] on 404 so the layout never crashes when the merchant hasn't
@@ -116,21 +104,17 @@ export default async function RootLayout({
116
104
  // One resolved name for the JSON-LD, the header and the footer. Live data
117
105
  // first; the scaffold-time literal is only the last fallback.
118
106
  const storeName = resolveStoreName(storeInfo);
107
+ // Feed title in the request locale (a literal "Blog" was every language's).
108
+ const seoStrings = await getServerNamespace('seo', locale);
119
109
 
120
110
  return (
121
111
  <html lang={locale} dir={dir}>
122
112
  <head>
123
- <script
124
- type="application/ld+json"
125
- nonce={nonce}
126
- suppressHydrationWarning
127
- dangerouslySetInnerHTML={{
128
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
129
- .replace(/</g, '\\u003c')
130
- .replace(/>/g, '\\u003e')
131
- .replace(/&/g, '\\u0026'),
132
- }}
133
- />
113
+ {/* No Organization JSON-LD here. This layout wraps EVERY page, and the
114
+ homepage already emits the full Organization + WebSite block
115
+ (components/seo/organization-json-ld.tsx); a second, thinner
116
+ Organization on the same page is a duplicate entity, not a reinforcement.
117
+ Organization data belongs on the homepage only. */}
134
118
  {/* Google Search Console verification — token set by the merchant in
135
119
  the dashboard (channel settings → Google site verification). Also
136
120
  what the Merchant Center website claim checks for. Renders nothing
@@ -140,7 +124,7 @@ export default async function RootLayout({
140
124
  ) : null}
141
125
  {/* RSS autodiscovery — feed readers and aggregators find the blog feed
142
126
  from any page. The route itself lives at /blog/rss.xml. */}
143
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
127
+ <link rel="alternate" type="application/rss+xml" title={seoStrings.blog ?? ''} href="/blog/rss.xml" />
144
128
  {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
145
129
  SPA route changes. Cookieless (no consent banner needed); the
146
130
  merchant can toggle it per sales channel in the dashboard. */}
@@ -194,6 +178,7 @@ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
194
178
  import { resolveRegion } from '@/core/lib/region.server';
195
179
  import { getNonce } from '@/core/lib/nonce';
196
180
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
181
+ import { getServerNamespace } from '@/core/lib/server-messages';
197
182
  import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
198
183
  import { buildMetaDescription } from '@/core/lib/seo';
199
184
  import './globals.css';
@@ -219,9 +204,6 @@ export async function generateMetadata(): Promise<Metadata> {
219
204
  template: buildTitleTemplate(storeName),
220
205
  },
221
206
  description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
222
- alternates: {
223
- canonical: '/',
224
- },
225
207
  openGraph: {
226
208
  siteName: storeName,
227
209
  locale: '<%= ogLocale %>',
@@ -234,22 +216,12 @@ export async function generateMetadata(): Promise<Metadata> {
234
216
  };
235
217
  }
236
218
 
237
- function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
238
- return {
239
- '@context': 'https://schema.org',
240
- '@type': 'Organization',
241
- name: storeName,
242
- url: baseUrl,
243
- };
244
- }
245
-
246
219
  export default async function RootLayout({
247
220
  children,
248
221
  }: {
249
222
  children: React.ReactNode;
250
223
  }) {
251
224
  const nonce = await getNonce();
252
- const baseUrl = await getCanonicalSiteUrl();
253
225
 
254
226
  // Merchant-driven layout chrome — fetched server-side. Each call falls back
255
227
  // to null/[] on 404 so the layout never crashes when the merchant hasn't
@@ -281,21 +253,17 @@ export default async function RootLayout({
281
253
  // One resolved name for the JSON-LD, the header and the footer. Live data
282
254
  // first; the scaffold-time literal is only the last fallback.
283
255
  const storeName = resolveStoreName(storeInfo);
256
+ // Feed title in the request locale (a literal "Blog" was every language's).
257
+ const seoStrings = await getServerNamespace('seo');
284
258
 
285
259
  return (
286
260
  <html lang="<%= language %>" dir="<%= direction %>">
287
261
  <head>
288
- <script
289
- type="application/ld+json"
290
- nonce={nonce}
291
- suppressHydrationWarning
292
- dangerouslySetInnerHTML={{
293
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
294
- .replace(/</g, '\\u003c')
295
- .replace(/>/g, '\\u003e')
296
- .replace(/&/g, '\\u0026'),
297
- }}
298
- />
262
+ {/* No Organization JSON-LD here. This layout wraps EVERY page, and the
263
+ homepage already emits the full Organization + WebSite block
264
+ (components/seo/organization-json-ld.tsx); a second, thinner
265
+ Organization on the same page is a duplicate entity, not a reinforcement.
266
+ Organization data belongs on the homepage only. */}
299
267
  {/* Google Search Console verification — token set by the merchant in
300
268
  the dashboard (channel settings → Google site verification). Also
301
269
  what the Merchant Center website claim checks for. Renders nothing
@@ -305,7 +273,7 @@ export default async function RootLayout({
305
273
  ) : null}
306
274
  {/* RSS autodiscovery — feed readers and aggregators find the blog feed
307
275
  from any page. The route itself lives at /blog/rss.xml. */}
308
- <link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
276
+ <link rel="alternate" type="application/rss+xml" title={seoStrings.blog ?? ''} href="/blog/rss.xml" />
309
277
  {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
310
278
  SPA route changes. Cookieless (no consent banner needed); the
311
279
  merchant can toggle it per sales channel in the dashboard. */}
@@ -1,75 +1,109 @@
1
- import type { Metadata } from 'next';
2
- import { Suspense } from 'react';
3
- import { getServerClient } from '@/core/lib/brainerce.server';
4
- import { buildMetaDescription } from '@/core/lib/seo';
5
- import { OrganizationJsonLd } from '@/components/seo/organization-json-ld';
6
- import { getCanonicalSiteUrl } from '@/core/lib/site-url';
7
- import { RichTextBlock } from '@/ui/layout/rich-text-block';
8
- import { HomeClient } from '@/ui/home/home-client';
9
- import { ReferralGreeting } from '@/components/referral/referral-greeting';
10
-
11
- // Build homepage metadata server-side so Google, Facebook, WhatsApp etc.
12
- // see a real <meta name="description"> on first request. Falls back to the
13
- // store name when the merchant hasn't authored a meta description yet.
14
- export async function generateMetadata(): Promise<Metadata> {
15
- try {
16
- const storeInfo = await (await getServerClient()).getStoreInfo();
17
- const description = buildMetaDescription(storeInfo.metaDescription) || undefined;
18
- return {
19
- title: storeInfo.name,
20
- description,
21
- openGraph: {
22
- title: storeInfo.name,
23
- description,
24
- type: 'website',
25
- },
26
- twitter: {
27
- card: 'summary_large_image',
28
- title: storeInfo.name,
29
- description,
30
- },
31
- };
32
- } catch {
33
- return {};
34
- }
35
- }
36
-
37
- export default async function HomePage() {
38
- // Fetch store info server-side so the JSON-LD is in the initial HTML —
39
- // crawlers and AI Overviews see it without needing to execute JavaScript.
40
- // Failure here is non-fatal: the client component re-fetches via the
41
- // StoreProvider so the page still renders.
42
- //
43
- // Also fetch the merchant's homepage intro (RICH_TEXT key='main'). When
44
- // present, it renders as a sanitized HTML block above the product grid —
45
- // typical use is brand introduction, value-prop copy, or a seasonal
46
- // announcement that needs more formatting than the AnnouncementBar.
47
- // When absent, RichTextBlock returns null cleanly so the layout collapses
48
- // to just the product grid.
49
- const client = await getServerClient();
50
- const [storeInfo, intro] = await Promise.all([
51
- client.getStoreInfo().catch(() => null),
52
- client.content.richText.get('main').catch(() => null),
53
- ]);
54
-
55
- const baseUrl = await getCanonicalSiteUrl();
56
-
57
- return (
58
- <>
59
- {storeInfo && baseUrl ? <OrganizationJsonLd storeInfo={storeInfo} baseUrl={baseUrl} /> : null}
60
- {/* Referral landing. A referral link is `/?ref=<code>`, so it lands here.
61
- Renders nothing without a valid `?ref=`, and nothing at all on a store
62
- with referrals switched off. The Suspense boundary is required: the
63
- component reads `useSearchParams()`, which would otherwise opt this
64
- whole page out of static rendering. */}
65
- <Suspense fallback={null}>
66
- <ReferralGreeting className="mt-6" />
67
- </Suspense>
68
- <RichTextBlock
69
- block={intro}
70
- className="prose prose-sm dark:prose-invert mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"
71
- />
72
- <HomeClient />
73
- </>
74
- );
75
- }
1
+ import type { Metadata } from 'next';
2
+ import { Suspense } from 'react';
3
+ import { getServerClient } from '@/core/lib/brainerce.server';
4
+ import { getRegionId } from '@/core/lib/region.server';
5
+ import { HOME_FEATURED_LIMIT } from '@/core/lib/listing-query';
6
+ import { localePath } from '@/core/lib/locale-path';
7
+ import { buildMetaDescription } from '@/core/lib/seo';
8
+ import { OrganizationJsonLd } from '@/components/seo/organization-json-ld';
9
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
10
+ import { ListingSeedProvider, type HomeSeed } from '@/core/providers/listing-seed';
11
+ import { RichTextBlock } from '@/ui/layout/rich-text-block';
12
+ import { HomeClient } from '@/ui/home/home-client';
13
+ import { ReferralGreeting } from '@/components/referral/referral-greeting';
14
+
15
+ // `locale` exists only on a multi-language storefront (the `[locale]` segment);
16
+ // a single-language one has no params at all.
17
+ type Props = { params: Promise<{ locale?: string }> };
18
+
19
+ // Build homepage metadata server-side so Google, Facebook, WhatsApp etc.
20
+ // see a real <meta name="description"> on first request. Falls back to the
21
+ // store name when the merchant hasn't authored a meta description yet.
22
+ //
23
+ // The canonical lives HERE, not in the root layout: a layout-level canonical is
24
+ // inherited by every page that does not set its own, which told Google that the
25
+ // FAQ, the blog and every other page were duplicates of the homepage.
26
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
27
+ try {
28
+ const { locale } = await params;
29
+ const storeInfo = await (await getServerClient(locale)).getStoreInfo();
30
+ const description = buildMetaDescription(storeInfo.metaDescription) || undefined;
31
+ return {
32
+ title: storeInfo.name,
33
+ description,
34
+ alternates: { canonical: localePath(locale, '/') },
35
+ // `openGraph` replaces the layout's object wholesale rather than merging
36
+ // into it, so `siteName` has to be repeated or the tag disappears.
37
+ openGraph: {
38
+ title: storeInfo.name,
39
+ description,
40
+ siteName: storeInfo.name,
41
+ type: 'website',
42
+ },
43
+ twitter: {
44
+ card: 'summary_large_image',
45
+ title: storeInfo.name,
46
+ description,
47
+ },
48
+ };
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ export default async function HomePage({ params }: Props) {
55
+ const { locale } = await params;
56
+ // Fetch store info server-side so the JSON-LD is in the initial HTML —
57
+ // crawlers and AI Overviews see it without needing to execute JavaScript.
58
+ // Failure here is non-fatal: the client component re-fetches via the
59
+ // StoreProvider so the page still renders.
60
+ //
61
+ // Also fetch the merchant's homepage intro (RICH_TEXT key='main'). When
62
+ // present, it renders as a sanitized HTML block above the product grid —
63
+ // typical use is brand introduction, value-prop copy, or a seasonal
64
+ // announcement that needs more formatting than the AnnouncementBar.
65
+ // When absent, RichTextBlock returns null cleanly so the layout collapses
66
+ // to just the product grid.
67
+ const client = await getServerClient(locale);
68
+ const regionId = await getRegionId();
69
+ const [storeInfo, intro, featured] = await Promise.all([
70
+ client.getStoreInfo().catch(() => null),
71
+ client.content.richText.get('main').catch(() => null),
72
+ // The featured grid, fetched here so the initial HTML carries real
73
+ // products. Without it the page was a spinner until the browser ran
74
+ // JavaScript, which is all a non-rendering crawler ever saw of the
75
+ // homepage. A failure is non-fatal: the client hook fetches instead.
76
+ client
77
+ .getProducts({
78
+ limit: HOME_FEATURED_LIMIT,
79
+ sortBy: 'createdAt',
80
+ sortOrder: 'desc',
81
+ ...(regionId ? { regionId } : {}),
82
+ })
83
+ .catch(() => null),
84
+ ]);
85
+ const homeSeed: HomeSeed | null = featured ? { products: featured.data, regionId } : null;
86
+
87
+ const baseUrl = await getCanonicalSiteUrl();
88
+
89
+ return (
90
+ <>
91
+ {storeInfo && baseUrl ? <OrganizationJsonLd storeInfo={storeInfo} baseUrl={baseUrl} /> : null}
92
+ {/* Referral landing. A referral link is `/?ref=<code>`, so it lands here.
93
+ Renders nothing without a valid `?ref=`, and nothing at all on a store
94
+ with referrals switched off. The Suspense boundary is required: the
95
+ component reads `useSearchParams()`, which would otherwise opt this
96
+ whole page out of static rendering. */}
97
+ <Suspense fallback={null}>
98
+ <ReferralGreeting className="mt-6" />
99
+ </Suspense>
100
+ <RichTextBlock
101
+ block={intro}
102
+ className="prose prose-sm dark:prose-invert mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8"
103
+ />
104
+ <ListingSeedProvider home={homeSeed}>
105
+ <HomeClient />
106
+ </ListingSeedProvider>
107
+ </>
108
+ );
109
+ }
@@ -16,6 +16,7 @@ import type { Metadata } from 'next';
16
16
  import { notFound } from 'next/navigation';
17
17
 
18
18
  import { getServerClient } from '@/core/lib/brainerce.server';
19
+ import { localePath } from '@/core/lib/locale-path';
19
20
  import { sanitizeHtml } from '@/core/lib/sanitize';
20
21
  import { decodeSlug } from '@/core/lib/utils';
21
22
 
@@ -33,6 +34,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
33
34
  return {
34
35
  title: seo.title ?? page.data.title,
35
36
  description: seo.description,
37
+ // Canonical per page: the root layout no longer sets one, and the one it
38
+ // used to set named the homepage as the canonical of every page without its own.
39
+ alternates: { canonical: localePath(locale, `/pages/${slug}`) },
36
40
  openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
37
41
  };
38
42
  }
@@ -71,6 +75,9 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
71
75
  return {
72
76
  title: seo.title ?? page.data.title,
73
77
  description: seo.description,
78
+ // Canonical per page: the root layout no longer sets one, and the one it
79
+ // used to set named the homepage as the canonical of every page without its own.
80
+ alternates: { canonical: localePath(undefined, `/pages/${slug}`) },
74
81
  openGraph: seo.ogImage ? { images: [{ url: seo.ogImage }] } : undefined,
75
82
  };
76
83
  }