create-brainerce-store 1.53.0 → 1.54.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 +1 -1
- 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 +92 -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/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/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 +7 -0
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.54.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"
|
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,92 @@
|
|
|
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 { ProductGrid } from '@/ui/product/product-grid';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
params: Promise<{ slug: string; locale?: string }>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Category (collection) landing page — the highest-leverage organic-SEO
|
|
17
|
+
* surface: it ranks for broad "research intent" queries ("running shoes")
|
|
18
|
+
* that individual product pages never capture. Merchant-authored copy (from
|
|
19
|
+
* the dashboard SEO hub) renders BELOW the grid so products stay above the
|
|
20
|
+
* fold, and CollectionPage + BreadcrumbList structured data feeds rich
|
|
21
|
+
* results + AI answers. Products come from getProducts, so this page is one
|
|
22
|
+
* metadata read + one listing read.
|
|
23
|
+
*/
|
|
24
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
25
|
+
const { slug: rawSlug, locale } = await params;
|
|
26
|
+
const slug = decodeSlug(rawSlug);
|
|
27
|
+
try {
|
|
28
|
+
const category = await getServerClient(locale).getCategoryBySlug(slug);
|
|
29
|
+
const description =
|
|
30
|
+
category.metaDescription || buildMetaDescription(category.description) || category.name;
|
|
31
|
+
const canonicalSlug = category.slug || slug;
|
|
32
|
+
const canonicalPath =
|
|
33
|
+
locale ? `/${locale}/category/${canonicalSlug}` : `/category/${canonicalSlug}`;
|
|
34
|
+
return {
|
|
35
|
+
title: category.name,
|
|
36
|
+
description,
|
|
37
|
+
alternates: { canonical: canonicalPath },
|
|
38
|
+
openGraph: {
|
|
39
|
+
title: category.name,
|
|
40
|
+
description,
|
|
41
|
+
type: 'website',
|
|
42
|
+
images: category.image ? [{ url: category.image, alt: category.name }] : [],
|
|
43
|
+
},
|
|
44
|
+
twitter: {
|
|
45
|
+
card: 'summary_large_image',
|
|
46
|
+
title: category.name,
|
|
47
|
+
description,
|
|
48
|
+
images: category.image ? [category.image] : [],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return { title: 'Category not found' };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default async function CategoryPage({ params }: Props) {
|
|
57
|
+
const { slug: rawSlug, locale } = await params;
|
|
58
|
+
const slug = decodeSlug(rawSlug);
|
|
59
|
+
const client = getServerClient(locale);
|
|
60
|
+
|
|
61
|
+
const category = await client.getCategoryBySlug(slug).catch(() => null);
|
|
62
|
+
if (!category) notFound();
|
|
63
|
+
|
|
64
|
+
// The listing endpoint owns pagination/FX/publish gating — reuse it rather
|
|
65
|
+
// than duplicating a product query here.
|
|
66
|
+
const { data: products } = await client
|
|
67
|
+
.getProducts({ categories: [category.id], limit: 48 })
|
|
68
|
+
.catch(() => ({ data: [] as Product[] }));
|
|
69
|
+
|
|
70
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
71
|
+
const url = `${baseUrl}/category/${category.slug || slug}`;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<>
|
|
75
|
+
<CategoryJsonLd category={category} url={url} />
|
|
76
|
+
<div className="container mx-auto px-4 py-8">
|
|
77
|
+
<header className="mb-6">
|
|
78
|
+
<h1 className="text-3xl font-bold tracking-tight">{category.name}</h1>
|
|
79
|
+
</header>
|
|
80
|
+
|
|
81
|
+
<ProductGrid products={products} />
|
|
82
|
+
|
|
83
|
+
{category.description ? (
|
|
84
|
+
<div
|
|
85
|
+
className="prose prose-neutral dark:prose-invert mt-12 max-w-none border-t pt-8"
|
|
86
|
+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(category.description) }}
|
|
87
|
+
/>
|
|
88
|
+
) : null}
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -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,4 +1,5 @@
|
|
|
1
1
|
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { getBlogSitemapEntries, getCategorySitemapEntries } from 'brainerce';
|
|
2
3
|
import { getServerClient } from '@/core/lib/brainerce';
|
|
3
4
|
|
|
4
5
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
@@ -42,7 +43,23 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
|
42
43
|
});
|
|
43
44
|
});
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// Category (collection) pages — the highest-value organic-SEO surface;
|
|
47
|
+
// must be crawlable so they can rank for broad research-intent queries.
|
|
48
|
+
const categoryPages: MetadataRoute.Sitemap = await getCategorySitemapEntries(client, {
|
|
49
|
+
siteUrl: baseUrl,
|
|
50
|
+
locales: isMultiLocale ? supportedLocales : undefined,
|
|
51
|
+
defaultLocale: defaultLoc,
|
|
52
|
+
}).catch(() => []);
|
|
53
|
+
|
|
54
|
+
// Blog posts — the SEO Autopilot (and manual publishes) must be
|
|
55
|
+
// discoverable; missing blog entries = articles never get crawled.
|
|
56
|
+
const blogPages: MetadataRoute.Sitemap = await getBlogSitemapEntries(client, {
|
|
57
|
+
siteUrl: baseUrl,
|
|
58
|
+
locales: isMultiLocale ? supportedLocales : undefined,
|
|
59
|
+
defaultLocale: defaultLoc,
|
|
60
|
+
}).catch(() => []);
|
|
61
|
+
|
|
62
|
+
return [...staticPages, ...productPages, ...categoryPages, ...blogPages];
|
|
46
63
|
} catch {
|
|
47
64
|
return staticPages;
|
|
48
65
|
}
|
|
@@ -34,6 +34,13 @@ export function BrainerceBotWidget() {
|
|
|
34
34
|
BrainerceBot.mount({
|
|
35
35
|
connectionId,
|
|
36
36
|
baseUrl: process.env.NEXT_PUBLIC_BRAINERCE_API_URL || undefined,
|
|
37
|
+
// Order-lookup login gating: this template's own same-origin BFF proxy
|
|
38
|
+
// (see src/app/api/store/[...path]/route.ts) already forwards the
|
|
39
|
+
// shopper's httpOnly session cookie as a Bearer header — the same
|
|
40
|
+
// mechanism @/core/lib/brainerce's own client uses. Opting the bot in
|
|
41
|
+
// here lets it detect login state without the widget ever touching the
|
|
42
|
+
// cookie itself.
|
|
43
|
+
customerSessionProxyPath: '/api/store',
|
|
37
44
|
onAddToCart: async ({ productId, variantId, quantity }) => {
|
|
38
45
|
try {
|
|
39
46
|
const { getClient } = await import('@/core/lib/brainerce');
|