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
@@ -6,6 +6,12 @@
6
6
  * - Injects Article JSON-LD for Google rich snippets
7
7
  * - Body HTML is sanitized before rendering
8
8
  *
9
+ * One implementation for single- and multi-language stores: `locale` exists only
10
+ * under the `[locale]` segment and is `undefined` otherwise. Every string comes
11
+ * from the `blog` message namespace in the request locale; none is a
12
+ * `locale === 'he'` ternary, which gave every third language English and forced
13
+ * `dir="ltr"` onto Arabic.
14
+ *
9
15
  * Security: always pass blog content through sanitizeHtml before dangerouslySetInnerHTML.
10
16
  */
11
17
  import * as React from 'react';
@@ -16,14 +22,15 @@ import { CdnImage as Image } from '@/ui/shared/cdn-image';
16
22
  import { ArticleJsonLd } from '@/components/seo/article-json-ld';
17
23
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
18
24
 
25
+ import { defaultLocale } from '@/i18n';
19
26
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
27
+ import { localePath } from '@/core/lib/locale-path';
28
+ import { getServerNamespace } from '@/core/lib/server-messages';
20
29
  import { sanitizeHtml } from '@/core/lib/sanitize';
21
30
  import { decodeSlug } from '@/core/lib/utils';
22
31
 
