create-brainerce-store 1.65.0 → 1.67.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 (49) hide show
  1. package/dist/index.js +2 -2
  2. package/messages/en.json +505 -502
  3. package/messages/he.json +505 -502
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.env.local.ejs +45 -24
  6. package/templates/nextjs/base/.eslintrc.json +51 -57
  7. package/templates/nextjs/base/AGENTS.md.ejs +107 -81
  8. package/templates/nextjs/base/CLAUDE.md.ejs +118 -92
  9. package/templates/nextjs/base/next.config.ts +103 -86
  10. package/templates/nextjs/base/scripts/fetch-store-info.mjs +104 -97
  11. package/templates/nextjs/base/src/app/agents.md/route.ts +4 -3
  12. package/templates/nextjs/base/src/app/api/auth/me/route.ts +65 -59
  13. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +255 -242
  14. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +311 -308
  15. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +277 -276
  16. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +4 -3
  17. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +6 -5
  18. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
  19. package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +25 -26
  20. package/templates/nextjs/base/src/app/layout.tsx.ejs +273 -257
  21. package/templates/nextjs/base/src/app/llms.txt/route.ts +4 -3
  22. package/templates/nextjs/base/src/app/opengraph-image.tsx +1 -1
  23. package/templates/nextjs/base/src/app/page.tsx +65 -64
  24. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +92 -92
  25. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +10 -5
  26. package/templates/nextjs/base/src/app/robots.ts +3 -2
  27. package/templates/nextjs/base/src/app/sitemap.ts +4 -3
  28. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +294 -292
  29. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +60 -59
  30. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +60 -61
  31. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +16 -2
  32. package/templates/nextjs/base/src/core/lib/brainerce.server.ts +81 -0
  33. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +60 -110
  34. package/templates/nextjs/base/src/core/lib/site-url.ts +197 -0
  35. package/templates/nextjs/base/src/ui/product/faq-section.tsx.ejs +46 -0
  36. package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +301 -285
  39. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +97 -96
  40. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +142 -141
  41. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +139 -138
  42. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +295 -267
  43. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
  44. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +72 -71
  45. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +83 -82
  46. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +120 -119
  47. package/templates/nextjs/ui-canvas/product/faq-section.tsx.ejs +54 -0
  48. package/templates/nextjs/ui-canvas/product/review-form.tsx +267 -266
  49. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
