create-brainerce-store 1.52.5 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brainerce Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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.52.5",
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"
@@ -180,7 +180,9 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
180
180
  // must pin >=1.30 to get the auto-region + tax-estimate methods.
181
181
  // 1.44 adds the order-level `notes` field to SetShippingAddressDto /
182
182
  // SetCheckoutCustomerDto — the scaffolded checkout form types depend on it.
183
- brainerce: "^1.44.0",
183
+ // 1.46 adds getAddressSuggestions/getAddressDetails — the scaffolded
184
+ // checkout form's address-autocomplete typeahead calls these directly.
185
+ brainerce: "^1.46.0",
184
186
  "isomorphic-dompurify": "^3.8.0"
185
187
  });
186
188
 
package/messages/en.json CHANGED
@@ -258,6 +258,8 @@
258
258
  "selectRegion": "Select region",
259
259
  "address": "Address",
260
260
  "streetAddress": "Street address",
261
+ "searchingAddress": "Searching…",
262
+ "outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue — we'll confirm delivery by phone.",
261
263
  "apartmentSuite": "Apartment, suite, etc.",
262
264
  "aptPlaceholder": "Apt, suite, unit, etc. (optional)",
263
265
  "city": "City",
package/messages/he.json CHANGED
@@ -258,6 +258,8 @@
258
258
  "selectRegion": "בחרו מחוז",
259
259
  "address": "כתובת",
260
260
  "streetAddress": "רחוב ומספר",
261
+ "searchingAddress": "מחפש…",
262
+ "outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך — ניצור איתך קשר טלפוני לתיאום המשלוח.",
261
263
  "apartmentSuite": "דירה, קומה וכו׳",
262
264
  "aptPlaceholder": "דירה, קומה, כניסה (אופציונלי)",