23
-
24
- <% if (i18nEnabled) { %>
25
32
  type PageProps = {
26
- params: Promise<{ locale: string; slug: string }>;
33
+ params: Promise<{ locale?: string; slug: string }>;
27
34
  };
28
35
 
29
36
  export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
@@ -41,8 +48,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
41
48
  const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
42
49
  const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
43
50
  const SITE_URL = await getCanonicalSiteUrl();
44
- const canonicalPath =
45
- locale && locale !== defaultLoc ? `/${locale}/blog/${post.slug}` : `/blog/${post.slug}`;
51
+ const canonicalPath = localePath(locale, `/blog/${post.slug}`);
46
52
  const hreflangLanguages =
47
53
  supportedLocales.length > 1
48
54
  ? Object.fromEntries([
@@ -63,9 +69,12 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
63
69
  canonical: canonicalPath,
64
70
  ...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
65
71
  },
72
+ // `openGraph` replaces the root layout's object wholesale rather than merging
73
+ // into it, so `siteName` is repeated here.
66
74
  openGraph: {
67
75
  title: post.seoTitle ?? post.title,
68
76
  description: post.seoDescription ?? post.excerpt ?? undefined,
77
+ ...(storeInfo?.name ? { siteName: storeInfo.name } : {}),
69
78
  type: 'article',
70
79
  publishedTime: post.publishedAt ?? undefined,
71
80
  authors: post.author ? [post.author] : undefined,
@@ -95,29 +104,43 @@ export default async function BlogPostPage({ params }: PageProps) {
95
104
  // Default locale stays unprefixed (matches the sitemap/hreflang convention).
96
105
  const redirect = await client.resolveSlugRedirect('blog', slug);
97
106
  if (redirect) {
98
- const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
99
- const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
100
- permanentRedirect(`${prefix}/blog/${redirect.currentSlug}`);
107
+ permanentRedirect(localePath(locale, `/blog/${redirect.currentSlug}`));
101
108
  }
102
109
  notFound();
103
110
  }
104
111
 
105
- const isHe = locale === 'he';
106
- const dir = isHe ? 'rtl' : 'ltr';
107
- const backLabel = isHe ? '← חזרה לבלוג' : '← Back to blog';
108
- const SITE_URL = await getCanonicalSiteUrl();
109
- const articleUrl = `${SITE_URL}/blog/${post.slug}`;
112
+ const [SITE_URL, strings, seo] = await Promise.all([
113
+ getCanonicalSiteUrl(),
114
+ getServerNamespace('blog', locale),
115
+ getServerNamespace('seo', locale),
116
+ ]);
117
+ // The same URL the canonical tag names (locale prefix included), so the
118
+ // Article JSON-LD cannot point at a different page than the page declares.
119
+ const articleUrl = `${SITE_URL}${localePath(locale, `/blog/${post.slug}`)}`;
120
+ const breadcrumbs = [
121
+ { name: seo.home ?? '', url: `${SITE_URL}${localePath(locale, '/')}` },
122
+ { name: seo.blog ?? '', url: `${SITE_URL}${localePath(locale, '/blog')}` },
123
+ { name: post.title, url: articleUrl },
124
+ ];
110
125
 
111
126
  return (
112
- <main dir={dir} className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
113
- <ArticleJsonLd post={post} url={articleUrl} organizationName={storeInfo?.name} />
127
+ <main className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
128
+ <ArticleJsonLd
129
+ post={post}
130
+ url={articleUrl}
131
+ organizationName={storeInfo?.name}
132
+ breadcrumbs={breadcrumbs}
133
+ />
114
134
 
115
135
  {/* Back link */}
116
136
  <Link
117
- href="/blog"
137
+ href={localePath(locale, '/blog')}
118
138
  className="mb-6 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
119
139
  >
120
- {backLabel}
140
+ <span aria-hidden="true" className="rtl:rotate-180">
141
+ ←
142
+ </span>
143
+ {strings.backToBlog}
121
144
  </Link>
122
145
 
123
146
  {/* Category */}
@@ -139,7 +162,7 @@ export default async function BlogPostPage({ params }: PageProps) {
139
162
  {post.author && <span className="font-medium text-foreground">{post.author}</span>}
140
163
  {post.publishedAt && (
141
164
  <time dateTime={post.publishedAt}>
142
- {new Date(post.publishedAt).toLocaleDateString(isHe ? 'he-IL' : 'en-US', {
165
+ {new Date(post.publishedAt).toLocaleDateString(locale ?? defaultLocale, {
143
166
  year: 'numeric',
144
167
  month: 'long',
145
168
  day: 'numeric',
@@ -189,129 +212,3 @@ export default async function BlogPostPage({ params }: PageProps) {
189
212
  </main>
190
213
  );
191
214
  }
192
- <% } else { %>
193
- type PageProps = {
194
- params: Promise<{ slug: string }>;
195
- };
196
-
197
- export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
198
- const { slug: rawSlug } = await params;
199
- const slug = decodeSlug(rawSlug);
200
- const post = await (await getServerClient()).blog.getPost(slug).catch(() => null);
201
- if (!post) return { title: slug };
202
- return {
203
- title: post.seoTitle ?? post.title,
204
- description: post.seoDescription ?? post.excerpt,
205
- alternates: { canonical: `/blog/${post.slug}` },
206
- openGraph: {
207
- title: post.seoTitle ?? post.title,
208
- description: post.seoDescription ?? post.excerpt ?? undefined,
209
- type: 'article',
210
- publishedTime: post.publishedAt ?? undefined,
211
- authors: post.author ? [post.author] : undefined,
212
- images: post.ogImageUrl
213
- ? [{ url: post.ogImageUrl }]
214
- : post.coverImageUrl
215
- ? [{ url: post.coverImageUrl }]
216
- : undefined,
217
- },
218
- };
219
- }
220
-
221
- export default async function BlogPostPage({ params }: PageProps) {
222
- const { slug: rawSlug } = await params;
223
- const slug = decodeSlug(rawSlug);
224
- const client = await getServerClient();
225
- const [post, storeInfo] = await Promise.all([
226
- // getPost returns null only on a genuine 404; any other API failure (a
227
- // 403 from the channel's origin check, a network error) throws and
228
- // surfaces as the error it is instead of masquerading as a missing post.
229
- client.blog.getPost(slug),
230
- client.getStoreInfo().catch(() => null),
231
- ]);
232
- if (!post) {
233
- // The slug may have been renamed — 301 to the current slug so the old
234
- // URL keeps its ranking; genuine unknowns fall through to the 404 page.
235
- const redirect = await client.resolveSlugRedirect('blog', slug);
236
- if (redirect) permanentRedirect(`/blog/${redirect.currentSlug}`);
237
- notFound();
238
- }
239
-
240
- const SITE_URL = await getCanonicalSiteUrl();
241
- const articleUrl = `${SITE_URL}/blog/${post.slug}`;
242
-
243
- return (
244
- <main className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
245
- <ArticleJsonLd post={post} url={articleUrl} organizationName={storeInfo?.name} />
246
-
247
- <Link
248
- href="/blog"
249
- className="mb-6 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
250
- >
251
- ← Back to blog
252
- </Link>
253
-
254
- {post.category && (
255
- <div className="mb-3">
256
- <span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
257
- {post.category}
258
- </span>
259
- </div>
260
- )}
261
-
262
- <h1 className="mb-4 text-3xl font-bold tracking-tight text-foreground sm:text-4xl leading-tight">
263
- {post.title}
264
- </h1>
265
-
266
- <div className="mb-6 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground border-b border-border pb-6">
267
- {post.author && <span className="font-medium text-foreground">{post.author}</span>}
268
- {post.publishedAt && (
269
- <time dateTime={post.publishedAt}>
270
- {new Date(post.publishedAt).toLocaleDateString('en-US', {
271
- year: 'numeric',
272
- month: 'long',
273
- day: 'numeric',
274
- })}
275
- </time>
276
- )}
277
- {post.tags && post.tags.length > 0 && (
278
- <div className="flex flex-wrap gap-1.5">
279
- {post.tags.map((tag) => (
280
- <span
281
- key={tag}
282
- className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
283
- >
284
- {tag}
285
- </span>
286
- ))}
287
- </div>
288
- )}
289
- </div>
290
-
291
- {post.coverImageUrl && (
292
- <div className="mb-8 overflow-hidden rounded-xl">
293
- <Image
294
- src={post.coverImageUrl}
295
- alt={post.coverImageAlt ?? post.title}
296
- width={800}
297
- height={450}
298
- className="w-full object-cover"
299
- priority
300
- />
301
- </div>
302
- )}
303
-
304
- {post.excerpt && (
305
- <p className="mb-6 text-lg text-muted-foreground leading-relaxed font-light italic border-s-2 border-primary/40 ps-4">
306
- {post.excerpt}
307
- </p>
308
- )}
309
-
310
- <div
311
- className="prose prose-neutral dark:prose-invert max-w-none"
312
- dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.content) }}
313
- />
314
- </main>
315
- );
316
- }
317
- <% } %>
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Blog listing page — fetches published posts from the Brainerce blog API.
3
+ *
4
+ * Merchants manage posts in the Brainerce dashboard under Sell → Blog.
5
+ * The storefront renders them here. Each post links to /blog/[slug].
6
+ *
7
+ * Posts are only visible when status=PUBLISHED and publishedAt <= NOW().
8
+ * Category and tag filtering available via query params.
9
+ *
10
+ * One implementation for single- and multi-language stores: `locale` exists only
11
+ * under the `[locale]` segment and is `undefined` otherwise. Every string comes
12
+ * from the `seo` / `blog` message namespaces in the request locale; none is a
13
+ * `locale === 'he'` ternary, which gave every third language English.
14
+ */
15
+ import * as React from 'react';
16
+ import type { Metadata } from 'next';
17
+ import Link from 'next/link';
18
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
19
+
20
+ import { defaultLocale } from '@/i18n';
21
+ import { getServerClient } from '@/core/lib/brainerce.server';
22
+ import { localePath } from '@/core/lib/locale-path';
23
+ import { getServerNamespace } from '@/core/lib/server-messages';
24
+
25
+ type PageProps = {
26
+ params: Promise<{ locale?: string }>;
27
+ searchParams: Promise<{ category?: string; tag?: string; page?: string }>;
28
+ };
29
+
30
+ /** `/blog` plus whichever of category / tag / page apply, in a fixed order. */
31
+ function blogHref(
32
+ locale: string | undefined,
33
+ query: { category?: string; tag?: string; page?: number }
34
+ ): string {
35
+ const params = new URLSearchParams();
36
+ if (query.category) params.set('category', query.category);
37
+ if (query.tag) params.set('tag', query.tag);
38
+ if (query.page && query.page > 1) params.set('page', String(query.page));
39
+ const qs = params.toString();
40
+ return `${localePath(locale, '/blog')}${qs ? `?${qs}` : ''}`;
41
+ }
42
+
43
+ // The canonical is set here because the root layout no longer sets one: a
44
+ // layout-level canonical is inherited by every page without its own, and this
45
+ // page used to tell Google it was a duplicate of the homepage. Each page of the
46
+ // list is its own canonical (`?page=2` is not a copy of page 1).
47
+ export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
48
+ const { locale } = await params;
49
+ const { category, tag, page: pageParam } = await searchParams;
50
+ const seo = await getServerNamespace('seo', locale);
51
+ return {
52
+ title: seo.blog ?? '',
53
+ description: seo.blogDescription ?? '',
54
+ alternates: {
55
+ canonical: blogHref(locale, { category, tag, page: Number(pageParam ?? '1') || 1 }),
56
+ },
57
+ };
58
+ }
59
+
60
+ export default async function BlogListPage({ params, searchParams }: PageProps) {
61
+ const { locale } = await params;
62
+ const { category, tag, page: pageParam } = await searchParams;
63
+ const page = Number(pageParam ?? '1') || 1;
64
+
65
+ const client = await getServerClient(locale);
66
+ const [{ data: posts, meta }, seo, strings] = await Promise.all([
67
+ client.blog
68
+ .getPosts({ category, tag, page, limit: 12 })
69
+ .catch(() => ({ data: [], meta: { page: 1, limit: 12, total: 0, totalPages: 1 } })),
70
+ getServerNamespace('seo', locale),
71
+ getServerNamespace('blog', locale),
72
+ ]);
73
+ const dateLocale = locale ?? defaultLocale;
74
+
75
+ return (
76
+ <main className="mx-auto max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
77
+ <h1 className="mb-8 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
78
+ {seo.blog}
79
+ </h1>
80
+
81
+ {posts.length === 0 ? (
82
+ <p className="text-muted-foreground">{strings.empty}</p>
83
+ ) : (
84
+ <>
85
+ <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
86
+ {posts.map((post) => {
87
+ const postHref = localePath(locale, `/blog/${encodeURIComponent(post.slug)}`);
88
+ return (
89
+ <article
90
+ key={post.id}
91
+ className="group flex flex-col overflow-hidden rounded-xl border border-border/60 bg-card shadow-sm transition-shadow hover:shadow-md"
92
+ >
93
+ {post.coverImageUrl && (
94
+ <Link href={postHref} tabIndex={-1}>
95
+ <div className="relative aspect-[16/9] overflow-hidden">
96
+ <Image
97
+ src={post.coverImageUrl}
98
+ alt={post.coverImageAlt ?? post.title}
99
+ fill
100
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
101
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
102
+ />
103
+ </div>
104
+ </Link>
105
+ )}
106
+
107
+ <div className="flex flex-1 flex-col gap-3 p-5">
108
+ {/* Category badge */}
109
+ {post.category && (
110
+ <span className="w-fit rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
111
+ {post.category}
112
+ </span>
113
+ )}
114
+
115
+ {/* Title */}
116
+ <Link href={postHref}>
117
+ <h2 className="line-clamp-2 text-lg font-semibold leading-snug text-foreground hover:text-primary transition-colors">
118
+ {post.title}
119
+ </h2>
120
+ </Link>
121
+
122
+ {/* Excerpt */}
123
+ {post.excerpt && (
124
+ <p className="line-clamp-3 text-sm text-muted-foreground leading-relaxed">
125
+ {post.excerpt}
126
+ </p>
127
+ )}
128
+
129
+ {/* Meta: author + date */}
130
+ <div className="mt-auto flex items-center gap-2 pt-2 text-xs text-muted-foreground">
131
+ {post.author && <span>{post.author}</span>}
132
+ {post.author && post.publishedAt && <span>·</span>}
133
+ {post.publishedAt && (
134
+ <time dateTime={post.publishedAt}>
135
+ {new Date(post.publishedAt).toLocaleDateString(dateLocale, {
136
+ year: 'numeric',
137
+ month: 'short',
138
+ day: 'numeric',
139
+ })}
140
+ </time>
141
+ )}
142
+ </div>
143
+
144
+ <Link
145
+ href={postHref}
146
+ className="mt-1 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
147
+ >
148
+ {strings.readMore}
149
+ <span aria-hidden="true" className="rtl:rotate-180">
150
+ →
151
+ </span>
152
+ </Link>
153
+ </div>
154
+ </article>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ {/* Pagination */}
160
+ {meta.totalPages > 1 && (
161
+ <nav className="mt-10 flex items-center justify-center gap-4">
162
+ {page > 1 && (
163
+ <Link
164
+ href={blogHref(locale, { category, tag, page: page - 1 })}
165
+ className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
166
+ >
167
+ {strings.previous}
168
+ </Link>
169
+ )}
170
+ <span className="text-sm text-muted-foreground">
171
+ {page} / {meta.totalPages}
172
+ </span>
173
+ {page < meta.totalPages && (
174
+ <Link
175
+ href={blogHref(locale, { category, tag, page: page + 1 })}
176
+ className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted transition-colors"
177
+ >
178
+ {strings.next}
179
+ </Link>
180
+ )}
181
+ </nav>
182
+ )}
183
+ </>
184
+ )}
185
+ </main>
186
+ );
187
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { getServerClient } from '@/core/lib/brainerce.server';
17
17
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
18
+ import { getServerNamespace } from '@/core/lib/server-messages';
18
19
 
19
20
  export const revalidate = 3600;
20
21
 
@@ -30,9 +31,12 @@ export async function GET() {
30
31
  const baseUrl = await getCanonicalSiteUrl();
31
32
  const client = await getServerClient();
32
33
 
33
- const [info, posts] = await Promise.all([
34
+ const [info, posts, seo] = await Promise.all([
34
35
  client.getStoreInfo().catch(() => null),
35
36
  client.blog.getPosts({ limit: 50 }).catch(() => null),
37
+ // The feed is one document for every locale (served at the app root), so its
38
+ // fallback copy is the default language's, from the message files.
39
+ getServerNamespace('seo'),
36
40
  ]);
37
41
 
38
42
  const items = (posts?.data ?? [])
@@ -56,9 +60,9 @@ export async function GET() {
56
60
  '<?xml version="1.0" encoding="UTF-8"?>',
57
61
  '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
58
62
  ' <channel>',
59
- ` <title>${esc(info?.name ?? 'Blog')}</title>`,
63
+ ` <title>${esc(info?.name ?? seo.blog ?? '')}</title>`,
60
64
  ` <link>${esc(`${baseUrl}/blog`)}</link>`,
61
- ` <description>${esc(info?.metaDescription ?? `${info?.name ?? 'Store'} blog`)}</description>`,
65
+ ` <description>${esc(info?.metaDescription ?? seo.blogDescription ?? '')}</description>`,
62
66
  ` <atom:link href="${esc(`${baseUrl}/blog/rss.xml`)}" rel="self" type="application/rss+xml"/>`,
63
67
  items,
64
68
  ' </channel>',
@@ -3,6 +3,8 @@ import { notFound } from 'next/navigation';
3
3
  import { BrainerceError } from 'brainerce';
4
4
  import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
5
  import { getRegionId } from '@/core/lib/region.server';
6
+ import { localePath } from '@/core/lib/locale-path';
7
+ import { getServerNamespace } from '@/core/lib/server-messages';
6
8
  import { buildMetaDescription } from '@/core/lib/seo';
7
9
  import { sanitizeHtml } from '@/core/lib/sanitize';
8
10
  import { decodeSlug } from '@/core/lib/utils';
@@ -35,9 +37,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
35
37
  const description =
36
38
  category.metaDescription || buildMetaDescription(category.description) || category.name;
37
39
  const canonicalSlug = category.slug || slug;
38
- const canonicalPath = locale
39
- ? `/${locale}/category/${canonicalSlug}`
40
- : `/category/${canonicalSlug}`;
40
+ // `localePath`, not `/${locale}/...`: the default locale arrives as a real
41
+ // `params.locale` too, and its prefixed URL is the one the middleware
42
+ // 308-redirects away, so a canonical built that way names a redirecting URL.
43
+ const canonicalPath = localePath(locale, `/category/${canonicalSlug}`);
41
44
 
42
45
  // hreflang: category URLs use the same slug across locales (only the
43
46
  // locale prefix changes) — the same convention getCategorySitemapEntries
@@ -65,9 +68,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
65
68
  canonical: canonicalPath,
66
69
  ...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
67
70
  },
71
+ // `siteName` repeated: this object replaces the root layout's, not merges.
68
72
  openGraph: {
69
73
  title: category.name,
70
74
  description,
75
+ ...(storeInfo?.name ? { siteName: storeInfo.name } : {}),
71
76
  type: 'website',
72
77
  images: category.image ? [{ url: category.image, alt: category.name }] : [],
73
78
  },
@@ -80,7 +85,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
80
85
  };
81
86
  } catch (err) {
82
87
  if (err instanceof BrainerceError && err.statusCode === 404) {
83
- return { title: 'Category not found' };
88
+ const seo = await getServerNamespace('seo', locale);
89
+ return { title: seo.categoryNotFound ?? '' };
84
90
  }
85
91
  // Anything else (channel origin rejection, network failure) hits the page
86
92
  // render below too and surfaces there — don't stamp a misleading title.
@@ -114,23 +120,32 @@ export default async function CategoryPage({ params }: Props) {
114
120
  regionId: await getRegionId(),
115
121
  });
116
122
 
117
- const baseUrl = await getCanonicalSiteUrl();
118
- const url = `${baseUrl}/category/${category.slug || slug}`;
119
- const localePrefix = locale ? `/${locale}` : '';
123
+ const [baseUrl, seo] = await Promise.all([
124
+ getCanonicalSiteUrl(),
125
+ getServerNamespace('seo', locale),
126
+ ]);
127
+ const url = `${baseUrl}${localePath(locale, `/category/${category.slug || slug}`)}`;
128
+ // One trail, two renderings: the visible breadcrumb and the BreadcrumbList
129
+ // JSON-LD are built from the SAME items. An ancestor without a slug has no
130
+ // page to link to, so it is left out of both rather than linked to a 404.
120
131
  const breadcrumbItems = [
121
- { name: 'Home', href: `${localePrefix}/` },
132
+ { name: seo.home ?? '', href: localePath(locale, '/') },
122
133
  ...category.breadcrumb
123
134
  .filter((ancestor) => ancestor.slug)
124
135
  .map((ancestor) => ({
125
136
  name: ancestor.name,
126
- href: `${localePrefix}/category/${ancestor.slug}`,
137
+ href: localePath(locale, `/category/${ancestor.slug}`),
127
138
  })),
128
- { name: category.name },
139
+ { name: category.name, href: undefined as string | undefined },
129
140
  ];
141
+ const breadcrumbJsonLd = breadcrumbItems.map((item) => ({
142
+ name: item.name,
143
+ url: item.href ? `${baseUrl}${item.href}` : url,
144
+ }));
130
145
 
131
146
  return (
132
147
  <>
133
- <CategoryJsonLd category={category} url={url} />
148
+ <CategoryJsonLd category={category} url={url} breadcrumbs={breadcrumbJsonLd} />
134
149
  <div className="container mx-auto px-4 py-8">
135
150
  <Breadcrumbs items={breadcrumbItems} />
136
151
  <header className="mb-6">
@@ -12,17 +12,26 @@ import * as React from 'react';
12
12
  import type { Metadata } from 'next';
13
13
 
14
14
  import { getServerClient } from '@/core/lib/brainerce.server';
15
+ import { localePath } from '@/core/lib/locale-path';
16
+ import { getServerNamespace } from '@/core/lib/server-messages';
15
17
  import { FaqSection } from '@/ui/layout/faq-section';
16
18
  <% if (i18nEnabled) { %>
17
19
  type PageProps = {
18
20
  params: Promise<{ locale: string }>;
19
21
  };
20
22
 
23
+ // The canonical is set here because the root layout no longer sets one: a
24
+ // layout-level canonical is inherited by every page without its own, and this
25
+ // page used to tell Google it was a duplicate of the homepage.
21
26
  export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
22
27
  const { locale } = await params;
23
- const faq = await (await getServerClient(locale)).content.faq.get('main', locale).catch(() => null);
28
+ const [faq, strings] = await Promise.all([
29
+ (await getServerClient(locale)).content.faq.get('main', locale).catch(() => null),
30
+ getServerNamespace('faq', locale),
31
+ ]);
24
32
  return {
25
- title: faq?.name ?? 'FAQ',
33
+ title: faq?.name ?? strings.title ?? '',
34
+ alternates: { canonical: localePath(locale, '/faq') },
26
35
  };
27
36
  }
28
37
 
@@ -32,10 +41,17 @@ export default async function FaqPage({ params }: PageProps) {
32
41
  return <FaqSection faq={faq} />;
33
42
  }
34
43
  <% } else { %>
44
+ // The canonical is set here because the root layout no longer sets one: a
45
+ // layout-level canonical is inherited by every page without its own, and this
46
+ // page used to tell Google it was a duplicate of the homepage.
35
47
  export async function generateMetadata(): Promise<Metadata> {
36
- const faq = await (await getServerClient()).content.faq.get('main').catch(() => null);
48
+ const [faq, strings] = await Promise.all([
49
+ (await getServerClient()).content.faq.get('main').catch(() => null),
50
+ getServerNamespace('faq'),
51
+ ]);
37
52
  return {
38
- title: faq?.name ?? 'FAQ',
53
+ title: faq?.name ?? strings.title ?? '',
54
+ alternates: { canonical: localePath(undefined, '/faq') },
39
55
  };
40
56
  }
41
57