create-brainerce-store 1.67.0 → 1.68.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/.env.local.ejs +7 -0
- package/templates/nextjs/base/AGENTS.md.ejs +7 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +7 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +16 -7
- package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +16 -6
- package/templates/nextjs/base/src/core/lib/site-url.ts +42 -9
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.68.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
|
@@ -38,6 +38,13 @@ NEXT_PUBLIC_BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
|
|
|
38
38
|
# Set it in your hosting provider's environment variables, not in this file:
|
|
39
39
|
# .env.local is gitignored and never travels with a deploy.
|
|
40
40
|
# `NEXT_PUBLIC_SITE_URL` is still read as a backwards-compatible alias.
|
|
41
|
+
#
|
|
42
|
+
# On some hosts SITE_URL is NOT optional. Platforms whose proxy forwards
|
|
43
|
+
# requests to the app as `Host: localhost:<port>` (OpenAI Sites /
|
|
44
|
+
# *.chatgpt.site, among others) hide the real hostname from the server, so
|
|
45
|
+
# nothing can be resolved from the request. Set SITE_URL there, or server-side
|
|
46
|
+
# API calls send `Origin: http://localhost:3000` and a sales channel with a
|
|
47
|
+
# configured Domain rejects them with 403.
|
|
41
48
|
|
|
42
49
|
# Optional: floating WhatsApp contact button (used by some designs, e.g.
|
|
43
50
|
# passepartout). Digits only, international format without "+"
|
|
@@ -81,6 +81,13 @@ development and on any host with nothing configured.
|
|
|
81
81
|
`getRequestOrigin()` (for the `Origin` header sent to Brainerce). Hand-rolled
|
|
82
82
|
versions have shipped `http://` on HTTPS hosts and internal container
|
|
83
83
|
hostnames.
|
|
84
|
+
- **Some hosts hide the real hostname from the app.** Platforms whose proxy
|
|
85
|
+
forwards requests as `Host: localhost:<port>` with no `x-forwarded-host`
|
|
86
|
+
(OpenAI Sites / `*.chatgpt.site` does this) leave the resolver blind — the
|
|
87
|
+
request looks like it arrived on localhost. On such platforms `SITE_URL` is
|
|
88
|
+
not optional: set it, and the resolver prefers it over any internal host it
|
|
89
|
+
sees. Without it, server-side API calls send `Origin: http://localhost:3000`
|
|
90
|
+
and a channel with a configured Domain rejects them with 403.
|
|
84
91
|
- **When the merchant gives you a real domain**, set `SITE_URL` in the hosting
|
|
85
92
|
provider's environment variables — not in `.env.local`, which is gitignored
|
|
86
93
|
and never travels with a deploy.
|
|
@@ -92,6 +92,13 @@ development and on any host with nothing configured.
|
|
|
92
92
|
`getRequestOrigin()` (for the `Origin` header sent to Brainerce). Hand-rolled
|
|
93
93
|
versions have shipped `http://` on HTTPS hosts and internal container
|
|
94
94
|
hostnames.
|
|
95
|
+
- **Some hosts hide the real hostname from the app.** Platforms whose proxy
|
|
96
|
+
forwards requests as `Host: localhost:<port>` with no `x-forwarded-host`
|
|
97
|
+
(OpenAI Sites / `*.chatgpt.site` does this) leave the resolver blind — the
|
|
98
|
+
request looks like it arrived on localhost. On such platforms `SITE_URL` is
|
|
99
|
+
not optional: set it, and the resolver prefers it over any internal host it
|
|
100
|
+
sees. Without it, server-side API calls send `Origin: http://localhost:3000`
|
|
101
|
+
and a channel with a configured Domain rejects them with 403.
|
|
95
102
|
- **When the merchant gives you a real domain**, set `SITE_URL` in the hosting
|
|
96
103
|
provider's environment variables — not in `.env.local`, which is gitignored
|
|
97
104
|
and never travels with a deploy.
|
|
@@ -83,7 +83,10 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
83
83
|
const slug = decodeSlug(rawSlug);
|
|
84
84
|
const client = await getServerClient(locale);
|
|
85
85
|
const [post, storeInfo] = await Promise.all([
|
|
86
|
-
|
|
86
|
+
// getPost returns null only on a genuine 404; any other API failure (a
|
|
87
|
+
// 403 from the channel's origin check, a network error) throws and
|
|
88
|
+
// surfaces as the error it is instead of masquerading as a missing post.
|
|
89
|
+
client.blog.getPost(slug),
|
|
87
90
|
client.getStoreInfo().catch(() => null),
|
|
88
91
|
]);
|
|
89
92
|
if (!post) {
|
|
@@ -220,7 +223,10 @@ export default async function BlogPostPage({ params }: PageProps) {
|
|
|
220
223
|
const slug = decodeSlug(rawSlug);
|
|
221
224
|
const client = await getServerClient();
|
|
222
225
|
const [post, storeInfo] = await Promise.all([
|
|
223
|
-
|
|
226
|
+
// getPost returns null only on a genuine 404; any other API failure (a
|
|
227
|
+
// 403 from the channel's origin check, a network error) throws and
|
|
228
|
+
// surfaces as the error it is instead of masquerading as a missing post.
|
|
229
|
+
client.blog.getPost(slug),
|
|
224
230
|
client.getStoreInfo().catch(() => null),
|
|
225
231
|
]);
|
|
226
232
|
if (!post) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
|
-
import
|
|
3
|
+
import { BrainerceError } from 'brainerce';
|
|
4
4
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
5
5
|
import { buildMetaDescription } from '@/core/lib/seo';
|
|
6
6
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
@@ -77,8 +77,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
77
77
|
images: category.image ? [category.image] : [],
|
|
78
78
|
},
|
|
79
79
|
};
|
|
80
|
-
} catch {
|
|
81
|
-
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof BrainerceError && err.statusCode === 404) {
|
|
82
|
+
return { title: 'Category not found' };
|
|
83
|
+
}
|
|
84
|
+
// Anything else (channel origin rejection, network failure) hits the page
|
|
85
|
+
// render below too and surfaces there — don't stamp a misleading title.
|
|
86
|
+
return {};
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -87,14 +92,18 @@ export default async function CategoryPage({ params }: Props) {
|
|
|
87
92
|
const slug = decodeSlug(rawSlug);
|
|
88
93
|
const client = await getServerClient(locale);
|
|
89
94
|
|
|
90
|
-
|
|
95
|
+
// Only a genuine 404 becomes a storefront 404; a 403 from the channel's
|
|
96
|
+
// origin check or a network failure must surface as the error it is, not
|
|
97
|
+
// masquerade as a missing category (or an empty one).
|
|
98
|
+
const category = await client.getCategoryBySlug(slug).catch((err: unknown) => {
|
|
99
|
+
if (err instanceof BrainerceError && err.statusCode === 404) return null;
|
|
100
|
+
throw err;
|
|
101
|
+
});
|
|
91
102
|
if (!category) notFound();
|
|
92
103
|
|
|
93
104
|
// The listing endpoint owns pagination/FX/publish gating — reuse it rather
|
|
94
105
|
// than duplicating a product query here.
|
|
95
|
-
const { data: products } = await client
|
|
96
|
-
.getProducts({ categories: [category.id], limit: 48 })
|
|
97
|
-
.catch(() => ({ data: [] as Product[] }));
|
|
106
|
+
const { data: products } = await client.getProducts({ categories: [category.id], limit: 48 });
|
|
98
107
|
|
|
99
108
|
const baseUrl = await getCanonicalSiteUrl();
|
|
100
109
|
const url = `${baseUrl}/category/${category.slug || slug}`;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Error page — rendered when a page render throws. Kept deliberately separate
|
|
8
|
+
* from the 404 page: an API failure (a rejected Origin, a network error, a
|
|
9
|
+
* 5xx) is NOT "product not found", and dressing it up as a 404 sends the
|
|
10
|
+
* merchant hunting for a slug bug that isn't there. The full error is in the
|
|
11
|
+
* server log; this page only needs to say "temporary" and offer a retry.
|
|
12
|
+
*/
|
|
13
|
+
export default function ErrorPage({
|
|
14
|
+
error,
|
|
15
|
+
reset,
|
|
16
|
+
}: {
|
|
17
|
+
error: Error & { digest?: string };
|
|
18
|
+
reset: () => void;
|
|
19
|
+
}) {
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
// The server log has the full error; the digest printed here links the two.
|
|
22
|
+
console.error(error);
|
|
23
|
+
}, [error]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="mx-auto flex max-w-2xl flex-col items-center justify-center px-4 py-24 text-center">
|
|
27
|
+
<p className="text-sm font-medium text-muted-foreground">
|
|
28
|
+
<%- language === 'he' ? 'שגיאה' : 'Error' %>
|
|
29
|
+
</p>
|
|
30
|
+
<h1 className="mt-2 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
|
|
31
|
+
<%- language === 'he' ? 'משהו השתבש' : 'Something went wrong' %>
|
|
32
|
+
</h1>
|
|
33
|
+
<p className="mt-4 text-muted-foreground">
|
|
34
|
+
<%- language === 'he' ? 'זו תקלה זמנית בטעינת העמוד — לא עמוד חסר. נסו שוב בעוד רגע.' : 'This is a temporary problem loading the page — not a missing page. Please try again in a moment.' %>
|
|
35
|
+
</p>
|
|
36
|
+
<div className="mt-8 flex gap-4">
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={reset}
|
|
40
|
+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90"
|
|
41
|
+
>
|
|
42
|
+
<%- language === 'he' ? 'לנסות שוב' : 'Try again' %>
|
|
43
|
+
</button>
|
|
44
|
+
<Link
|
|
45
|
+
href="/"
|
|
46
|
+
className="rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
|
47
|
+
>
|
|
48
|
+
<%- language === 'he' ? 'לדף הבית' : 'Go home' %>
|
|
49
|
+
</Link>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -40,7 +40,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
40
40
|
export default async function StaticPage({ params }: PageProps) {
|
|
41
41
|
const { locale, slug: rawSlug } = await params;
|
|
42
42
|
const slug = decodeSlug(rawSlug);
|
|
43
|
-
|
|
43
|
+
// getBySlug returns null only on a genuine 404; any other API failure (a
|
|
44
|
+
// 403 from the channel's origin check, a network error) throws and surfaces
|
|
45
|
+
// as the error it is instead of masquerading as a missing page.
|
|
46
|
+
const page = await (await getServerClient(locale)).content.page.getBySlug(slug, locale);
|
|
44
47
|
if (!page) notFound();
|
|
45
48
|
return (
|
|
46
49
|
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
@@ -75,7 +78,10 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
|
|
75
78
|
export default async function StaticPage({ params }: PageProps) {
|
|
76
79
|
const { slug: rawSlug } = await params;
|
|
77
80
|
const slug = decodeSlug(rawSlug);
|
|
78
|
-
|
|
81
|
+
// getBySlug returns null only on a genuine 404; any other API failure (a
|
|
82
|
+
// 403 from the channel's origin check, a network error) throws and surfaces
|
|
83
|
+
// as the error it is instead of masquerading as a missing page.
|
|
84
|
+
const page = await (await getServerClient()).content.page.getBySlug(slug);
|
|
79
85
|
if (!page) notFound();
|
|
80
86
|
return (
|
|
81
87
|
<article className="mx-auto max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import { notFound, permanentRedirect } from 'next/navigation';
|
|
3
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
+
import { BrainerceError, getProductPriceInfo } from 'brainerce';
|
|
4
4
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
5
5
|
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
6
6
|
import { buildMetaDescription } from '@/core/lib/seo';
|
|
@@ -123,10 +123,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
123
123
|
...(brandName ? { 'product:brand': brandName } : {}),
|
|
124
124
|
},
|
|
125
125
|
};
|
|
126
|
-
} catch {
|
|
127
|
-
|
|
128
|
-
title: 'Product not found'
|
|
129
|
-
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof BrainerceError && err.statusCode === 404) {
|
|
128
|
+
return { title: 'Product not found' };
|
|
129
|
+
}
|
|
130
|
+
// Anything else (channel origin rejection, network failure) hits the page
|
|
131
|
+
// render below too and surfaces there — don't stamp a misleading title.
|
|
132
|
+
return {};
|
|
130
133
|
}
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -138,7 +141,14 @@ export default async function ProductDetailPage({ params }: Props) {
|
|
|
138
141
|
let product;
|
|
139
142
|
try {
|
|
140
143
|
product = await client.getProductBySlug(slug);
|
|
141
|
-
} catch {
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Only a genuine 404 may become a storefront 404. Anything else — a 403
|
|
146
|
+
// from the channel's origin check, a network failure, a 5xx — is a
|
|
147
|
+
// configuration or availability problem; disguising it as "product not
|
|
148
|
+
// found" sends the merchant hunting for a slug bug that isn't there.
|
|
149
|
+
if (!(err instanceof BrainerceError && err.statusCode === 404)) {
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
142
152
|
// The slug may have been RENAMED — the platform records every rename.
|
|
143
153
|
// 301 the old URL to the current slug so its ranking and inbound links
|
|
144
154
|
// carry over; genuine unknowns fall through to the 404 page. Default
|
|
@@ -22,16 +22,37 @@ import { headers } from 'next/headers';
|
|
|
22
22
|
* and must not be forgeable by a request header.
|
|
23
23
|
*
|
|
24
24
|
* {@link getRequestOrigin} — the address a request actually ARRIVED on.
|
|
25
|
-
* Request headers first
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Request headers first — unless they name an internal host (localhost,
|
|
26
|
+
* 127.0.0.1, a bind address) and an origin is configured, because an
|
|
27
|
+
* internal host is how the platform's proxy addresses this server, not
|
|
28
|
+
* where the request came from. Claiming the production domain from a
|
|
29
|
+
* preview deployment on a real hostname would still pass a check that
|
|
30
|
+
* ought to fail, so public hostnames keep winning over the env.
|
|
28
31
|
*/
|
|
29
32
|
|
|
30
33
|
const DEV_FALLBACK = 'http://localhost:3000';
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Hosts that can never be a storefront's public address: loopback names and
|
|
37
|
+
* the 0.0.0.0 bind address. Some hosting proxies (OpenAI Sites among them)
|
|
38
|
+
* forward requests to the app with `Host: localhost:<port>` and no
|
|
39
|
+
* `x-forwarded-host`, so seeing one of these in a request means "the proxy's
|
|
40
|
+
* internal leg", not "the shopper is on localhost".
|
|
41
|
+
*/
|
|
42
|
+
function isInternalHost(host: string): boolean {
|
|
43
|
+
const name = host.startsWith('[')
|
|
44
|
+
? host.slice(1, host.includes(']') ? host.indexOf(']') : host.length)
|
|
45
|
+
: host.split(':')[0];
|
|
46
|
+
const lower = name.toLowerCase();
|
|
47
|
+
return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === '0.0.0.0';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isInternalOrigin(origin: string): boolean {
|
|
51
|
+
try {
|
|
52
|
+
return isInternalHost(new URL(origin).host);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
/**
|
|
@@ -44,7 +65,7 @@ function toOrigin(raw: string | undefined): string | null {
|
|
|
44
65
|
if (!trimmed) return null;
|
|
45
66
|
const hasProtocol = /^https?:\/\//i.test(trimmed);
|
|
46
67
|
const bare = trimmed.replace(/^\/\//, '');
|
|
47
|
-
const candidate = hasProtocol ? trimmed : `${
|
|
68
|
+
const candidate = hasProtocol ? trimmed : `${isInternalHost(bare) ? 'http' : 'https'}://${bare}`;
|
|
48
69
|
try {
|
|
49
70
|
return new URL(candidate).origin;
|
|
50
71
|
} catch {
|
|
@@ -112,7 +133,7 @@ function fromForwardedHeaders(h: Headers): string | null {
|
|
|
112
133
|
const host = forwardedHost || h.get('host')?.trim();
|
|
113
134
|
if (!host) return null;
|
|
114
135
|
const proto =
|
|
115
|
-
h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (
|
|
136
|
+
h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isInternalHost(host) ? 'http' : 'https');
|
|
116
137
|
return toOrigin(`${proto}://${host}`);
|
|
117
138
|
}
|
|
118
139
|
|
|
@@ -187,11 +208,23 @@ export async function getRequestOrigin(requestHeaders?: Headers): Promise<string
|
|
|
187
208
|
// dynamically rather than prerendered against a fallback origin.
|
|
188
209
|
const h = requestHeaders ?? (await headers());
|
|
189
210
|
const fromRequest = fromForwardedHeaders(h);
|
|
190
|
-
if (fromRequest) return fromRequest;
|
|
191
211
|
|
|
212
|
+
// A public hostname from the request wins outright — it is what this
|
|
213
|
+
// request actually arrived on.
|
|
214
|
+
if (fromRequest && !isInternalOrigin(fromRequest)) return fromRequest;
|
|
215
|
+
|
|
216
|
+
// An internal host here does NOT mean the request arrived on localhost. It
|
|
217
|
+
// means the platform's proxy addresses this server by its bind address
|
|
218
|
+
// (`Host: localhost:3000`, no `x-forwarded-host`) — OpenAI Sites does
|
|
219
|
+
// exactly this — and the request's true origin is invisible to us. A
|
|
220
|
+
// configured origin is the only correct answer then. In plain local dev
|
|
221
|
+
// nothing is configured, so the loopback host (with its real port) still
|
|
222
|
+
// wins below.
|
|
192
223
|
const configured = getSiteUrlFromEnv();
|
|
193
224
|
if (configured) return configured;
|
|
194
225
|
|
|
226
|
+
if (fromRequest) return fromRequest;
|
|
227
|
+
|
|
195
228
|
warnUnresolved();
|
|
196
229
|
return DEV_FALLBACK;
|
|
197
230
|
}
|