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.
Files changed (23) hide show
  1. package/dist/index.js +23 -3
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
  4. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +51 -5
  5. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
  6. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +132 -106
  7. package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
  8. package/templates/nextjs/base/src/app/layout.tsx.ejs +20 -0
  9. package/templates/nextjs/base/src/app/llms.txt/route.ts +67 -44
  10. package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
  11. package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
  12. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +187 -175
  13. package/templates/nextjs/base/src/app/robots.ts +73 -14
  14. package/templates/nextjs/base/src/app/sitemap.ts +89 -66
  15. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +71 -179
  16. package/templates/nextjs/base/src/core/lib/store-info.ts +8 -0
  17. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +1 -1
  18. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +3 -1
  19. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +20 -0
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +1 -1
  21. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +1 -1
  22. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +3 -1
  23. 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.63.0",
34
+ version: "1.64.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,13 @@ 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
- brainerce: "^1.55.0",
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
+ brainerce: "^1.56.0",
199
205
  "isomorphic-dompurify": "^3.8.0"
200
206
  });
201
207
 
@@ -508,12 +514,26 @@ async function scaffold(options) {
508
514
  const appDir = import_path.default.join(targetDir, "src", "app");
509
515
  const localeDir = import_path.default.join(appDir, "[locale]");
510
516
  await import_fs_extra.default.ensureDir(localeDir);
511
- const keepInAppRoot = /* @__PURE__ */ new Set(["globals.css", "robots.ts", "sitemap.ts", "api", ".well-known"]);
517
+ const keepInAppRoot = /* @__PURE__ */ new Set([
518
+ "globals.css",
519
+ "robots.ts",
520
+ "sitemap.ts",
521
+ "api",
522
+ ".well-known",
523
+ "llms.txt",
524
+ "indexnow-key.txt",
525
+ "agents.md",
526
+ "opengraph-image.tsx"
527
+ ]);
512
528
  const appEntries = await import_fs_extra.default.readdir(appDir, { withFileTypes: true });
513
529
  for (const entry of appEntries) {
514
530
  if (keepInAppRoot.has(entry.name) || entry.name === "[locale]") continue;
515
531
  await import_fs_extra.default.move(import_path.default.join(appDir, entry.name), import_path.default.join(localeDir, entry.name));
516
532
  }
533
+ const nestedRss = import_path.default.join(localeDir, "blog", "rss.xml");
534
+ if (await import_fs_extra.default.pathExists(nestedRss)) {
535
+ await import_fs_extra.default.move(nestedRss, import_path.default.join(appDir, "blog", "rss.xml"));
536
+ }
517
537
  }
518
538
  }
519
539
  function deepMerge(base, patch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.63.0",
3
+ "version": "1.64.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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 getServerClient(locale).blog.getPost(slug).catch(() => null);
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) notFound();
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) notFound();
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, '&amp;')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;');
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 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
- }
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. */}