create-brainerce-store 1.63.0 → 1.64.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 +23 -3
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +51 -5
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +132 -106
- package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +20 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +67 -44
- package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
- package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +187 -175
- package/templates/nextjs/base/src/app/robots.ts +73 -14
- package/templates/nextjs/base/src/app/sitemap.ts +89 -66
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +71 -179
- package/templates/nextjs/base/src/core/lib/store-info.ts +8 -0
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +3 -1
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +20 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +3 -1
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +3 -1
|
@@ -1,44 +1,67 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* llms.txt — GET /llms.txt
|
|
3
|
-
*
|
|
4
|
-
* A machine-readable site summary for AI answer engines (ChatGPT, Perplexity,
|
|
5
|
-
* Claude, AI Overviews)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt — GET /llms.txt
|
|
3
|
+
*
|
|
4
|
+
* A machine-readable site summary for AI answer engines (ChatGPT, Perplexity,
|
|
5
|
+
* Claude, AI Overviews): a concise markdown map of what the site is and where
|
|
6
|
+
* its key content lives, so AI crawlers ground answers in (and cite) the
|
|
7
|
+
* right pages. /agents.md is the richer agent-facing companion (the newer
|
|
8
|
+
* convention) — keep the two consistent.
|
|
9
|
+
*/
|
|
10
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
11
|
+
|
|
12
|
+
export const revalidate = 3600;
|
|
13
|
+
|
|
14
|
+
interface CategoryNodeLike {
|
|
15
|
+
name: string;
|
|
16
|
+
slug?: string | null;
|
|
17
|
+
children?: CategoryNodeLike[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function GET() {
|
|
21
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
22
|
+
const client = getServerClient();
|
|
23
|
+
|
|
24
|
+
const [info, posts, categoriesRes] = await Promise.all([
|
|
25
|
+
client.getStoreInfo().catch(() => null),
|
|
26
|
+
client.blog.getPosts({ limit: 20 }).catch(() => null),
|
|
27
|
+
client.getCategories().catch(() => null),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const topCategories: CategoryNodeLike[] = (
|
|
31
|
+
(categoriesRes?.categories as CategoryNodeLike[] | undefined) ?? []
|
|
32
|
+
).filter((c) => c.slug);
|
|
33
|
+
|
|
34
|
+
const lines: string[] = [
|
|
35
|
+
`# ${info?.name ?? 'Store'}`,
|
|
36
|
+
'',
|
|
37
|
+
...(info?.metaDescription ? [`> ${info.metaDescription}`, ''] : []),
|
|
38
|
+
'## Key pages',
|
|
39
|
+
'',
|
|
40
|
+
`- [Products](${baseUrl}/products): full catalog`,
|
|
41
|
+
...topCategories
|
|
42
|
+
.slice(0, 20)
|
|
43
|
+
.map((c) => `- [${c.name}](${baseUrl}/category/${c.slug}): category`),
|
|
44
|
+
`- [Blog](${baseUrl}/blog): guides and articles`,
|
|
45
|
+
`- [FAQ](${baseUrl}/faq)`,
|
|
46
|
+
`- [Contact](${baseUrl}/contact)`,
|
|
47
|
+
...(info?.contactEmail ? [`- Contact email: ${info.contactEmail}`] : []),
|
|
48
|
+
'',
|
|
49
|
+
'## Machine-readable surfaces',
|
|
50
|
+
'',
|
|
51
|
+
`- [Agent guide](${baseUrl}/agents.md)`,
|
|
52
|
+
`- [Sitemap](${baseUrl}/sitemap.xml)`,
|
|
53
|
+
`- [Blog RSS](${baseUrl}/blog/rss.xml)`,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
if (posts && posts.data.length > 0) {
|
|
57
|
+
lines.push('', '## Recent articles', '');
|
|
58
|
+
for (const post of posts.data) {
|
|
59
|
+
const summary = post.seoDescription ?? post.excerpt ?? '';
|
|
60
|
+
lines.push(`- [${post.title}](${baseUrl}/blog/${post.slug})${summary ? `: ${summary}` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return new Response(lines.join('\n') + '\n', {
|
|
65
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 404 page — rendered for unknown URLs and for `notFound()` calls from
|
|
5
|
+
* product/category/blog pages (deleted products, renamed slugs). A branded
|
|
6
|
+
* 404 with a path back to the catalog keeps the visitor (and their SEO
|
|
7
|
+
* equity) on the site instead of bouncing off the framework default.
|
|
8
|
+
*/
|
|
9
|
+
export default function NotFound() {
|
|
10
|
+
return (
|
|
11
|
+
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-24 text-center">
|
|
12
|
+
<p className="text-sm font-medium text-muted-foreground">404</p>
|
|
13
|
+
<h1 className="mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
|
14
|
+
<%- language === 'he' ? 'העמוד לא נמצא' : 'Page not found' %>
|
|
15
|
+
</h1>
|
|
16
|
+
<p className="mt-4 text-muted-foreground">
|
|
17
|
+
<%- language === 'he' ? 'יכול להיות שהמוצר הוסר או שהכתובת השתנתה.' : 'The product may have been removed, or the address may have changed.' %>
|
|
18
|
+
</p>
|
|
19
|
+
<div className="mt-8 flex gap-4">
|
|
20
|
+
<Link
|
|
21
|
+
href="/"
|
|
22
|
+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
|
23
|
+
>
|
|
24
|
+
<%- language === 'he' ? 'לדף הבית' : 'Go home' %>
|
|
25
|
+
</Link>
|
|
26
|
+
<Link
|
|
27
|
+
href="/products"
|
|
28
|
+
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
|
29
|
+
>
|
|
30
|
+
<%- language === 'he' ? 'לכל המוצרים' : 'Browse products' %>
|
|
31
|
+
</Link>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ImageResponse } from 'next/og';
|
|
2
|
+
import { fetchStoreInfo } from '@/core/lib/brainerce';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default Open Graph card for every page that doesn't set its own og:image
|
|
6
|
+
* (home, /products, /faq, /contact, content pages without an OG image).
|
|
7
|
+
* Product / category / blog pages override this with their real images.
|
|
8
|
+
*
|
|
9
|
+
* Without this file, any page lacking an image gets NO social card at all —
|
|
10
|
+
* a bare-domain link in WhatsApp/Facebook/X renders as plain text.
|
|
11
|
+
*/
|
|
12
|
+
export const size = { width: 1200, height: 630 };
|
|
13
|
+
export const contentType = 'image/png';
|
|
14
|
+
export const alt = 'Store';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Satori (the OG renderer) ships a Latin-only default font — a Hebrew or
|
|
18
|
+
* Arabic store name would render as tofu (□□□). Fetch a Noto Sans variant
|
|
19
|
+
* with the needed coverage at request time; fetching the CSS without a
|
|
20
|
+
* browser UA makes Google Fonts return TTF (which Satori accepts — it can't
|
|
21
|
+
* parse woff2). Cached by the framework; on any failure we render without
|
|
22
|
+
* text rather than shipping tofu.
|
|
23
|
+
*/
|
|
24
|
+
async function loadFont(text: string): Promise<ArrayBuffer | null> {
|
|
25
|
+
try {
|
|
26
|
+
const css = await fetch(
|
|
27
|
+
`https://fonts.googleapis.com/css2?family=Noto+Sans+Hebrew:wght@700&family=Noto+Sans:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
|
28
|
+
{ cache: 'force-cache' }
|
|
29
|
+
).then((r) => r.text());
|
|
30
|
+
const url = css.match(/src:\s*url\((.+?)\)\s*format\(['"](?:truetype|opentype)['"]\)/)?.[1];
|
|
31
|
+
if (!url) return null;
|
|
32
|
+
return await fetch(url, { cache: 'force-cache' }).then((r) => r.arrayBuffer());
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Basic Latin through Latin Extended-B (U+0020–U+024F) plus whitespace,
|
|
39
|
+
// punctuation, and digits — the coverage of Satori's bundled default font.
|
|
40
|
+
const LATIN_ONLY = /^[ -ɏ\s\p{P}\p{N}]*$/u;
|
|
41
|
+
|
|
42
|
+
export default async function OpenGraphImage() {
|
|
43
|
+
const info = await fetchStoreInfo();
|
|
44
|
+
const name = info?.name?.trim() || '';
|
|
45
|
+
const description = info?.metaDescription?.trim() || '';
|
|
46
|
+
const logo = info?.logo || null;
|
|
47
|
+
|
|
48
|
+
const text = `${name} ${description}`.trim();
|
|
49
|
+
const font = text ? await loadFont(text) : null;
|
|
50
|
+
// Default font covers Latin only — if the font fetch failed and the text
|
|
51
|
+
// needs more coverage, drop the text instead of rendering tofu.
|
|
52
|
+
const canRenderText = Boolean(text) && (font !== null || LATIN_ONLY.test(text));
|
|
53
|
+
|
|
54
|
+
return new ImageResponse(
|
|
55
|
+
<div
|
|
56
|
+
style={{
|
|
57
|
+
width: '100%',
|
|
58
|
+
height: '100%',
|
|
59
|
+
display: 'flex',
|
|
60
|
+
flexDirection: 'column',
|
|
61
|
+
alignItems: 'center',
|
|
62
|
+
justifyContent: 'center',
|
|
63
|
+
gap: 32,
|
|
64
|
+
background: 'linear-gradient(135deg, #111113 0%, #1c1c22 55%, #26262e 100%)',
|
|
65
|
+
color: '#fafafa',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{logo ? (
|
|
69
|
+
<img src={logo} alt="" style={{ maxHeight: 220, maxWidth: 520, objectFit: 'contain' }} />
|
|
70
|
+
) : null}
|
|
71
|
+
{canRenderText && name ? (
|
|
72
|
+
<div
|
|
73
|
+
style={{
|
|
74
|
+
display: 'flex',
|
|
75
|
+
fontSize: logo ? 56 : 84,
|
|
76
|
+
fontWeight: 700,
|
|
77
|
+
textAlign: 'center',
|
|
78
|
+
maxWidth: 1000,
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{name}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
{canRenderText && description ? (
|
|
85
|
+
<div
|
|
86
|
+
style={{
|
|
87
|
+
display: 'flex',
|
|
88
|
+
fontSize: 32,
|
|
89
|
+
color: '#a1a1aa',
|
|
90
|
+
textAlign: 'center',
|
|
91
|
+
maxWidth: 960,
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
{description.length > 120 ? `${description.slice(0, 119)}…` : description}
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
</div>,
|
|
98
|
+
{
|
|
99
|
+
...size,
|
|
100
|
+
...(font
|
|
101
|
+
? {
|
|
102
|
+
fonts: [
|
|
103
|
+
{ name: 'Noto Sans', data: font, weight: 700 as const, style: 'normal' as const },
|
|
104
|
+
],
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
}
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -1,175 +1,187 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import { notFound } from 'next/navigation';
|
|
3
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
-
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
5
|
-
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
6
|
-
import { buildMetaDescription } from '@/core/lib/seo';
|
|
7
|
-
import { decodeSlug } from '@/core/lib/utils';
|
|
8
|
-
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
-
import { Breadcrumbs } from '@/components/seo/breadcrumbs';
|
|
10
|
-
import { ReviewsSection } from '@/ui/product/reviews-section';
|
|
11
|
-
import { ProductClientSection } from '@/ui/product/product-client-section';
|
|
12
|
-
|
|
13
|
-
type Props = {
|
|
14
|
-
params: Promise<{ slug: string; locale?: string }>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function buildHreflang(
|
|
18
|
-
baseUrl: string,
|
|
19
|
-
baseSlug: string,
|
|
20
|
-
localeSlugs: Record<string, string>,
|
|
21
|
-
locales: string[],
|
|
22
|
-
defaultLoc: string
|
|
23
|
-
): Record<string, string> {
|
|
24
|
-
const langs: Record<string, string> = {};
|
|
25
|
-
for (const loc of locales) {
|
|
26
|
-
const locSlug = localeSlugs[loc] || baseSlug;
|
|
27
|
-
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
28
|
-
langs[loc] = `${baseUrl}${path}`;
|
|
29
|
-
}
|
|
30
|
-
// x-default points to the default-locale canonical (no prefix)
|
|
31
|
-
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
32
|
-
return langs;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
36
|
-
const { slug: rawSlug, locale } = await params;
|
|
37
|
-
const slug = decodeSlug(rawSlug);
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const client = getServerClient(locale);
|
|
41
|
-
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
42
|
-
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
43
|
-
// silently emit a wrong currency when the API is briefly unreachable).
|
|
44
|
-
const [product, storeInfo] = await Promise.all([
|
|
45
|
-
client.getProductBySlug(slug),
|
|
46
|
-
fetchStoreInfo(locale),
|
|
47
|
-
]);
|
|
48
|
-
const imageUrl = product.images?.[0]?.url;
|
|
49
|
-
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
50
|
-
// version of the visible description, then product name. We must NEVER
|
|
51
|
-
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
52
|
-
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
53
|
-
const seoDescription =
|
|
54
|
-
(product as { seoDescription?: string | null }).seoDescription ||
|
|
55
|
-
buildMetaDescription(product.description) ||
|
|
56
|
-
product.name;
|
|
57
|
-
|
|
58
|
-
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
59
|
-
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
60
|
-
// here (the previous bug) causes price-zero link cards. Use the real
|
|
61
|
-
// effective price from the SDK helper.
|
|
62
|
-
const priceInfo = getProductPriceInfo(product);
|
|
63
|
-
const currency = resolveCurrency(storeInfo);
|
|
64
|
-
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
65
|
-
const inStock = product.inventory?.canPurchase !== false;
|
|
66
|
-
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
67
|
-
|
|
68
|
-
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
69
|
-
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
70
|
-
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
71
|
-
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
72
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
-
const baseSlug = product.slug || slug;
|
|
74
|
-
const localeSlugs = product.localeSlugs ?? {};
|
|
75
|
-
|
|
76
|
-
// Canonical: use the locale-specific slug for this locale when available.
|
|
77
|
-
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
78
|
-
const canonicalPath =
|
|
79
|
-
locale && locale !== defaultLoc
|
|
80
|
-
? `/${locale}/products/${canonicalSlug}`
|
|
81
|
-
: `/products/${canonicalSlug}`;
|
|
82
|
-
|
|
83
|
-
const hreflangLanguages =
|
|
84
|
-
supportedLocales.length > 1
|
|
85
|
-
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
86
|
-
: undefined;
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
title: seoTitle,
|
|
90
|
-
description: seoDescription,
|
|
91
|
-
alternates: {
|
|
92
|
-
canonical: canonicalPath,
|
|
93
|
-
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
94
|
-
},
|
|
95
|
-
openGraph: {
|
|
96
|
-
title: seoTitle,
|
|
97
|
-
description: seoDescription,
|
|
98
|
-
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
99
|
-
type: 'website',
|
|
100
|
-
},
|
|
101
|
-
twitter: {
|
|
102
|
-
card: 'summary_large_image',
|
|
103
|
-
title: seoTitle,
|
|
104
|
-
description: seoDescription,
|
|
105
|
-
images: imageUrl ? [imageUrl] : [],
|
|
106
|
-
},
|
|
107
|
-
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
108
|
-
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
109
|
-
// can't determine a positive amount, rather than shipping "0".
|
|
110
|
-
other: {
|
|
111
|
-
...(priceAmount
|
|
112
|
-
? {
|
|
113
|
-
'og:price:amount': priceAmount,
|
|
114
|
-
'og:price:currency': currency,
|
|
115
|
-
'product:price:amount': priceAmount,
|
|
116
|
-
'product:price:currency': currency,
|
|
117
|
-
}
|
|
118
|
-
: {}),
|
|
119
|
-
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
120
|
-
'product:condition': 'new',
|
|
121
|
-
...(brandName ? { 'product:brand': brandName } : {}),
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
} catch {
|
|
125
|
-
return {
|
|
126
|
-
title: 'Product not found',
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export default async function ProductDetailPage({ params }: Props) {
|
|
132
|
-
const { slug: rawSlug, locale } = await params;
|
|
133
|
-
const slug = decodeSlug(rawSlug);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
product = await client.getProductBySlug(slug);
|
|
139
|
-
} catch {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound, permanentRedirect } from 'next/navigation';
|
|
3
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
5
|
+
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
6
|
+
import { buildMetaDescription } from '@/core/lib/seo';
|
|
7
|
+
import { decodeSlug } from '@/core/lib/utils';
|
|
8
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
+
import { Breadcrumbs } from '@/components/seo/breadcrumbs';
|
|
10
|
+
import { ReviewsSection } from '@/ui/product/reviews-section';
|
|
11
|
+
import { ProductClientSection } from '@/ui/product/product-client-section';
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
params: Promise<{ slug: string; locale?: string }>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function buildHreflang(
|
|
18
|
+
baseUrl: string,
|
|
19
|
+
baseSlug: string,
|
|
20
|
+
localeSlugs: Record<string, string>,
|
|
21
|
+
locales: string[],
|
|
22
|
+
defaultLoc: string
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
const langs: Record<string, string> = {};
|
|
25
|
+
for (const loc of locales) {
|
|
26
|
+
const locSlug = localeSlugs[loc] || baseSlug;
|
|
27
|
+
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
28
|
+
langs[loc] = `${baseUrl}${path}`;
|
|
29
|
+
}
|
|
30
|
+
// x-default points to the default-locale canonical (no prefix)
|
|
31
|
+
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
32
|
+
return langs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
36
|
+
const { slug: rawSlug, locale } = await params;
|
|
37
|
+
const slug = decodeSlug(rawSlug);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const client = getServerClient(locale);
|
|
41
|
+
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
42
|
+
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
43
|
+
// silently emit a wrong currency when the API is briefly unreachable).
|
|
44
|
+
const [product, storeInfo] = await Promise.all([
|
|
45
|
+
client.getProductBySlug(slug),
|
|
46
|
+
fetchStoreInfo(locale),
|
|
47
|
+
]);
|
|
48
|
+
const imageUrl = product.images?.[0]?.url;
|
|
49
|
+
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
50
|
+
// version of the visible description, then product name. We must NEVER
|
|
51
|
+
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
52
|
+
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
53
|
+
const seoDescription =
|
|
54
|
+
(product as { seoDescription?: string | null }).seoDescription ||
|
|
55
|
+
buildMetaDescription(product.description) ||
|
|
56
|
+
product.name;
|
|
57
|
+
|
|
58
|
+
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
59
|
+
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
60
|
+
// here (the previous bug) causes price-zero link cards. Use the real
|
|
61
|
+
// effective price from the SDK helper.
|
|
62
|
+
const priceInfo = getProductPriceInfo(product);
|
|
63
|
+
const currency = resolveCurrency(storeInfo);
|
|
64
|
+
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
65
|
+
const inStock = product.inventory?.canPurchase !== false;
|
|
66
|
+
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
67
|
+
|
|
68
|
+
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
69
|
+
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
70
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
71
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
72
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
+
const baseSlug = product.slug || slug;
|
|
74
|
+
const localeSlugs = product.localeSlugs ?? {};
|
|
75
|
+
|
|
76
|
+
// Canonical: use the locale-specific slug for this locale when available.
|
|
77
|
+
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
78
|
+
const canonicalPath =
|
|
79
|
+
locale && locale !== defaultLoc
|
|
80
|
+
? `/${locale}/products/${canonicalSlug}`
|
|
81
|
+
: `/products/${canonicalSlug}`;
|
|
82
|
+
|
|
83
|
+
const hreflangLanguages =
|
|
84
|
+
supportedLocales.length > 1
|
|
85
|
+
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: seoTitle,
|
|
90
|
+
description: seoDescription,
|
|
91
|
+
alternates: {
|
|
92
|
+
canonical: canonicalPath,
|
|
93
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
94
|
+
},
|
|
95
|
+
openGraph: {
|
|
96
|
+
title: seoTitle,
|
|
97
|
+
description: seoDescription,
|
|
98
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
99
|
+
type: 'website',
|
|
100
|
+
},
|
|
101
|
+
twitter: {
|
|
102
|
+
card: 'summary_large_image',
|
|
103
|
+
title: seoTitle,
|
|
104
|
+
description: seoDescription,
|
|
105
|
+
images: imageUrl ? [imageUrl] : [],
|
|
106
|
+
},
|
|
107
|
+
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
108
|
+
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
109
|
+
// can't determine a positive amount, rather than shipping "0".
|
|
110
|
+
other: {
|
|
111
|
+
...(priceAmount
|
|
112
|
+
? {
|
|
113
|
+
'og:price:amount': priceAmount,
|
|
114
|
+
'og:price:currency': currency,
|
|
115
|
+
'product:price:amount': priceAmount,
|
|
116
|
+
'product:price:currency': currency,
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
120
|
+
'product:condition': 'new',
|
|
121
|
+
...(brandName ? { 'product:brand': brandName } : {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return {
|
|
126
|
+
title: 'Product not found',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
132
|
+
const { slug: rawSlug, locale } = await params;
|
|
133
|
+
const slug = decodeSlug(rawSlug);
|
|
134
|
+
|
|
135
|
+
const client = getServerClient(locale);
|
|
136
|
+
let product;
|
|
137
|
+
try {
|
|
138
|
+
product = await client.getProductBySlug(slug);
|
|
139
|
+
} catch {
|
|
140
|
+
// The slug may have been RENAMED — the platform records every rename.
|
|
141
|
+
// 301 the old URL to the current slug so its ranking and inbound links
|
|
142
|
+
// carry over; genuine unknowns fall through to the 404 page. Default
|
|
143
|
+
// locale stays unprefixed (matches the sitemap/hreflang convention); the
|
|
144
|
+
// destination page's canonical then points crawlers at the localized URL.
|
|
145
|
+
const redirect = await client.resolveSlugRedirect('product', slug);
|
|
146
|
+
if (redirect) {
|
|
147
|
+
const info = await fetchStoreInfo(locale);
|
|
148
|
+
const defaultLoc = info?.i18n?.defaultLocale ?? info?.language ?? '';
|
|
149
|
+
const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
|
|
150
|
+
permanentRedirect(`${prefix}/products/${redirect.currentSlug}`);
|
|
151
|
+
}
|
|
152
|
+
notFound();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
156
|
+
const productUrl = `${baseUrl}/products/${slug}`;
|
|
157
|
+
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
158
|
+
// collapses both into one backend request per render. resolveCurrency owns
|
|
159
|
+
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
160
|
+
const storeInfo = await fetchStoreInfo(locale);
|
|
161
|
+
const currency = resolveCurrency(storeInfo);
|
|
162
|
+
const localePrefix = locale ? `/${locale}` : '';
|
|
163
|
+
const primaryCategory = product.categories?.find((c) => c.slug);
|
|
164
|
+
const breadcrumbItems = [
|
|
165
|
+
{ name: 'Home', href: `${localePrefix}/` },
|
|
166
|
+
...(primaryCategory
|
|
167
|
+
? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
|
|
168
|
+
: []),
|
|
169
|
+
{ name: product.name },
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<>
|
|
174
|
+
<ProductJsonLd
|
|
175
|
+
product={product}
|
|
176
|
+
url={productUrl}
|
|
177
|
+
currency={currency}
|
|
178
|
+
shipping={storeInfo?.shipping}
|
|
179
|
+
/>
|
|
180
|
+
<div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
|
|
181
|
+
<Breadcrumbs items={breadcrumbItems} />
|
|
182
|
+
</div>
|
|
183
|
+
<ProductClientSection product={product} />
|
|
184
|
+
<ReviewsSection productId={product.id} />
|
|
185
|
+
</>
|
|
186
|
+
);
|
|
187
|
+
}
|