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.
- package/dist/index.js +2 -2
- package/messages/en.json +505 -502
- package/messages/he.json +505 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +45 -24
- package/templates/nextjs/base/.eslintrc.json +51 -57
- package/templates/nextjs/base/AGENTS.md.ejs +107 -81
- package/templates/nextjs/base/CLAUDE.md.ejs +118 -92
- package/templates/nextjs/base/next.config.ts +103 -86
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +104 -97
- package/templates/nextjs/base/src/app/agents.md/route.ts +4 -3
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +65 -59
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +255 -242
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +311 -308
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +277 -276
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +4 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +6 -5
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +46 -46
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +25 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +273 -257
- package/templates/nextjs/base/src/app/llms.txt/route.ts +4 -3
- package/templates/nextjs/base/src/app/opengraph-image.tsx +1 -1
- package/templates/nextjs/base/src/app/page.tsx +65 -64
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +92 -92
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +10 -5
- package/templates/nextjs/base/src/app/robots.ts +3 -2
- package/templates/nextjs/base/src/app/sitemap.ts +4 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +294 -292
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +60 -59
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +60 -61
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +16 -2
- package/templates/nextjs/base/src/core/lib/brainerce.server.ts +81 -0
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +60 -110
- package/templates/nextjs/base/src/core/lib/site-url.ts +197 -0
- package/templates/nextjs/base/src/ui/product/faq-section.tsx.ejs +46 -0
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +301 -285
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +97 -96
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +142 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +139 -138
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +295 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +72 -71
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +83 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +120 -119
- package/templates/nextjs/ui-canvas/product/faq-section.tsx.ejs +54 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +267 -266
- 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
|
+
}
|