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.
- package/dist/index.js +3 -2
- package/messages/en.json +21 -0
- package/messages/he.json +21 -0
- package/package.json +3 -2
- package/templates/nextjs/base/.eslintrc.json +1 -0
- package/templates/nextjs/base/AI-GUIDE.md +35 -0
- package/templates/nextjs/base/TRANSLATIONS.md +6 -0
- package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
- package/templates/nextjs/base/scripts/connect.mjs +6 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
- package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
- package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
- package/templates/nextjs/base/src/app/page.tsx +109 -75
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
- package/templates/nextjs/base/src/app/products/page.tsx +184 -19
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
- package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
- package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
- package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
- package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
- package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
- package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
- package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
- 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
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
<ArticleJsonLd
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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 ?? '
|
|
63
|
+
` <title>${esc(info?.name ?? seo.blog ?? '')}</title>`,
|
|
60
64
|
` <link>${esc(`${baseUrl}/blog`)}</link>`,
|
|
61
|
-
` <description>${esc(info?.metaDescription ??
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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: '
|
|
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:
|
|
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
|
|
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 ?? '
|
|
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
|
|
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 ?? '
|
|
53
|
+
title: faq?.name ?? strings.title ?? '',
|
|
54
|
+
alternates: { canonical: localePath(undefined, '/faq') },
|
|
39
55
|
};
|
|
40
56
|
}
|
|
41
57
|
|