@@ -0,0 +1,197 @@
1
+ import { headers } from 'next/headers';
2
+
3
+ /**
4
+ * The storefront's public origin — the value that ends up in canonical tags,
5
+ * sitemap.xml, robots.txt, JSON-LD, and the `Origin` header the backend
6
+ * compares against the sales channel's configured domain.
7
+ *
8
+ * ⛔ NEVER hardcode this, and never reintroduce a literal default. The
9
+ * scaffolder cannot know the answer: `npx create-brainerce-store` is very
10
+ * often run by an AI builder (ChatGPT, Lovable, Bolt) that deploys the result
11
+ * to a sandbox domain *it* owns, and the merchant moves it to their own domain
12
+ * later. The old `.env.local` shipped `NEXT_PUBLIC_SITE_URL=http://localhost:3000`
13
+ * as a confident wrong answer, which is worse than no answer — it silently
14
+ * wrote `http://localhost:3000` into every sitemap and canonical tag of every
15
+ * storefront not run from a laptop, and `https://example.com` into the ones
16
+ * where that env file never travelled at all (it is gitignored).
17
+ *
18
+ * Two resolvers, split by what the caller actually needs:
19
+ *
20
+ * {@link getCanonicalSiteUrl} — the address the store should be KNOWN by.
21
+ * Env first, because a canonical URL must be stable across preview deploys
22
+ * and must not be forgeable by a request header.
23
+ *
24
+ * {@link getRequestOrigin} — the address a request actually ARRIVED on.
25
+ * Request headers first, because this is what the backend's origin check
26
+ * has to match; claiming the production domain from a preview deployment
27
+ * would pass a check that ought to fail.
28
+ */
29
+
30
+ const DEV_FALLBACK = 'http://localhost:3000';
31
+
32
+ function isLoopback(host: string): boolean {
33
+ const name = host.split(':')[0].toLowerCase();
34
+ return name === 'localhost' || name === '127.0.0.1' || name === '[::1]' || name === '::1';
35
+ }
36
+
37
+ /**
38
+ * Coerce a host (`shop.example.com`) or a full URL (`https://shop.example.com/`)
39
+ * into a bare origin. Returns null for anything unparseable, so a malformed env
40
+ * var degrades to the next source instead of throwing.
41
+ */
42
+ function toOrigin(raw: string | undefined): string | null {
43
+ const trimmed = raw?.trim();
44
+ if (!trimmed) return null;
45
+ const hasProtocol = /^https?:\/\//i.test(trimmed);
46
+ const bare = trimmed.replace(/^\/\//, '');
47
+ const candidate = hasProtocol ? trimmed : `${isLoopback(bare) ? 'http' : 'https'}://${bare}`;
48
+ try {
49
+ return new URL(candidate).origin;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Explicitly configured origin. `SITE_URL` is preferred; `NEXT_PUBLIC_SITE_URL`
57
+ * is accepted as a backwards-compatible alias for storefronts scaffolded before
58
+ * this resolver existed.
59
+ *
60
+ * The `NEXT_PUBLIC_` prefix was always a misnomer here — nothing client-side
61
+ * reads this value, every consumer is server-side — and the prefix implies a
62
+ * build-time inline that does not apply to server reads. Prefer `SITE_URL`.
63
+ */
64
+ function fromExplicitEnv(): string | null {
65
+ return toOrigin(process.env.SITE_URL) ?? toOrigin(process.env.NEXT_PUBLIC_SITE_URL);
66
+ }
67
+
68
+ /**
69
+ * Origins injected by the hosting platform. These are present during
70
+ * `next build` as well as at runtime, so they answer correctly with no request
71
+ * in hand.
72
+ *
73
+ * Production domains are listed before per-deployment domains: a preview build
74
+ * must not stamp its throwaway URL into canonical tags.
75
+ *
76
+ * Netlify's `URL` is guarded behind `NETLIFY` — the bare name is too generic to
77
+ * trust on its own, and a merchant's own `URL` variable must not be mistaken
78
+ * for a site address.
79
+ */
80
+ function fromPlatformEnv(): string | null {
81
+ const env = process.env;
82
+ const candidates = [
83
+ env.VERCEL_PROJECT_PRODUCTION_URL,
84
+ env.VERCEL_URL,
85
+ env.NETLIFY ? env.URL : undefined,
86
+ env.NETLIFY ? env.DEPLOY_PRIME_URL : undefined,
87
+ env.RENDER_EXTERNAL_URL,
88
+ env.RAILWAY_PUBLIC_DOMAIN,
89
+ env.CF_PAGES_URL,
90
+ ];
91
+ for (const candidate of candidates) {
92
+ const origin = toOrigin(candidate);
93
+ if (origin) return origin;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Origin derived from the incoming request.
100
+ *
101
+ * ⚠️ Caller-controlled. `curl -H 'Host: evil.example'` reaches this, so a
102
+ * storefront relying on it can have a canonical tag poisoned by whoever asks.
103
+ * That is an accepted trade for the AI-builder case — a sandbox injecting none
104
+ * of the platform vars above would otherwise have no correct source at all —
105
+ * and it disappears the moment `SITE_URL` is set, which is why every caller
106
+ * prefers the env.
107
+ */
108
+ function fromForwardedHeaders(h: Headers): string | null {
109
+ // `x-forwarded-host` carries a comma-separated chain when several proxies
110
+ // append to it; the first entry is the original client-facing host.
111
+ const forwardedHost = h.get('x-forwarded-host')?.split(',')[0]?.trim();
112
+ const host = forwardedHost || h.get('host')?.trim();
113
+ if (!host) return null;
114
+ const proto =
115
+ h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isLoopback(host) ? 'http' : 'https');
116
+ return toOrigin(`${proto}://${host}`);
117
+ }
118
+
119
+ let warnedMissingSiteUrl = false;
120
+
121
+ /**
122
+ * Alarm at the point of failure rather than fail open. Reaching the dev
123
+ * fallback in production means every absolute URL this process emits is wrong,
124
+ * and no downstream layer can detect that — a canonical tag pointing at
125
+ * localhost looks perfectly well-formed to a crawler.
126
+ */
127
+ function warnUnresolved(): void {
128
+ if (warnedMissingSiteUrl || process.env.NODE_ENV !== 'production') return;
129
+ warnedMissingSiteUrl = true;
130
+ console.warn(
131
+ [
132
+ `[brainerce] Could not determine this storefront's public URL; falling back to ${DEV_FALLBACK}.`,
133
+ 'Sitemap, robots.txt, canonical tags and JSON-LD will all be wrong.',
134
+ ' Fix: set SITE_URL to your public origin (e.g. SITE_URL=https://shop.example.com)',
135
+ " in your hosting provider's environment variables.",
136
+ ' Also check the channel: a LIVE sales channel only accepts requests whose Origin',
137
+ ' matches the Domain configured on it. Add this host under Sales Channels -> your',
138
+ ' channel -> Domain in the Brainerce dashboard, or storefront API calls are rejected',
139
+ ' with 403 no matter what SITE_URL says.',
140
+ ].join('\n')
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Env-only resolution, safe to call outside a request (module scope, build
146
+ * scripts, `next.config.ts`). Returns null rather than guessing.
147
+ */
148
+ export function getSiteUrlFromEnv(): string | null {
149
+ return fromExplicitEnv() ?? fromPlatformEnv();
150
+ }
151
+
152
+ /**
153
+ * The origin this store should be known by — canonical tags, sitemap entries,
154
+ * JSON-LD `url` fields, OpenGraph. Stable across deploys when `SITE_URL` is set.
155
+ */
156
+ export async function getCanonicalSiteUrl(): Promise<string> {
157
+ const configured = getSiteUrlFromEnv();
158
+ if (configured) return configured;
159
+
160
+ // Only reached when nothing is configured, so the `headers()` call — and the
161
+ // dynamic rendering it forces — is only paid in that case.
162
+ //
163
+ // ⛔ Deliberately NOT wrapped in try/catch. During static generation
164
+ // `headers()` throws Next's DynamicServerError, which is a *signal*, not a
165
+ // failure: Next catches it upstream and re-renders the route dynamically.
166
+ // Catching it here returns DEV_FALLBACK instead, and Next then bakes
167
+ // `http://localhost:3000` into the prerendered sitemap.xml and robots.txt —
168
+ // reproducing the exact bug this module exists to prevent. Verified: with
169
+ // the catch in place the build emitted localhost into `sitemap.xml.body`.
170
+ const fromRequest = fromForwardedHeaders(await headers());
171
+ if (fromRequest) return fromRequest;
172
+
173
+ warnUnresolved();
174
+ return DEV_FALLBACK;
175
+ }
176
+
177
+ /**
178
+ * The origin a request actually arrived on — used for the `Origin` header sent
179
+ * to the Brainerce backend, which must match the channel's configured domain.
180
+ *
181
+ * Pass the live request headers where you have them (route handlers, the BFF
182
+ * proxy); omit to read the ambient request context.
183
+ */
184
+ export async function getRequestOrigin(requestHeaders?: Headers): Promise<string> {
185
+ // Same rule as above: no try/catch around the ambient `headers()` read, so
186
+ // Next's DynamicServerError propagates and the route is re-rendered
187
+ // dynamically rather than prerendered against a fallback origin.
188
+ const h = requestHeaders ?? (await headers());
189
+ const fromRequest = fromForwardedHeaders(h);
190
+ if (fromRequest) return fromRequest;
191
+
192
+ const configured = getSiteUrlFromEnv();
193
+ if (configured) return configured;
194
+
195
+ warnUnresolved();
196
+ return DEV_FALLBACK;
197
+ }
@@ -0,0 +1,46 @@
1
+ import type { Product } from 'brainerce';
2
+ <% if (i18nEnabled) { %>import { getMessages, defaultLocale } from '@/i18n';<% } else { %>import { defaultLocale, messages } from '@/i18n';<% } %>
3
+
4
+ type FaqStrings = Record<string, string>;
5
+
6
+ <% if (i18nEnabled) { %>async function getFaqStrings(locale: string): Promise<FaqStrings> {
7
+ return ((await getMessages(locale)).faq ?? {}) as FaqStrings;
8
+ }<% } else { %>async function getFaqStrings(_locale: string): Promise<FaqStrings> {
9
+ return ((messages as Record<string, unknown>).faq ?? {}) as FaqStrings;
10
+ }<% } %>
11
+
12
+ interface ProductFaqSectionProps {
13
+ product: Product;
14
+ locale?: string;
15
+ }
16
+
17
+ /**
18
+ * Visible product Q&A — the extractable question/answer format AI answer
19
+ * engines cite. The SAME pairs are emitted as FAQPage JSON-LD by
20
+ * <ProductJsonLd> (both read product.faq, so they can never drift). Server
21
+ * component on purpose: the pairs must be in the initial HTML payload — AI
22
+ * crawlers don't run JavaScript. Content inside a closed <details> is still
23
+ * present in the HTML, so the accordion costs nothing for extraction.
24
+ */
25
+ export async function ProductFaqSection({
26
+ product,
27
+ locale = defaultLocale,
28
+ }: ProductFaqSectionProps) {
29
+ const pairs = (product.faq ?? []).filter((item) => item?.q && item?.a);
30
+ if (pairs.length === 0) return null;
31
+ const t = await getFaqStrings(locale);
32
+
33
+ return (
34
+ <section className="mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8">
35
+ <h2 className="mb-6 text-2xl font-semibold">{t.title ?? 'Frequently asked questions'}</h2>
36
+ <div className="divide-y rounded-xl border">
37
+ {pairs.map((item) => (
38
+ <details key={item.q} className="px-5 py-4">
39
+ <summary className="cursor-pointer text-base font-medium">{item.q}</summary>
40
+ <p className="mt-2 text-sm leading-relaxed opacity-80">{item.a}</p>
41
+ </details>
42
+ ))}
43
+ </div>
44
+ </section>
45
+ );
46
+ }