create-brainerce-store 1.48.0 → 1.50.1
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/messages/en.json +1 -0
- package/messages/he.json +1 -0
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +56 -34
- package/templates/nextjs/base/src/app/layout.tsx.ejs +22 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +157 -157
- package/templates/nextjs/base/src/app/sitemap.ts +49 -49
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -2
- package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +10 -0
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.50.1",
|
|
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/messages/en.json
CHANGED
package/messages/he.json
CHANGED
package/package.json
CHANGED
|
@@ -4,63 +4,85 @@ const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
|
4
4
|
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5
5
|
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
6
6
|
|
|
7
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
8
|
+
/\/$/,
|
|
9
|
+
''
|
|
10
|
+
);
|
|
11
|
+
|
|
7
12
|
function isSecure(): boolean {
|
|
8
13
|
return process.env.NODE_ENV === 'production';
|
|
9
14
|
}
|
|
10
15
|
|
|
16
|
+
function errorRedirect(base: URL, message: string): NextResponse {
|
|
17
|
+
base.searchParams.set('oauth_error', message);
|
|
18
|
+
const response = NextResponse.redirect(base);
|
|
19
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
|
|
11
23
|
/**
|
|
12
24
|
* OAuth callback handler.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
25
|
+
*
|
|
26
|
+
* The backend redirects here with ?oauth_success=true&auth_code=<one-time-code>.
|
|
27
|
+
* We exchange the auth_code for the real JWT via a server-to-server POST (so the
|
|
28
|
+
* JWT never appears in any URL), then set an httpOnly cookie and redirect to the
|
|
29
|
+
* client-side callback page.
|
|
15
30
|
*/
|
|
16
31
|
export async function GET(request: NextRequest) {
|
|
17
32
|
const { searchParams } = request.nextUrl;
|
|
18
|
-
const
|
|
33
|
+
const authCode = searchParams.get('auth_code');
|
|
19
34
|
const oauthSuccess = searchParams.get('oauth_success');
|
|
20
35
|
const oauthError = searchParams.get('oauth_error');
|
|
21
36
|
|
|
22
|
-
// Build redirect URL to client-side callback page
|
|
23
37
|
const redirectUrl = new URL('/auth/callback', request.url);
|
|
24
38
|
|
|
25
39
|
if (oauthError) {
|
|
26
|
-
redirectUrl
|
|
27
|
-
const response = NextResponse.redirect(redirectUrl);
|
|
28
|
-
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
29
|
-
return response;
|
|
40
|
+
return errorRedirect(redirectUrl, oauthError);
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
if (oauthSuccess
|
|
33
|
-
redirectUrl
|
|
34
|
-
|
|
35
|
-
const response = NextResponse.redirect(redirectUrl);
|
|
43
|
+
if (oauthSuccess !== 'true' || !authCode) {
|
|
44
|
+
return errorRedirect(redirectUrl, 'Authentication failed');
|
|
45
|
+
}
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
// Exchange the one-time auth_code for the real JWT.
|
|
48
|
+
// This is a server-to-server call — the JWT never enters the browser URL.
|
|
49
|
+
let token: string;
|
|
50
|
+
try {
|
|
51
|
+
const exchangeRes = await fetch(`${BACKEND_URL}/api/oauth/customer/exchange`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ code: authCode }),
|
|
44
55
|
});
|
|
56
|
+
if (!exchangeRes.ok) {
|
|
57
|
+
throw new Error(`exchange responded ${exchangeRes.status}`);
|
|
58
|
+
}
|
|
59
|
+
const data = await exchangeRes.json();
|
|
60
|
+
if (!data.token) throw new Error('missing token in exchange response');
|
|
61
|
+
token = data.token;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('[oauth-callback] auth_code exchange failed:', err);
|
|
64
|
+
return errorRedirect(redirectUrl, 'Authentication failed');
|
|
65
|
+
}
|
|
45
66
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
httpOnly: false,
|
|
49
|
-
secure: isSecure(),
|
|
50
|
-
sameSite: 'lax',
|
|
51
|
-
path: '/',
|
|
52
|
-
maxAge: COOKIE_MAX_AGE,
|
|
53
|
-
});
|
|
67
|
+
redirectUrl.searchParams.set('oauth_success', 'true');
|
|
68
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
secure: isSecure(),
|
|
73
|
+
sameSite: 'lax',
|
|
74
|
+
path: '/',
|
|
75
|
+
maxAge: COOKIE_MAX_AGE,
|
|
76
|
+
});
|
|
57
77
|
|
|
58
|
-
|
|
59
|
-
|
|
78
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
79
|
+
httpOnly: false,
|
|
80
|
+
secure: isSecure(),
|
|
81
|
+
sameSite: 'lax',
|
|
82
|
+
path: '/',
|
|
83
|
+
maxAge: COOKIE_MAX_AGE,
|
|
84
|
+
});
|
|
60
85
|
|
|
61
|
-
// Fallback: no token or success flag
|
|
62
|
-
redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
|
|
63
|
-
const response = NextResponse.redirect(redirectUrl);
|
|
64
86
|
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
65
87
|
return response;
|
|
66
88
|
}
|
|
@@ -86,6 +86,17 @@ export default async function RootLayout({
|
|
|
86
86
|
.replace(/&/g, '\\u0026'),
|
|
87
87
|
}}
|
|
88
88
|
/>
|
|
89
|
+
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
90
|
+
SPA route changes. Cookieless (no consent banner needed); the
|
|
91
|
+
merchant can toggle it per sales channel in the dashboard. */}
|
|
92
|
+
<script
|
|
93
|
+
defer
|
|
94
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com'}/t.js`}
|
|
95
|
+
data-channel={
|
|
96
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
97
|
+
}
|
|
98
|
+
nonce={nonce}
|
|
99
|
+
/>
|
|
89
100
|
</head>
|
|
90
101
|
<body className={font.className}>
|
|
91
102
|
<StoreProvider locale={locale} initialStoreInfo={storeInfo}>
|
|
@@ -181,6 +192,17 @@ export default async function RootLayout({
|
|
|
181
192
|
.replace(/&/g, '\\u0026'),
|
|
182
193
|
}}
|
|
183
194
|
/>
|
|
195
|
+
{/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
|
|
196
|
+
SPA route changes. Cookieless (no consent banner needed); the
|
|
197
|
+
merchant can toggle it per sales channel in the dashboard. */}
|
|
198
|
+
<script
|
|
199
|
+
defer
|
|
200
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_API_URL || 'https://api.brainerce.com'}/t.js`}
|
|
201
|
+
data-channel={
|
|
202
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
203
|
+
}
|
|
204
|
+
nonce={nonce}
|
|
205
|
+
/>
|
|
184
206
|
</head>
|
|
185
207
|
<body className={font.className}>
|
|
186
208
|
<StoreProvider initialStoreInfo={storeInfo}>
|
|
@@ -1,157 +1,157 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import { notFound } from 'next/navigation';
|
|
3
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
-
import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
|
|
5
|
-
import { resolveCurrency } from '@/lib/resolve-currency';
|
|
6
|
-
import { buildMetaDescription } from '@/lib/seo';
|
|
7
|
-
import { decodeSlug } from '@/lib/utils';
|
|
8
|
-
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
-
import { ReviewsSection } from '@/components/reviews/reviews-section';
|
|
10
|
-
import { ProductClientSection } from './product-client-section';
|
|
11
|
-
|
|
12
|
-
type Props = {
|
|
13
|
-
params: Promise<{ slug: string; locale?: string }>;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
function buildHreflang(
|
|
17
|
-
baseUrl: string,
|
|
18
|
-
baseSlug: string,
|
|
19
|
-
localeSlugs: Record<string, string>,
|
|
20
|
-
locales: string[],
|
|
21
|
-
defaultLoc: string
|
|
22
|
-
): Record<string, string> {
|
|
23
|
-
const langs: Record<string, string> = {};
|
|
24
|
-
for (const loc of locales) {
|
|
25
|
-
const locSlug = localeSlugs[loc] || baseSlug;
|
|
26
|
-
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
27
|
-
langs[loc] = `${baseUrl}${path}`;
|
|
28
|
-
}
|
|
29
|
-
// x-default points to the default-locale canonical (no prefix)
|
|
30
|
-
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
31
|
-
return langs;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
35
|
-
const { slug: rawSlug, locale } = await params;
|
|
36
|
-
const slug = decodeSlug(rawSlug);
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const client = getServerClient(locale);
|
|
40
|
-
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
41
|
-
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
42
|
-
// silently emit a wrong currency when the API is briefly unreachable).
|
|
43
|
-
const [product, storeInfo] = await Promise.all([
|
|
44
|
-
client.getProductBySlug(slug),
|
|
45
|
-
fetchStoreInfo(locale),
|
|
46
|
-
]);
|
|
47
|
-
const imageUrl = product.images?.[0]?.url;
|
|
48
|
-
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
49
|
-
// version of the visible description, then product name. We must NEVER
|
|
50
|
-
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
51
|
-
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
52
|
-
const seoDescription =
|
|
53
|
-
(product as { seoDescription?: string | null }).seoDescription ||
|
|
54
|
-
buildMetaDescription(product.description) ||
|
|
55
|
-
product.name;
|
|
56
|
-
|
|
57
|
-
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
58
|
-
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
59
|
-
// here (the previous bug) causes price-zero link cards. Use the real
|
|
60
|
-
// effective price from the SDK helper.
|
|
61
|
-
const priceInfo = getProductPriceInfo(product);
|
|
62
|
-
const currency = resolveCurrency(storeInfo);
|
|
63
|
-
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
64
|
-
const inStock = product.inventory?.canPurchase !== false;
|
|
65
|
-
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
66
|
-
|
|
67
|
-
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
68
|
-
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
69
|
-
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
70
|
-
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
71
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
72
|
-
const baseSlug = product.slug || slug;
|
|
73
|
-
const localeSlugs = product.localeSlugs ?? {};
|
|
74
|
-
|
|
75
|
-
// Canonical: use the locale-specific slug for this locale when available.
|
|
76
|
-
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
77
|
-
const canonicalPath =
|
|
78
|
-
locale && locale !== defaultLoc
|
|
79
|
-
? `/${locale}/products/${canonicalSlug}`
|
|
80
|
-
: `/products/${canonicalSlug}`;
|
|
81
|
-
|
|
82
|
-
const hreflangLanguages =
|
|
83
|
-
supportedLocales.length > 1
|
|
84
|
-
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
85
|
-
: undefined;
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
title: seoTitle,
|
|
89
|
-
description: seoDescription,
|
|
90
|
-
alternates: {
|
|
91
|
-
canonical: canonicalPath,
|
|
92
|
-
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
93
|
-
},
|
|
94
|
-
openGraph: {
|
|
95
|
-
title: seoTitle,
|
|
96
|
-
description: seoDescription,
|
|
97
|
-
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
98
|
-
type: 'website',
|
|
99
|
-
},
|
|
100
|
-
twitter: {
|
|
101
|
-
card: 'summary_large_image',
|
|
102
|
-
title: seoTitle,
|
|
103
|
-
description: seoDescription,
|
|
104
|
-
images: imageUrl ? [imageUrl] : [],
|
|
105
|
-
},
|
|
106
|
-
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
107
|
-
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
108
|
-
// can't determine a positive amount, rather than shipping "0".
|
|
109
|
-
other: {
|
|
110
|
-
...(priceAmount
|
|
111
|
-
? {
|
|
112
|
-
'og:price:amount': priceAmount,
|
|
113
|
-
'og:price:currency': currency,
|
|
114
|
-
'product:price:amount': priceAmount,
|
|
115
|
-
'product:price:currency': currency,
|
|
116
|
-
}
|
|
117
|
-
: {}),
|
|
118
|
-
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
119
|
-
'product:condition': 'new',
|
|
120
|
-
...(brandName ? { 'product:brand': brandName } : {}),
|
|
121
|
-
},
|
|
122
|
-
};
|
|
123
|
-
} catch {
|
|
124
|
-
return {
|
|
125
|
-
title: 'Product not found',
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export default async function ProductDetailPage({ params }: Props) {
|
|
131
|
-
const { slug: rawSlug, locale } = await params;
|
|
132
|
-
const slug = decodeSlug(rawSlug);
|
|
133
|
-
|
|
134
|
-
let product;
|
|
135
|
-
try {
|
|
136
|
-
const client = getServerClient(locale);
|
|
137
|
-
product = await client.getProductBySlug(slug);
|
|
138
|
-
} catch {
|
|
139
|
-
notFound();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
143
|
-
const productUrl = `${baseUrl}/products/${slug}`;
|
|
144
|
-
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
145
|
-
// collapses both into one backend request per render. resolveCurrency owns
|
|
146
|
-
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
147
|
-
const storeInfo = await fetchStoreInfo(locale);
|
|
148
|
-
const currency = resolveCurrency(storeInfo);
|
|
149
|
-
|
|
150
|
-
return (
|
|
151
|
-
<>
|
|
152
|
-
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
153
|
-
<ProductClientSection product={product} />
|
|
154
|
-
<ReviewsSection productId={product.id} />
|
|
155
|
-
</>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
+
import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
|
|
5
|
+
import { resolveCurrency } from '@/lib/resolve-currency';
|
|
6
|
+
import { buildMetaDescription } from '@/lib/seo';
|
|
7
|
+
import { decodeSlug } from '@/lib/utils';
|
|
8
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
+
import { ReviewsSection } from '@/components/reviews/reviews-section';
|
|
10
|
+
import { ProductClientSection } from './product-client-section';
|
|
11
|
+
|
|
12
|
+
type Props = {
|
|
13
|
+
params: Promise<{ slug: string; locale?: string }>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildHreflang(
|
|
17
|
+
baseUrl: string,
|
|
18
|
+
baseSlug: string,
|
|
19
|
+
localeSlugs: Record<string, string>,
|
|
20
|
+
locales: string[],
|
|
21
|
+
defaultLoc: string
|
|
22
|
+
): Record<string, string> {
|
|
23
|
+
const langs: Record<string, string> = {};
|
|
24
|
+
for (const loc of locales) {
|
|
25
|
+
const locSlug = localeSlugs[loc] || baseSlug;
|
|
26
|
+
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
27
|
+
langs[loc] = `${baseUrl}${path}`;
|
|
28
|
+
}
|
|
29
|
+
// x-default points to the default-locale canonical (no prefix)
|
|
30
|
+
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
31
|
+
return langs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
35
|
+
const { slug: rawSlug, locale } = await params;
|
|
36
|
+
const slug = decodeSlug(rawSlug);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const client = getServerClient(locale);
|
|
40
|
+
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
41
|
+
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
42
|
+
// silently emit a wrong currency when the API is briefly unreachable).
|
|
43
|
+
const [product, storeInfo] = await Promise.all([
|
|
44
|
+
client.getProductBySlug(slug),
|
|
45
|
+
fetchStoreInfo(locale),
|
|
46
|
+
]);
|
|
47
|
+
const imageUrl = product.images?.[0]?.url;
|
|
48
|
+
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
49
|
+
// version of the visible description, then product name. We must NEVER
|
|
50
|
+
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
51
|
+
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
52
|
+
const seoDescription =
|
|
53
|
+
(product as { seoDescription?: string | null }).seoDescription ||
|
|
54
|
+
buildMetaDescription(product.description) ||
|
|
55
|
+
product.name;
|
|
56
|
+
|
|
57
|
+
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
58
|
+
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
59
|
+
// here (the previous bug) causes price-zero link cards. Use the real
|
|
60
|
+
// effective price from the SDK helper.
|
|
61
|
+
const priceInfo = getProductPriceInfo(product);
|
|
62
|
+
const currency = resolveCurrency(storeInfo);
|
|
63
|
+
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
64
|
+
const inStock = product.inventory?.canPurchase !== false;
|
|
65
|
+
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
66
|
+
|
|
67
|
+
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
68
|
+
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
69
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
70
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
71
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
72
|
+
const baseSlug = product.slug || slug;
|
|
73
|
+
const localeSlugs = product.localeSlugs ?? {};
|
|
74
|
+
|
|
75
|
+
// Canonical: use the locale-specific slug for this locale when available.
|
|
76
|
+
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
77
|
+
const canonicalPath =
|
|
78
|
+
locale && locale !== defaultLoc
|
|
79
|
+
? `/${locale}/products/${canonicalSlug}`
|
|
80
|
+
: `/products/${canonicalSlug}`;
|
|
81
|
+
|
|
82
|
+
const hreflangLanguages =
|
|
83
|
+
supportedLocales.length > 1
|
|
84
|
+
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
85
|
+
: undefined;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
title: seoTitle,
|
|
89
|
+
description: seoDescription,
|
|
90
|
+
alternates: {
|
|
91
|
+
canonical: canonicalPath,
|
|
92
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
93
|
+
},
|
|
94
|
+
openGraph: {
|
|
95
|
+
title: seoTitle,
|
|
96
|
+
description: seoDescription,
|
|
97
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
98
|
+
type: 'website',
|
|
99
|
+
},
|
|
100
|
+
twitter: {
|
|
101
|
+
card: 'summary_large_image',
|
|
102
|
+
title: seoTitle,
|
|
103
|
+
description: seoDescription,
|
|
104
|
+
images: imageUrl ? [imageUrl] : [],
|
|
105
|
+
},
|
|
106
|
+
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
107
|
+
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
108
|
+
// can't determine a positive amount, rather than shipping "0".
|
|
109
|
+
other: {
|
|
110
|
+
...(priceAmount
|
|
111
|
+
? {
|
|
112
|
+
'og:price:amount': priceAmount,
|
|
113
|
+
'og:price:currency': currency,
|
|
114
|
+
'product:price:amount': priceAmount,
|
|
115
|
+
'product:price:currency': currency,
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
118
|
+
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
119
|
+
'product:condition': 'new',
|
|
120
|
+
...(brandName ? { 'product:brand': brandName } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
} catch {
|
|
124
|
+
return {
|
|
125
|
+
title: 'Product not found',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
131
|
+
const { slug: rawSlug, locale } = await params;
|
|
132
|
+
const slug = decodeSlug(rawSlug);
|
|
133
|
+
|
|
134
|
+
let product;
|
|
135
|
+
try {
|
|
136
|
+
const client = getServerClient(locale);
|
|
137
|
+
product = await client.getProductBySlug(slug);
|
|
138
|
+
} catch {
|
|
139
|
+
notFound();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
143
|
+
const productUrl = `${baseUrl}/products/${slug}`;
|
|
144
|
+
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
145
|
+
// collapses both into one backend request per render. resolveCurrency owns
|
|
146
|
+
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
147
|
+
const storeInfo = await fetchStoreInfo(locale);
|
|
148
|
+
const currency = resolveCurrency(storeInfo);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<>
|
|
152
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
153
|
+
<ProductClientSection product={product} />
|
|
154
|
+
<ReviewsSection productId={product.id} />
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
import type { MetadataRoute } from 'next';
|
|
2
|
-
import { getServerClient } from '@/lib/brainerce';
|
|
3
|
-
|
|
4
|
-
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
5
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
6
|
-
|
|
7
|
-
const client = getServerClient();
|
|
8
|
-
const storeInfo = await client.getStoreInfo().catch(() => null);
|
|
9
|
-
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
10
|
-
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
11
|
-
const isMultiLocale = supportedLocales.length > 1;
|
|
12
|
-
|
|
13
|
-
const staticPages: MetadataRoute.Sitemap = isMultiLocale
|
|
14
|
-
? supportedLocales.flatMap((loc) => {
|
|
15
|
-
const prefix = loc === defaultLoc ? '' : `/${loc}`;
|
|
16
|
-
return [
|
|
17
|
-
{ url: `${baseUrl}${prefix || '/'}`, lastModified: new Date(), priority: 1 },
|
|
18
|
-
{ url: `${baseUrl}${prefix}/products`, lastModified: new Date(), priority: 0.9 },
|
|
19
|
-
];
|
|
20
|
-
})
|
|
21
|
-
: [
|
|
22
|
-
{ url: baseUrl, lastModified: new Date(), priority: 1 },
|
|
23
|
-
{ url: `${baseUrl}/products`, lastModified: new Date(), priority: 0.9 },
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
const { data: products } = await client.getProducts({ limit: 1000 });
|
|
28
|
-
|
|
29
|
-
const productPages: MetadataRoute.Sitemap = products.flatMap((product) => {
|
|
30
|
-
const baseSlug = product.slug || product.id;
|
|
31
|
-
const localeSlugs = product.localeSlugs ?? {};
|
|
32
|
-
const lastMod = product.updatedAt ? new Date(product.updatedAt) : new Date();
|
|
33
|
-
|
|
34
|
-
if (!isMultiLocale) {
|
|
35
|
-
return [{ url: `${baseUrl}/products/${baseSlug}`, lastModified: lastMod, priority: 0.8 }];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return supportedLocales.map((loc) => {
|
|
39
|
-
const locSlug = localeSlugs[loc] || baseSlug;
|
|
40
|
-
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
41
|
-
return { url: `${baseUrl}${path}`, lastModified: lastMod, priority: 0.8 };
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
return [...staticPages, ...productPages];
|
|
46
|
-
} catch {
|
|
47
|
-
return staticPages;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
3
|
+
|
|
4
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
5
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
6
|
+
|
|
7
|
+
const client = getServerClient();
|
|
8
|
+
const storeInfo = await client.getStoreInfo().catch(() => null);
|
|
9
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
10
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
11
|
+
const isMultiLocale = supportedLocales.length > 1;
|
|
12
|
+
|
|
13
|
+
const staticPages: MetadataRoute.Sitemap = isMultiLocale
|
|
14
|
+
? supportedLocales.flatMap((loc) => {
|
|
15
|
+
const prefix = loc === defaultLoc ? '' : `/${loc}`;
|
|
16
|
+
return [
|
|
17
|
+
{ url: `${baseUrl}${prefix || '/'}`, lastModified: new Date(), priority: 1 },
|
|
18
|
+
{ url: `${baseUrl}${prefix}/products`, lastModified: new Date(), priority: 0.9 },
|
|
19
|
+
];
|
|
20
|
+
})
|
|
21
|
+
: [
|
|
22
|
+
{ url: baseUrl, lastModified: new Date(), priority: 1 },
|
|
23
|
+
{ url: `${baseUrl}/products`, lastModified: new Date(), priority: 0.9 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const { data: products } = await client.getProducts({ limit: 1000 });
|
|
28
|
+
|
|
29
|
+
const productPages: MetadataRoute.Sitemap = products.flatMap((product) => {
|
|
30
|
+
const baseSlug = product.slug || product.id;
|
|
31
|
+
const localeSlugs = product.localeSlugs ?? {};
|
|
32
|
+
const lastMod = product.updatedAt ? new Date(product.updatedAt) : new Date();
|
|
33
|
+
|
|
34
|
+
if (!isMultiLocale) {
|
|
35
|
+
return [{ url: `${baseUrl}/products/${baseSlug}`, lastModified: lastMod, priority: 0.8 }];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return supportedLocales.map((loc) => {
|
|
39
|
+
const locSlug = localeSlugs[loc] || baseSlug;
|
|
40
|
+
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
41
|
+
return { url: `${baseUrl}${path}`, lastModified: lastMod, priority: 0.8 };
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return [...staticPages, ...productPages];
|
|
46
|
+
} catch {
|
|
47
|
+
return staticPages;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -291,7 +291,16 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
|
|
|
291
291
|
const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0;
|
|
292
292
|
const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0;
|
|
293
293
|
const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0;
|
|
294
|
-
|
|
294
|
+
// Inclusive (VAT) orders persist taxAmount=0; the real VAT lives on the
|
|
295
|
+
// breakdown. Surface whichever is present (mirror of TaxDisplay).
|
|
296
|
+
const explicitTax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
|
|
297
|
+
const taxIncluded = !!order.taxBreakdown?.pricesIncludeTax;
|
|
298
|
+
const tax =
|
|
299
|
+
explicitTax > 0
|
|
300
|
+
? explicitTax
|
|
301
|
+
: typeof order.taxBreakdown?.totalTax === 'number'
|
|
302
|
+
? order.taxBreakdown.totalTax
|
|
303
|
+
: 0;
|
|
295
304
|
const rules = order.appliedDiscounts;
|
|
296
305
|
|
|
297
306
|
const hasBreakdown = subtotal !== null && subtotal > 0;
|
|
@@ -352,7 +361,7 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
|
|
|
352
361
|
|
|
353
362
|
{tax > 0 && (
|
|
354
363
|
<div className="flex items-center justify-between">
|
|
355
|
-
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
364
|
+
<span className="text-muted-foreground">{taxIncluded ? tc('taxIncl') : tc('tax')}</span>
|
|
356
365
|
<span className="text-foreground">{formatPrice(tax, { currency }) as string}</span>
|
|
357
366
|
</div>
|
|
358
367
|
)}
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
import * as React from 'react';
|
|
19
19
|
import type { Content } from 'brainerce';
|
|
20
20
|
import { HeaderAccount } from './header-account';
|
|
21
|
+
<% if (i18nEnabled) { %>
|
|
22
|
+
import { LanguageSwitcher } from '@/components/layout/language-switcher';
|
|
23
|
+
<% } %>
|
|
21
24
|
|
|
22
25
|
interface SiteHeaderProps {
|
|
23
26
|
/** Pre-fetched header payload (server-side). `null` triggers static fallback. */
|
|
@@ -79,6 +82,9 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
79
82
|
{brandLabel}
|
|
80
83
|
</a>
|
|
81
84
|
<div className="flex items-center gap-2">
|
|
85
|
+
<% if (i18nEnabled) { %>
|
|
86
|
+
<LanguageSwitcher />
|
|
87
|
+
<% } %>
|
|
82
88
|
<HeaderAccount />
|
|
83
89
|
<a
|
|
84
90
|
href="/cart"
|
|
@@ -133,6 +139,10 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
133
139
|
</a>
|
|
134
140
|
) : null}
|
|
135
141
|
|
|
142
|
+
<% if (i18nEnabled) { %>
|
|
143
|
+
<LanguageSwitcher />
|
|
144
|
+
<% } %>
|
|
145
|
+
|
|
136
146
|
<HeaderAccount />
|
|
137
147
|
|
|
138
148
|
<a
|