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 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.53.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.53.0",
3
+ "version": "1.54.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"
@@ -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 post = await getServerClient(locale).blog.getPost(slug).catch(() => null);
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
- <script
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 post = await getServerClient().blog.getPost(slug).catch(() => null);
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 articleJsonLd = {
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
- <script
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
- return [...staticPages, ...productPages];
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');