263
265
  "city": "עיר",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.52.5",
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"
@@ -1,86 +1,86 @@
1
- import type { NextConfig } from 'next';
2
- import { OPTIMIZED_IMAGE_HOSTS } from './src/core/lib/image-hosts';
3
-
4
- // Build-time invariant — fail loud if NEXT_PUBLIC_STORE_CURRENCY is missing
5
- // for a production build. Next.js inlines NEXT_PUBLIC_* into the client
6
- // bundle at `next build` time; once inlined, the value cannot be patched at
7
- // deploy time. A missing env here is the root cause of non-USD stores ending
8
- // up with `$5,096` baked into their HTML (and indexed by Googlebot).
9
- //
10
- // In dev (`next dev`) we only warn, so local-only experiments don't break.
11
- if (!process.env.NEXT_PUBLIC_STORE_CURRENCY) {
12
- const msg =
13
- 'NEXT_PUBLIC_STORE_CURRENCY is not set.\n' +
14
- 'Next.js inlines NEXT_PUBLIC_* vars into the client bundle at build time;\n' +
15
- 'a missing value will silently fall back to USD in storefront price displays\n' +
16
- 'and ship that to search-engine crawlers. Set it in .env.local (written by\n' +
17
- 'create-brainerce-store) or in your CI / hosting build env (Coolify / Vercel).';
18
- if (process.env.NODE_ENV === 'production') {
19
- throw new Error(`[next.config] ${msg}`);
20
- } else {
21
- console.warn(`[next.config] warning: ${msg}`);
22
- }
23
- }
24
-
25
- const nextConfig: NextConfig = {
26
- // isomorphic-dompurify ships jsdom, which at runtime reads stylesheet files
27
- // from its own package directory. Webpack bundling breaks those relative
28
- // lookups — loading it externally from node_modules keeps the paths intact.
29
- //
30
- // brainerce (the SDK) is imported across both the server and client
31
- // compilation boundaries in ~60 files, including the [locale]/layout.tsx
32
- // route (see generateStaticParams below) whose static params are resolved
33
- // by a separate jest-worker child process in `next dev` (webpack). That
34
- // worker requires the compiled page against the vendor-chunks manifest
35
- // while the main dev server may still be writing it, which can throw
36
- // "Cannot find module './vendor-chunks/brainerce.js'". Excluding it from
37
- // server bundling (loaded externally from node_modules instead) avoids the
38
- // race.
39
- serverExternalPackages: ['isomorphic-dompurify', 'brainerce'],
40
- images: {
41
- // The storefront is a consumer of the Brainerce API — it has to render
42
- // whatever image URLs the API returns. In practice those URLs are
43
- // usually on cdn.brainerce.com, but a product/variant can still carry a
44
- // raw upstream-merchant URL (WooCommerce, Shopify, self-hosted) while
45
- // its image-import job is pending or failed. Rather than allowlist every
46
- // possible merchant host (or hard-fail on them), only cdn.brainerce.com
47
- // goes through the server-side optimizer; components render product/blog
48
- // images via `CdnImage` (src/ui/shared/cdn-image.tsx), which
49
- // detects any other host and renders it `unoptimized` — an unresized
50
- // direct-from-origin fetch, same as this template's prior behavior —
51
- // instead of next/image throwing on an unconfigured hostname. No
52
- // server-side fetching of unknown hosts → no SSRF/DoS surface on this
53
- // Next server.
54
- remotePatterns: OPTIMIZED_IMAGE_HOSTS.map((hostname) => ({
55
- protocol: 'https' as const,
56
- hostname,
57
- pathname: '/**',
58
- })),
59
- },
60
- async headers() {
61
- return [
62
- {
63
- source: '/(.*)',
64
- headers: [
65
- {
66
- key: 'Strict-Transport-Security',
67
- value: 'max-age=63072000; includeSubDomains; preload',
68
- },
69
- { key: 'X-Content-Type-Options', value: 'nosniff' },
70
- // SAMEORIGIN (not DENY) so iframe-based payment providers (e.g. Cardcom)
71
- // can redirect the iframe back to /payment-complete on the storefront
72
- // itself after a successful charge — the postMessage relay needs the
73
- // parent frame to be able to render our own same-origin page.
74
- { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
75
- { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
76
- {
77
- key: 'Permissions-Policy',
78
- value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
79
- },
80
- ],
81
- },
82
- ];
83
- },
84
- };
85
-
86
- export default nextConfig;
1
+ import type { NextConfig } from 'next';
2
+ import { OPTIMIZED_IMAGE_HOSTS } from './src/core/lib/image-hosts';
3
+
4
+ // Build-time invariant — fail loud if NEXT_PUBLIC_STORE_CURRENCY is missing
5
+ // for a production build. Next.js inlines NEXT_PUBLIC_* into the client
6
+ // bundle at `next build` time; once inlined, the value cannot be patched at
7
+ // deploy time. A missing env here is the root cause of non-USD stores ending
8
+ // up with `$5,096` baked into their HTML (and indexed by Googlebot).
9
+ //
10
+ // In dev (`next dev`) we only warn, so local-only experiments don't break.
11
+ if (!process.env.NEXT_PUBLIC_STORE_CURRENCY) {
12
+ const msg =
13
+ 'NEXT_PUBLIC_STORE_CURRENCY is not set.\n' +
14
+ 'Next.js inlines NEXT_PUBLIC_* vars into the client bundle at build time;\n' +
15
+ 'a missing value will silently fall back to USD in storefront price displays\n' +
16
+ 'and ship that to search-engine crawlers. Set it in .env.local (written by\n' +
17
+ 'create-brainerce-store) or in your CI / hosting build env (Coolify / Vercel).';
18
+ if (process.env.NODE_ENV === 'production') {
19
+ throw new Error(`[next.config] ${msg}`);
20
+ } else {
21
+ console.warn(`[next.config] warning: ${msg}`);
22
+ }
23
+ }
24
+
25
+ const nextConfig: NextConfig = {
26
+ // isomorphic-dompurify ships jsdom, which at runtime reads stylesheet files
27
+ // from its own package directory. Webpack bundling breaks those relative
28
+ // lookups — loading it externally from node_modules keeps the paths intact.
29
+ //
30
+ // brainerce (the SDK) is imported across both the server and client
31
+ // compilation boundaries in ~60 files, including the [locale]/layout.tsx
32
+ // route (see generateStaticParams below) whose static params are resolved
33
+ // by a separate jest-worker child process in `next dev` (webpack). That
34
+ // worker requires the compiled page against the vendor-chunks manifest
35
+ // while the main dev server may still be writing it, which can throw
36
+ // "Cannot find module './vendor-chunks/brainerce.js'". Excluding it from
37
+ // server bundling (loaded externally from node_modules instead) avoids the
38
+ // race.
39
+ serverExternalPackages: ['isomorphic-dompurify', 'brainerce'],
40
+ images: {
41
+ // The storefront is a consumer of the Brainerce API — it has to render
42
+ // whatever image URLs the API returns. In practice those URLs are
43
+ // usually on cdn.brainerce.com, but a product/variant can still carry a
44
+ // raw upstream-merchant URL (WooCommerce, Shopify, self-hosted) while
45
+ // its image-import job is pending or failed. Rather than allowlist every
46
+ // possible merchant host (or hard-fail on them), only cdn.brainerce.com
47
+ // goes through the server-side optimizer; components render product/blog
48
+ // images via `CdnImage` (src/ui/shared/cdn-image.tsx), which
49
+ // detects any other host and renders it `unoptimized` — an unresized
50
+ // direct-from-origin fetch, same as this template's prior behavior —
51
+ // instead of next/image throwing on an unconfigured hostname. No
52
+ // server-side fetching of unknown hosts → no SSRF/DoS surface on this
53
+ // Next server.
54
+ remotePatterns: OPTIMIZED_IMAGE_HOSTS.map((hostname) => ({
55
+ protocol: 'https' as const,
56
+ hostname,
57
+ pathname: '/**',
58
+ })),
59
+ },
60
+ async headers() {
61
+ return [
62
+ {
63
+ source: '/(.*)',
64
+ headers: [
65
+ {
66
+ key: 'Strict-Transport-Security',
67
+ value: 'max-age=63072000; includeSubDomains; preload',
68
+ },
69
+ { key: 'X-Content-Type-Options', value: 'nosniff' },
70
+ // SAMEORIGIN (not DENY) so iframe-based payment providers (e.g. Cardcom)
71
+ // can redirect the iframe back to /payment-complete on the storefront
72
+ // itself after a successful charge — the postMessage relay needs the
73
+ // parent frame to be able to render our own same-origin page.
74
+ { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
75
+ { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
76
+ {
77
+ key: 'Permissions-Policy',
78
+ value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
79
+ },
80
+ ],
81
+ },
82
+ ];
83
+ },
84
+ };
85
+
86
+ export default nextConfig;
@@ -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
+ }