create-brainerce-store 1.53.0 → 1.55.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 +9 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +17 -31
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -0
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -157
- package/templates/nextjs/base/src/app/sitemap.ts +18 -1
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +7 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +564 -551
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -0
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -0
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -0
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +4 -1
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +56 -2
- package/templates/nextjs/base/src/core/lib/store-info.ts +3 -0
- package/templates/nextjs/designs/atelier/globals.css +1 -1
- package/templates/nextjs/designs/atelier/messages-patch/en.json +13 -13
- package/templates/nextjs/designs/atelier/messages-patch/he.json +15 -15
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +16 -13
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +1 -1
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.55.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -399,6 +399,9 @@ async function scaffold(options) {
|
|
|
399
399
|
* must keep using the escaped storeNameJs / storeNameEnv variants. */
|
|
400
400
|
storeName: cleanStoreName,
|
|
401
401
|
storeNameJs: toJsStringLiteral(cleanStoreName),
|
|
402
|
+
/** Pre-escaped for splicing into a JSON string value (no wrapping
|
|
403
|
+
* quotes) — used by the `{{storeName}}` token in messages-patch files. */
|
|
404
|
+
storeNameJsonEscaped: JSON.stringify(cleanStoreName).slice(1, -1),
|
|
402
405
|
titleTemplateJs: toJsStringLiteral(`%s | ${cleanStoreName}`),
|
|
403
406
|
storeNameEnv: toEnvLiteral(cleanStoreName),
|
|
404
407
|
currencyEnv: toEnvLiteral(cleanCurrency),
|
|
@@ -479,7 +482,11 @@ async function scaffold(options) {
|
|
|
479
482
|
if (!await import_fs_extra.default.pathExists(patchFile)) continue;
|
|
480
483
|
const targetFile = import_path.default.join(targetMessages, file);
|
|
481
484
|
const base = await import_fs_extra.default.readJson(targetFile);
|
|
482
|
-
const
|
|
485
|
+
const patchText = (await import_fs_extra.default.readFile(patchFile, "utf-8")).replace(
|
|
486
|
+
/\{\{storeName\}\}/g,
|
|
487
|
+
templateVars.storeNameJsonEscaped
|
|
488
|
+
);
|
|
489
|
+
const patch = JSON.parse(patchText);
|
|
483
490
|
await import_fs_extra.default.writeJson(targetFile, deepMerge(base, patch), { spaces: 2 });
|
|
484
491
|
}
|
|
485
492
|
}
|
package/package.json
CHANGED
|
@@ -13,11 +13,14 @@ import type { Metadata } from 'next';
|
|
|
13
13
|
import { notFound } from 'next/navigation';
|
|
14
14
|
import Link from 'next/link';
|
|
15
15
|
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
16
|
+
import { ArticleJsonLd } from '@/components/seo/article-json-ld';
|
|
16
17
|
|
|
17
18
|
import { getServerClient } from '@/core/lib/brainerce';
|
|
18
19
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
19
20
|
import { decodeSlug } from '@/core/lib/utils';
|
|
20
21
|
|
|
22
|
+
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
23
|
+
|
|
21
24
|
<% if (i18nEnabled) { %>
|
|
22
25
|
type PageProps = {
|
|
23
26
|
params: Promise<{ locale: string; slug: string }>;
|
|
@@ -49,30 +52,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
49
52
|
export default async function BlogPostPage({ params }: PageProps) {
|
|
50
53
|
const { locale, slug: rawSlug } = await params;
|
|
51
54
|
const slug = decodeSlug(rawSlug);
|
|
52
|
-
const
|
|
55
|
+
const client = getServerClient(locale);
|
|
56
|
+
const [post, storeInfo] = await Promise.all([
|
|
57
|
+
client.blog.getPost(slug).catch(() => null),
|
|
58
|
+
client.getStoreInfo().catch(() => null),
|
|
59
|
+
]);
|
|
53
60
|
if (!post) notFound();
|
|
54
61
|
|
|
55
62
|
const isHe = locale === 'he';
|
|
56
63
|
const dir = isHe ? 'rtl' : 'ltr';
|
|
57
64
|
const backLabel = isHe ? '← חזרה לבלוג' : '← Back to blog';
|
|
58
|
-
|
|
59
|
-
const articleJsonLd = {
|
|
60
|
-
'@context': 'https://schema.org',
|
|
61
|
-
'@type': 'Article',
|
|
62
|
-
headline: post.title,
|
|
63
|
-
description: post.excerpt ?? undefined,
|
|
64
|
-
image: post.coverImageUrl ?? undefined,
|
|
65
|
-
datePublished: post.publishedAt ?? undefined,
|
|
66
|
-
dateModified: post.updatedAt,
|
|
67
|
-
author: post.author ? { '@type': 'Person', name: post.author } : undefined,
|
|
68
|
-
};
|
|
65
|
+
const articleUrl = `${SITE_URL}/blog/${post.slug}`;
|
|
69
66
|
|
|
70
67
|
return (
|
|
71
68
|
<main dir={dir} className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
72
|
-
<
|
|
73
|
-
type="application/ld+json"
|
|
74
|
-
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd).replace(/</g, '\\u003c') }}
|
|
75
|
-
/>
|
|
69
|
+
<ArticleJsonLd post={post} url={articleUrl} organizationName={storeInfo?.name} />
|
|
76
70
|
|
|
77
71
|
{/* Back link */}
|
|
78
72
|
<Link
|
|
@@ -182,26 +176,18 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
182
176
|
export default async function BlogPostPage({ params }: PageProps) {
|
|
183
177
|
const { slug: rawSlug } = await params;
|
|
184
178
|
const slug = decodeSlug(rawSlug);
|
|
185
|
-
const
|
|
179
|
+
const client = getServerClient();
|
|
180
|
+
const [post, storeInfo] = await Promise.all([
|
|
181
|
+
client.blog.getPost(slug).catch(() => null),
|
|
182
|
+
client.getStoreInfo().catch(() => null),
|
|
183
|
+
]);
|
|
186
184
|
if (!post) notFound();
|
|
187
185
|
|
|
188
|
-
const
|
|
189
|
-
'@context': 'https://schema.org',
|
|
190
|
-
'@type': 'Article',
|
|
191
|
-
headline: post.title,
|
|
192
|
-
description: post.excerpt ?? undefined,
|
|
193
|
-
image: post.coverImageUrl ?? undefined,
|
|
194
|
-
datePublished: post.publishedAt ?? undefined,
|
|
195
|
-
dateModified: post.updatedAt,
|
|
196
|
-
author: post.author ? { '@type': 'Person', name: post.author } : undefined,
|
|
197
|
-
};
|
|
186
|
+
const articleUrl = `${SITE_URL}/blog/${post.slug}`;
|
|
198
187
|
|
|
199
188
|
return (
|
|
200
189
|
<main className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
201
|
-
<
|
|
202
|
-
type="application/ld+json"
|
|
203
|
-
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd).replace(/</g, '\\u003c') }}
|
|
204
|
-
/>
|
|
190
|
+
<ArticleJsonLd post={post} url={articleUrl} organizationName={storeInfo?.name} />
|
|
205
191
|
|
|
206
192
|
<Link
|
|
207
193
|
href="/blog"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
5
|
+
import { buildMetaDescription } from '@/core/lib/seo';
|
|
6
|
+
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
7
|
+
import { decodeSlug } from '@/core/lib/utils';
|
|
8
|
+
import { CategoryJsonLd } from '@/components/seo/category-json-ld';
|
|
9
|
+
import { Breadcrumbs } from '@/components/seo/breadcrumbs';
|
|
10
|
+
import { ProductGrid } from '@/ui/product/product-grid';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
params: Promise<{ slug: string; locale?: string }>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Category (collection) landing page — the highest-leverage organic-SEO
|
|
18
|
+
* surface: it ranks for broad "research intent" queries ("running shoes")
|
|
19
|
+
* that individual product pages never capture. Merchant-authored copy (from
|
|
20
|
+
* the dashboard SEO hub) renders BELOW the grid so products stay above the
|
|
21
|
+
* fold, and CollectionPage + BreadcrumbList structured data feeds rich
|
|
22
|
+
* results + AI answers. Products come from getProducts, so this page is one
|
|
23
|
+
* metadata read + one listing read.
|
|
24
|
+
*/
|
|
25
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
26
|
+
const { slug: rawSlug, locale } = await params;
|
|
27
|
+
const slug = decodeSlug(rawSlug);
|
|
28
|
+
try {
|
|
29
|
+
const category = await getServerClient(locale).getCategoryBySlug(slug);
|
|
30
|
+
const description =
|
|
31
|
+
category.metaDescription || buildMetaDescription(category.description) || category.name;
|
|
32
|
+
const canonicalSlug = category.slug || slug;
|
|
33
|
+
const canonicalPath = locale
|
|
34
|
+
? `/${locale}/category/${canonicalSlug}`
|
|
35
|
+
: `/category/${canonicalSlug}`;
|
|
36
|
+
return {
|
|
37
|
+
title: category.name,
|
|
38
|
+
description,
|
|
39
|
+
alternates: { canonical: canonicalPath },
|
|
40
|
+
openGraph: {
|
|
41
|
+
title: category.name,
|
|
42
|
+
description,
|
|
43
|
+
type: 'website',
|
|
44
|
+
images: category.image ? [{ url: category.image, alt: category.name }] : [],
|
|
45
|
+
},
|
|
46
|
+
twitter: {
|
|
47
|
+
card: 'summary_large_image',
|
|
48
|
+
title: category.name,
|
|
49
|
+
description,
|
|
50
|
+
images: category.image ? [category.image] : [],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return { title: 'Category not found' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function CategoryPage({ params }: Props) {
|
|
59
|
+
const { slug: rawSlug, locale } = await params;
|
|
60
|
+
const slug = decodeSlug(rawSlug);
|
|
61
|
+
const client = getServerClient(locale);
|
|
62
|
+
|
|
63
|
+
const category = await client.getCategoryBySlug(slug).catch(() => null);
|
|
64
|
+
if (!category) notFound();
|
|
65
|
+
|
|
66
|
+
// The listing endpoint owns pagination/FX/publish gating — reuse it rather
|
|
67
|
+
// than duplicating a product query here.
|
|
68
|
+
const { data: products } = await client
|
|
69
|
+
.getProducts({ categories: [category.id], limit: 48 })
|
|
70
|
+
.catch(() => ({ data: [] as Product[] }));
|
|
71
|
+
|
|
72
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
+
const url = `${baseUrl}/category/${category.slug || slug}`;
|
|
74
|
+
const localePrefix = locale ? `/${locale}` : '';
|
|
75
|
+
const breadcrumbItems = [
|
|
76
|
+
{ name: 'Home', href: `${localePrefix}/` },
|
|
77
|
+
...category.breadcrumb
|
|
78
|
+
.filter((ancestor) => ancestor.slug)
|
|
79
|
+
.map((ancestor) => ({
|
|
80
|
+
name: ancestor.name,
|
|
81
|
+
href: `${localePrefix}/category/${ancestor.slug}`,
|
|
82
|
+
})),
|
|
83
|
+
{ name: category.name },
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<>
|
|
88
|
+
<CategoryJsonLd category={category} url={url} />
|
|
89
|
+
<div className="container mx-auto px-4 py-8">
|
|
90
|
+
<Breadcrumbs items={breadcrumbItems} />
|
|
91
|
+
<header className="mb-6">
|
|
92
|
+
<h1 className="text-3xl font-bold tracking-tight">{category.name}</h1>
|
|
93
|
+
</header>
|
|
94
|
+
|
|
95
|
+
<ProductGrid products={products} />
|
|
96
|
+
|
|
97
|
+
{category.description ? (
|
|
98
|
+
<div
|
|
99
|
+
className="prose prose-neutral dark:prose-invert mt-12 max-w-none border-t pt-8"
|
|
100
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(category.description) }}
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
</>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexNow key file — GET /indexnow-key.txt
|
|
3
|
+
*
|
|
4
|
+
* Brainerce's SEO Autopilot pings IndexNow (instant search-engine indexing)
|
|
5
|
+
* whenever a blog post publishes. The ping includes
|
|
6
|
+
* `keyLocation: https://<your-domain>/indexnow-key.txt`, and search engines
|
|
7
|
+
* verify ownership by fetching this file and matching the key.
|
|
8
|
+
*
|
|
9
|
+
* The key comes from `getStoreInfo().seo.indexNowKey` — it is NOT a secret
|
|
10
|
+
* (the file is public by protocol design). Returns 404 until the platform
|
|
11
|
+
* generates a key for this sales channel, which is harmless.
|
|
12
|
+
*/
|
|
13
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
14
|
+
|
|
15
|
+
export const revalidate = 3600;
|
|
16
|
+
|
|
17
|
+
export async function GET() {
|
|
18
|
+
const info = await getServerClient()
|
|
19
|
+
.getStoreInfo()
|
|
20
|
+
.catch(() => null);
|
|
21
|
+
const key = info?.seo?.indexNowKey;
|
|
22
|
+
if (!key) return new Response(null, { status: 404 });
|
|
23
|
+
return new Response(key, {
|
|
24
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,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). Emerging convention: a concise markdown map of what
|
|
6
|
+
* the site is and where its key content lives, so AI crawlers ground answers
|
|
7
|
+
* in (and cite) the right pages.
|
|
8
|
+
*/
|
|
9
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
10
|
+
|
|
11
|
+
export const revalidate = 3600;
|
|
12
|
+
|
|
13
|
+
export async function GET() {
|
|
14
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
15
|
+
const client = getServerClient();
|
|
16
|
+
|
|
17
|
+
const [info, posts] = await Promise.all([
|
|
18
|
+
client.getStoreInfo().catch(() => null),
|
|
19
|
+
client.blog.getPosts({ limit: 20 }).catch(() => null),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const lines: string[] = [
|
|
23
|
+
`# ${info?.name ?? 'Store'}`,
|
|
24
|
+
'',
|
|
25
|
+
...(info?.metaDescription ? [`> ${info.metaDescription}`, ''] : []),
|
|
26
|
+
'## Key pages',
|
|
27
|
+
'',
|
|
28
|
+
`- [Products](${baseUrl}/products): full catalog`,
|
|
29
|
+
`- [Blog](${baseUrl}/blog): guides and articles`,
|
|
30
|
+
...(info?.contactEmail ? [`- Contact: ${info.contactEmail}`] : []),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
if (posts && posts.data.length > 0) {
|
|
34
|
+
lines.push('', '## Recent articles', '');
|
|
35
|
+
for (const post of posts.data) {
|
|
36
|
+
const summary = post.seoDescription ?? post.excerpt ?? '';
|
|
37
|
+
lines.push(`- [${post.title}](${baseUrl}/blog/${post.slug})${summary ? `: ${summary}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Response(lines.join('\n') + '\n', {
|
|
42
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -1,157 +1,175 @@
|
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const slug =
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
product.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'og:price:
|
|
114
|
-
'
|
|
115
|
-
'product:price:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
'product:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const slug =
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
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
|
+
let product;
|
|
136
|
+
try {
|
|
137
|
+
const client = getServerClient(locale);
|
|
138
|
+
product = await client.getProductBySlug(slug);
|
|
139
|
+
} catch {
|
|
140
|
+
notFound();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
144
|
+
const productUrl = `${baseUrl}/products/${slug}`;
|
|
145
|
+
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
146
|
+
// collapses both into one backend request per render. resolveCurrency owns
|
|
147
|
+
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
148
|
+
const storeInfo = await fetchStoreInfo(locale);
|
|
149
|
+
const currency = resolveCurrency(storeInfo);
|
|
150
|
+
const localePrefix = locale ? `/${locale}` : '';
|
|
151
|
+
const primaryCategory = product.categories?.find((c) => c.slug);
|
|
152
|
+
const breadcrumbItems = [
|
|
153
|
+
{ name: 'Home', href: `${localePrefix}/` },
|
|
154
|
+
...(primaryCategory
|
|
155
|
+
? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
|
|
156
|
+
: []),
|
|
157
|
+
{ name: product.name },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<ProductJsonLd
|
|
163
|
+
product={product}
|
|
164
|
+
url={productUrl}
|
|
165
|
+
currency={currency}
|
|
166
|
+
shipping={storeInfo?.shipping}
|
|
167
|
+
/>
|
|
168
|
+
<div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
|
|
169
|
+
<Breadcrumbs items={breadcrumbItems} />
|
|
170
|
+
</div>
|
|
171
|
+
<ProductClientSection product={product} />
|
|
172
|
+
<ReviewsSection productId={product.id} />
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|