create-brainerce-store 1.63.0 → 1.65.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 +32 -6
- 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
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.65.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"
|
|
@@ -195,7 +195,18 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
|
|
|
195
195
|
// 1.55 adds initTracking() + trackMarketingEvent(). The scaffolded layout
|
|
196
196
|
// calls initTracking() and the product/checkout/confirmation pages call
|
|
197
197
|
// trackMarketingEvent(), so an older SDK fails type-check at scaffold time.
|
|
198
|
-
|
|
198
|
+
// 1.56 adds getProductSitemapEntries()/getSitemapProducts() — the scaffolded
|
|
199
|
+
// app/sitemap.ts calls the helper (fixes the silent 100-product sitemap
|
|
200
|
+
// truncation) — plus resolveSlugRedirect(), which the scaffolded product and
|
|
201
|
+
// blog not-found paths call to 301 renamed slugs, and StoreInfo.seo gains
|
|
202
|
+
// googleSiteVerification, which the scaffolded root layout renders as a
|
|
203
|
+
// meta tag. Older SDKs fail type-check at scaffold time.
|
|
204
|
+
// 1.57 adds automatic traffic attribution (brainerce_attr capture,
|
|
205
|
+
// auto-attached at setCheckoutCustomer/setShippingAddress) — powers the
|
|
206
|
+
// dashboard's "orders from ChatGPT/Google/…" reporting. No template code
|
|
207
|
+
// depends on it (fully automatic), but scaffolds should ship the SDK that
|
|
208
|
+
// actually reports attribution.
|
|
209
|
+
brainerce: "^1.57.0",
|
|
199
210
|
"isomorphic-dompurify": "^3.8.0"
|
|
200
211
|
});
|
|
201
212
|
|
|
@@ -465,6 +476,7 @@ async function scaffold(options) {
|
|
|
465
476
|
const messagesRoot = findMessagesDir(__dirname);
|
|
466
477
|
const targetMessages = import_path.default.join(targetDir, "messages");
|
|
467
478
|
await import_fs_extra.default.ensureDir(targetMessages);
|
|
479
|
+
const safeLanguage = isValidLocale(options.language) ? options.language : "en";
|
|
468
480
|
if (isMultiLocale) {
|
|
469
481
|
for (const locale of supportedLocales) {
|
|
470
482
|
const srcFile = import_path.default.join(messagesRoot, `${locale}.json`);
|
|
@@ -474,12 +486,12 @@ async function scaffold(options) {
|
|
|
474
486
|
}
|
|
475
487
|
const defaultFile = import_path.default.join(targetMessages, `${defaultLocale}.json`);
|
|
476
488
|
if (!await import_fs_extra.default.pathExists(defaultFile)) {
|
|
477
|
-
await import_fs_extra.default.copy(import_path.default.join(messagesRoot, `${
|
|
489
|
+
await import_fs_extra.default.copy(import_path.default.join(messagesRoot, `${safeLanguage}.json`), defaultFile);
|
|
478
490
|
}
|
|
479
491
|
} else {
|
|
480
492
|
await import_fs_extra.default.copy(
|
|
481
|
-
import_path.default.join(messagesRoot, `${
|
|
482
|
-
import_path.default.join(targetMessages, `${
|
|
493
|
+
import_path.default.join(messagesRoot, `${safeLanguage}.json`),
|
|
494
|
+
import_path.default.join(targetMessages, `${safeLanguage}.json`)
|
|
483
495
|
);
|
|
484
496
|
}
|
|
485
497
|
if (designName && designDir && !options.canvas) {
|
|
@@ -508,12 +520,26 @@ async function scaffold(options) {
|
|
|
508
520
|
const appDir = import_path.default.join(targetDir, "src", "app");
|
|
509
521
|
const localeDir = import_path.default.join(appDir, "[locale]");
|
|
510
522
|
await import_fs_extra.default.ensureDir(localeDir);
|
|
511
|
-
const keepInAppRoot = /* @__PURE__ */ new Set([
|
|
523
|
+
const keepInAppRoot = /* @__PURE__ */ new Set([
|
|
524
|
+
"globals.css",
|
|
525
|
+
"robots.ts",
|
|
526
|
+
"sitemap.ts",
|
|
527
|
+
"api",
|
|
528
|
+
".well-known",
|
|
529
|
+
"llms.txt",
|
|
530
|
+
"indexnow-key.txt",
|
|
531
|
+
"agents.md",
|
|
532
|
+
"opengraph-image.tsx"
|
|
533
|
+
]);
|
|
512
534
|
const appEntries = await import_fs_extra.default.readdir(appDir, { withFileTypes: true });
|
|
513
535
|
for (const entry of appEntries) {
|
|
514
536
|
if (keepInAppRoot.has(entry.name) || entry.name === "[locale]") continue;
|
|
515
537
|
await import_fs_extra.default.move(import_path.default.join(appDir, entry.name), import_path.default.join(localeDir, entry.name));
|
|
516
538
|
}
|
|
539
|
+
const nestedRss = import_path.default.join(localeDir, "blog", "rss.xml");
|
|
540
|
+
if (await import_fs_extra.default.pathExists(nestedRss)) {
|
|
541
|
+
await import_fs_extra.default.move(nestedRss, import_path.default.join(appDir, "blog", "rss.xml"));
|
|
542
|
+
}
|
|
517
543
|
}
|
|
518
544
|
}
|
|
519
545
|
function deepMerge(base, patch) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents.md — GET /agents.md
|
|
3
|
+
*
|
|
4
|
+
* The canonical AI-agent discovery file (the convention Shopify made standard
|
|
5
|
+
* in 2026): a machine-readable guide telling shopping agents and AI assistants
|
|
6
|
+
* what this site sells, where its machine surfaces live, and how a purchase
|
|
7
|
+
* works. /llms.txt mirrors the same information in the older convention —
|
|
8
|
+
* 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
|
+
// Machine-readable product feed served by the Brainerce API per channel —
|
|
23
|
+
// the same artifact Google/Microsoft/Perplexity ingest; agents can read the
|
|
24
|
+
// whole catalog in one structured fetch instead of crawling page by page.
|
|
25
|
+
const apiUrl = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/+$/, '');
|
|
26
|
+
const channelId =
|
|
27
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
28
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
29
|
+
'';
|
|
30
|
+
const client = getServerClient();
|
|
31
|
+
|
|
32
|
+
const [info, categoriesRes] = await Promise.all([
|
|
33
|
+
client.getStoreInfo().catch(() => null),
|
|
34
|
+
client.getCategories().catch(() => null),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const topCategories: CategoryNodeLike[] = (
|
|
38
|
+
(categoriesRes?.categories as CategoryNodeLike[] | undefined) ?? []
|
|
39
|
+
).filter((c) => c.slug);
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [
|
|
42
|
+
`# ${info?.name ?? 'Store'} — guide for AI agents`,
|
|
43
|
+
'',
|
|
44
|
+
...(info?.metaDescription ? [`> ${info.metaDescription}`, ''] : []),
|
|
45
|
+
'This is an e-commerce storefront. Product data, prices, and availability',
|
|
46
|
+
'are rendered server-side into every page (no JavaScript execution needed)',
|
|
47
|
+
'and marked up with schema.org Product / Offer / BreadcrumbList JSON-LD.',
|
|
48
|
+
'',
|
|
49
|
+
'## Machine-readable surfaces',
|
|
50
|
+
'',
|
|
51
|
+
`- Sitemap: ${baseUrl}/sitemap.xml (products, categories, articles, pages)`,
|
|
52
|
+
`- AI summary: ${baseUrl}/llms.txt`,
|
|
53
|
+
`- Blog RSS: ${baseUrl}/blog/rss.xml`,
|
|
54
|
+
...(channelId
|
|
55
|
+
? [
|
|
56
|
+
`- Product feed (Google Shopping XML): ${apiUrl}/api/feeds/${channelId}/google-shopping.xml`,
|
|
57
|
+
`- Product feed (JSONL): ${apiUrl}/api/feeds/${channelId}/openai-products.jsonl`,
|
|
58
|
+
`- MCP endpoint (catalog tools, stateless JSON-RPC): ${apiUrl}/api/mcp/storefront/${channelId}`,
|
|
59
|
+
' Tools: search_products, get_product, list_categories, get_store_info. Read-only;',
|
|
60
|
+
' to buy, send the shopper to the product `url` — checkout happens on this site.',
|
|
61
|
+
]
|
|
62
|
+
: []),
|
|
63
|
+
`- IndexNow key: ${baseUrl}/indexnow-key.txt`,
|
|
64
|
+
'',
|
|
65
|
+
'## Key pages',
|
|
66
|
+
'',
|
|
67
|
+
`- [All products](${baseUrl}/products) — full catalog, filterable`,
|
|
68
|
+
...topCategories
|
|
69
|
+
.slice(0, 20)
|
|
70
|
+
.map((c) => `- [${c.name}](${baseUrl}/category/${c.slug}) — category page`),
|
|
71
|
+
`- [Blog](${baseUrl}/blog) — guides and articles`,
|
|
72
|
+
`- [FAQ](${baseUrl}/faq)`,
|
|
73
|
+
`- [Contact](${baseUrl}/contact)`,
|
|
74
|
+
'',
|
|
75
|
+
'## Buying',
|
|
76
|
+
'',
|
|
77
|
+
`- Currency: ${info?.currency ?? 'see product pages'}`,
|
|
78
|
+
'- Checkout happens on this site (cart → checkout). Carts and checkout',
|
|
79
|
+
' are session-based; agents should hand the shopper the product URL.',
|
|
80
|
+
...(info?.contactEmail ? ['', `## Contact`, '', `- Email: ${info.contactEmail}`] : []),
|
|
81
|
+
'',
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
return new Response(lines.join('\n'), {
|
|
85
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as React from 'react';
|
|
12
12
|
import type { Metadata } from 'next';
|
|
13
|
-
import { notFound } from 'next/navigation';
|
|
13
|
+
import { notFound, permanentRedirect } from 'next/navigation';
|
|
14
14
|
import Link from 'next/link';
|
|
15
15
|
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
16
16
|
import { ArticleJsonLd } from '@/components/seo/article-json-ld';
|
|
17
17
|
|
|
18
|
-
import { getServerClient } from '@/core/lib/brainerce';
|
|
18
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
19
19
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
20
20
|
import { decodeSlug } from '@/core/lib/utils';
|
|
21
21
|
|
|
@@ -29,11 +29,39 @@ type PageProps = {
|
|
|
29
29
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
30
30
|
const { locale, slug: rawSlug } = await params;
|
|
31
31
|
const slug = decodeSlug(rawSlug);
|
|
32
|
-
const post = await
|
|
32
|
+
const [post, storeInfo] = await Promise.all([
|
|
33
|
+
getServerClient(locale).blog.getPost(slug).catch(() => null),
|
|
34
|
+
fetchStoreInfo(locale),
|
|
35
|
+
]);
|
|
33
36
|
if (!post) return { title: slug };
|
|
37
|
+
|
|
38
|
+
// Canonical + hreflang. Blog URLs use the same slug across locales (only
|
|
39
|
+
// the locale prefix changes) — the same convention getBlogSitemapEntries
|
|
40
|
+
// emits, so the alternates always match real, crawlable URLs.
|
|
41
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
42
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
43
|
+
const canonicalPath =
|
|
44
|
+
locale && locale !== defaultLoc ? `/${locale}/blog/${post.slug}` : `/blog/${post.slug}`;
|
|
45
|
+
const hreflangLanguages =
|
|
46
|
+
supportedLocales.length > 1
|
|
47
|
+
? Object.fromEntries([
|
|
48
|
+
...supportedLocales.map((loc) => [
|
|
49
|
+
loc,
|
|
50
|
+
loc === defaultLoc
|
|
51
|
+
? `${SITE_URL}/blog/${post.slug}`
|
|
52
|
+
: `${SITE_URL}/${loc}/blog/${post.slug}`,
|
|
53
|
+
]),
|
|
54
|
+
['x-default', `${SITE_URL}/blog/${post.slug}`],
|
|
55
|
+
])
|
|
56
|
+
: undefined;
|
|
57
|
+
|
|
34
58
|
return {
|
|
35
59
|
title: post.seoTitle ?? post.title,
|
|
36
60
|
description: post.seoDescription ?? post.excerpt,
|
|
61
|
+
alternates: {
|
|
62
|
+
canonical: canonicalPath,
|
|
63
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
64
|
+
},
|
|
37
65
|
openGraph: {
|
|
38
66
|
title: post.seoTitle ?? post.title,
|
|
39
67
|
description: post.seoDescription ?? post.excerpt ?? undefined,
|
|
@@ -57,7 +85,18 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
57
85
|
client.blog.getPost(slug).catch(() => null),
|
|
58
86
|
client.getStoreInfo().catch(() => null),
|
|
59
87
|
]);
|
|
60
|
-
if (!post)
|
|
88
|
+
if (!post) {
|
|
89
|
+
// The slug may have been renamed — 301 to the current slug so the old
|
|
90
|
+
// URL keeps its ranking; genuine unknowns fall through to the 404 page.
|
|
91
|
+
// Default locale stays unprefixed (matches the sitemap/hreflang convention).
|
|
92
|
+
const redirect = await client.resolveSlugRedirect('blog', slug);
|
|
93
|
+
if (redirect) {
|
|
94
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
95
|
+
const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
|
|
96
|
+
permanentRedirect(`${prefix}/blog/${redirect.currentSlug}`);
|
|
97
|
+
}
|
|
98
|
+
notFound();
|
|
99
|
+
}
|
|
61
100
|
|
|
62
101
|
const isHe = locale === 'he';
|
|
63
102
|
const dir = isHe ? 'rtl' : 'ltr';
|
|
@@ -158,6 +197,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
158
197
|
return {
|
|
159
198
|
title: post.seoTitle ?? post.title,
|
|
160
199
|
description: post.seoDescription ?? post.excerpt,
|
|
200
|
+
alternates: { canonical: `/blog/${post.slug}` },
|
|
161
201
|
openGraph: {
|
|
162
202
|
title: post.seoTitle ?? post.title,
|
|
163
203
|
description: post.seoDescription ?? post.excerpt ?? undefined,
|
|
@@ -181,7 +221,13 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
181
221
|
client.blog.getPost(slug).catch(() => null),
|
|
182
222
|
client.getStoreInfo().catch(() => null),
|
|
183
223
|
]);
|
|
184
|
-
if (!post)
|
|
224
|
+
if (!post) {
|
|
225
|
+
// The slug may have been renamed — 301 to the current slug so the old
|
|
226
|
+
// URL keeps its ranking; genuine unknowns fall through to the 404 page.
|
|
227
|
+
const redirect = await client.resolveSlugRedirect('blog', slug);
|
|
228
|
+
if (redirect) permanentRedirect(`/blog/${redirect.currentSlug}`);
|
|
229
|
+
notFound();
|
|
230
|
+
}
|
|
185
231
|
|
|
186
232
|
const articleUrl = `${SITE_URL}/blog/${post.slug}`;
|
|
187
233
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blog RSS feed — GET /blog/rss.xml
|
|
3
|
+
*
|
|
4
|
+
* Syndication for the store's articles (many of them auto-published by the
|
|
5
|
+
* SEO Autopilot): feed readers, newsletter tools, and aggregators can follow
|
|
6
|
+
* the blog without polling HTML. The root layout advertises it via
|
|
7
|
+
* <link rel="alternate" type="application/rss+xml">.
|
|
8
|
+
*
|
|
9
|
+
* NOTE (multi-locale scaffolds): the URL contains a dot, so the locale
|
|
10
|
+
* middleware never rewrites it, and a copy nested under [locale]/blog/ would
|
|
11
|
+
* 404. The scaffolder moves this route back to the app ROOT
|
|
12
|
+
* (app/blog/rss.xml) after the [locale] move — the literal `blog` segment
|
|
13
|
+
* wins over `[locale]` for /blog/rss.xml, while /he/blog/... keeps matching
|
|
14
|
+
* the locale tree. See scaffold.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { getServerClient } from '@/core/lib/brainerce';
|
|
17
|
+
|
|
18
|
+
export const revalidate = 3600;
|
|
19
|
+
|
|
20
|
+
function esc(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function GET() {
|
|
29
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
30
|
+
const client = getServerClient();
|
|
31
|
+
|
|
32
|
+
const [info, posts] = await Promise.all([
|
|
33
|
+
client.getStoreInfo().catch(() => null),
|
|
34
|
+
client.blog.getPosts({ limit: 50 }).catch(() => null),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const items = (posts?.data ?? [])
|
|
38
|
+
.map((post) => {
|
|
39
|
+
const url = `${baseUrl}/blog/${post.slug}`;
|
|
40
|
+
const description = post.seoDescription ?? post.excerpt ?? '';
|
|
41
|
+
const pubDate = post.publishedAt ? new Date(post.publishedAt).toUTCString() : undefined;
|
|
42
|
+
return [
|
|
43
|
+
' <item>',
|
|
44
|
+
` <title>${esc(post.title)}</title>`,
|
|
45
|
+
` <link>${esc(url)}</link>`,
|
|
46
|
+
` <guid isPermaLink="true">${esc(url)}</guid>`,
|
|
47
|
+
...(pubDate ? [` <pubDate>${pubDate}</pubDate>`] : []),
|
|
48
|
+
...(description ? [` <description>${esc(description)}</description>`] : []),
|
|
49
|
+
' </item>',
|
|
50
|
+
].join('\n');
|
|
51
|
+
})
|
|
52
|
+
.join('\n');
|
|
53
|
+
|
|
54
|
+
const xml = [
|
|
55
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
56
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
|
57
|
+
' <channel>',
|
|
58
|
+
` <title>${esc(info?.name ?? 'Blog')}</title>`,
|
|
59
|
+
` <link>${esc(`${baseUrl}/blog`)}</link>`,
|
|
60
|
+
` <description>${esc(info?.metaDescription ?? `${info?.name ?? 'Store'} blog`)}</description>`,
|
|
61
|
+
` <atom:link href="${esc(`${baseUrl}/blog/rss.xml`)}" rel="self" type="application/rss+xml"/>`,
|
|
62
|
+
items,
|
|
63
|
+
' </channel>',
|
|
64
|
+
'</rss>',
|
|
65
|
+
].join('\n');
|
|
66
|
+
|
|
67
|
+
return new Response(xml, {
|
|
68
|
+
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -1,106 +1,132 @@
|
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { getServerClient, fetchStoreInfo } 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, storeInfo] = await Promise.all([
|
|
30
|
+
getServerClient(locale).getCategoryBySlug(slug),
|
|
31
|
+
fetchStoreInfo(locale),
|
|
32
|
+
]);
|
|
33
|
+
const description =
|
|
34
|
+
category.metaDescription || buildMetaDescription(category.description) || category.name;
|
|
35
|
+
const canonicalSlug = category.slug || slug;
|
|
36
|
+
const canonicalPath = locale
|
|
37
|
+
? `/${locale}/category/${canonicalSlug}`
|
|
38
|
+
: `/category/${canonicalSlug}`;
|
|
39
|
+
|
|
40
|
+
// hreflang: category URLs use the same slug across locales (only the
|
|
41
|
+
// locale prefix changes) — the same convention getCategorySitemapEntries
|
|
42
|
+
// emits, so the alternates always match real, crawlable URLs.
|
|
43
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
44
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
45
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
46
|
+
const hreflangLanguages =
|
|
47
|
+
supportedLocales.length > 1
|
|
48
|
+
? Object.fromEntries([
|
|
49
|
+
...supportedLocales.map((loc) => [
|
|
50
|
+
loc,
|
|
51
|
+
loc === defaultLoc
|
|
52
|
+
? `${baseUrl}/category/${canonicalSlug}`
|
|
53
|
+
: `${baseUrl}/${loc}/category/${canonicalSlug}`,
|
|
54
|
+
]),
|
|
55
|
+
['x-default', `${baseUrl}/category/${canonicalSlug}`],
|
|
56
|
+
])
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
title: category.name,
|
|
61
|
+
description,
|
|
62
|
+
alternates: {
|
|
63
|
+
canonical: canonicalPath,
|
|
64
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
65
|
+
},
|
|
66
|
+
openGraph: {
|
|
67
|
+
title: category.name,
|
|
68
|
+
description,
|
|
69
|
+
type: 'website',
|
|
70
|
+
images: category.image ? [{ url: category.image, alt: category.name }] : [],
|
|
71
|
+
},
|
|
72
|
+
twitter: {
|
|
73
|
+
card: 'summary_large_image',
|
|
74
|
+
title: category.name,
|
|
75
|
+
description,
|
|
76
|
+
images: category.image ? [category.image] : [],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
return { title: 'Category not found' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default async function CategoryPage({ params }: Props) {
|
|
85
|
+
const { slug: rawSlug, locale } = await params;
|
|
86
|
+
const slug = decodeSlug(rawSlug);
|
|
87
|
+
const client = getServerClient(locale);
|
|
88
|
+
|
|
89
|
+
const category = await client.getCategoryBySlug(slug).catch(() => null);
|
|
90
|
+
if (!category) notFound();
|
|
91
|
+
|
|
92
|
+
// The listing endpoint owns pagination/FX/publish gating — reuse it rather
|
|
93
|
+
// than duplicating a product query here.
|
|
94
|
+
const { data: products } = await client
|
|
95
|
+
.getProducts({ categories: [category.id], limit: 48 })
|
|
96
|
+
.catch(() => ({ data: [] as Product[] }));
|
|
97
|
+
|
|
98
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
99
|
+
const url = `${baseUrl}/category/${category.slug || slug}`;
|
|
100
|
+
const localePrefix = locale ? `/${locale}` : '';
|
|
101
|
+
const breadcrumbItems = [
|
|
102
|
+
{ name: 'Home', href: `${localePrefix}/` },
|
|
103
|
+
...category.breadcrumb
|
|
104
|
+
.filter((ancestor) => ancestor.slug)
|
|
105
|
+
.map((ancestor) => ({
|
|
106
|
+
name: ancestor.name,
|
|
107
|
+
href: `${localePrefix}/category/${ancestor.slug}`,
|
|
108
|
+
})),
|
|
109
|
+
{ name: category.name },
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<CategoryJsonLd category={category} url={url} />
|
|
115
|
+
<div className="container mx-auto px-4 py-8">
|
|
116
|
+
<Breadcrumbs items={breadcrumbItems} />
|
|
117
|
+
<header className="mb-6">
|
|
118
|
+
<h1 className="text-3xl font-bold tracking-tight">{category.name}</h1>
|
|
119
|
+
</header>
|
|
120
|
+
|
|
121
|
+
<ProductGrid products={products} />
|
|
122
|
+
|
|
123
|
+
{category.description ? (
|
|
124
|
+
<div
|
|
125
|
+
className="prose prose-neutral dark:prose-invert mt-12 max-w-none border-t pt-8"
|
|
126
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(category.description) }}
|
|
127
|
+
/>
|
|
128
|
+
) : null}
|
|
129
|
+
</div>
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
// The contact page itself is a client component (the form is dashboard-
|
|
4
|
+
// driven), so its metadata lives here. Indexable on purpose — a crawlable
|
|
5
|
+
// contact page is a store-trust signal for both Google and AI assistants.
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: <%- language === 'he' ? "'צור קשר'" : "'Contact'" %>,
|
|
8
|
+
description: <%- language === 'he' ? "'יצירת קשר עם החנות — שאלות, הזמנות ושירות לקוחות.'" : "'Get in touch with the store — questions, orders, and support.'" %>,
|
|
9
|
+
alternates: { canonical: '/contact' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|
|
@@ -91,6 +91,16 @@ export default async function RootLayout({
|
|
|
91
91
|
.replace(/&/g, '\\u0026'),
|
|
92
92
|
}}
|
|
93
93
|
/>
|
|
94
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
95
|
+
the dashboard (channel settings → Google site verification). Also
|
|
96
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
97
|
+
until a token is configured. */}
|
|
98
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
99
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
100
|
+
) : null}
|
|
101
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
102
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
103
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
94
104
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
95
105
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
96
106
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
@@ -203,6 +213,16 @@ export default async function RootLayout({
|
|
|
203
213
|
.replace(/&/g, '\\u0026'),
|
|
204
214
|
}}
|
|
205
215
|
/>
|
|
216
|
+
{/* Google Search Console verification — token set by the merchant in
|
|
217
|
+
the dashboard (channel settings → Google site verification). Also
|
|
218
|
+
what the Merchant Center website claim checks for. Renders nothing
|
|
219
|
+
until a token is configured. */}
|
|
220
|
+
{storeInfo?.seo?.googleSiteVerification ? (
|
|
221
|
+
<meta name="google-site-verification" content={storeInfo.seo.googleSiteVerification} />
|
|
222
|
+
) : null}
|
|
223
|
+
{/* RSS autodiscovery — feed readers and aggregators find the blog feed
|
|
224
|
+
from any page. The route itself lives at /blog/rss.xml. */}
|
|
225
|
+
<link rel="alternate" type="application/rss+xml" title="Blog" href="/blog/rss.xml" />
|
|
206
226
|
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
207
227
|
SPA route changes. Cookieless (no consent banner needed); the
|
|
208
228
|
merchant can toggle it per sales channel in the dashboard. */}
|