create-brainerce-store 1.62.0 → 1.63.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 +21 -21
- package/README.md +71 -71
- package/dist/index.js +5 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- 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/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import { notFound } from 'next/navigation';
|
|
3
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
-
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
5
|
-
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
6
|
-
import { buildMetaDescription } from '@/core/lib/seo';
|
|
7
|
-
import { decodeSlug } from '@/core/lib/utils';
|
|
8
|
-
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
-
import { Breadcrumbs } from '@/components/seo/breadcrumbs';
|
|
10
|
-
import { ReviewsSection } from '@/ui/product/reviews-section';
|
|
11
|
-
import { ProductClientSection } from '@/ui/product/product-client-section';
|
|
12
|
-
|
|
13
|
-
type Props = {
|
|
14
|
-
params: Promise<{ slug: string; locale?: string }>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function buildHreflang(
|
|
18
|
-
baseUrl: string,
|
|
19
|
-
baseSlug: string,
|
|
20
|
-
localeSlugs: Record<string, string>,
|
|
21
|
-
locales: string[],
|
|
22
|
-
defaultLoc: string
|
|
23
|
-
): Record<string, string> {
|
|
24
|
-
const langs: Record<string, string> = {};
|
|
25
|
-
for (const loc of locales) {
|
|
26
|
-
const locSlug = localeSlugs[loc] || baseSlug;
|
|
27
|
-
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
28
|
-
langs[loc] = `${baseUrl}${path}`;
|
|
29
|
-
}
|
|
30
|
-
// x-default points to the default-locale canonical (no prefix)
|
|
31
|
-
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
32
|
-
return langs;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
36
|
-
const { slug: rawSlug, locale } = await params;
|
|
37
|
-
const slug = decodeSlug(rawSlug);
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
const client = getServerClient(locale);
|
|
41
|
-
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
42
|
-
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
43
|
-
// silently emit a wrong currency when the API is briefly unreachable).
|
|
44
|
-
const [product, storeInfo] = await Promise.all([
|
|
45
|
-
client.getProductBySlug(slug),
|
|
46
|
-
fetchStoreInfo(locale),
|
|
47
|
-
]);
|
|
48
|
-
const imageUrl = product.images?.[0]?.url;
|
|
49
|
-
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
50
|
-
// version of the visible description, then product name. We must NEVER
|
|
51
|
-
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
52
|
-
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
53
|
-
const seoDescription =
|
|
54
|
-
(product as { seoDescription?: string | null }).seoDescription ||
|
|
55
|
-
buildMetaDescription(product.description) ||
|
|
56
|
-
product.name;
|
|
57
|
-
|
|
58
|
-
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
59
|
-
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
60
|
-
// here (the previous bug) causes price-zero link cards. Use the real
|
|
61
|
-
// effective price from the SDK helper.
|
|
62
|
-
const priceInfo = getProductPriceInfo(product);
|
|
63
|
-
const currency = resolveCurrency(storeInfo);
|
|
64
|
-
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
65
|
-
const inStock = product.inventory?.canPurchase !== false;
|
|
66
|
-
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
67
|
-
|
|
68
|
-
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
69
|
-
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
70
|
-
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
71
|
-
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
72
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
-
const baseSlug = product.slug || slug;
|
|
74
|
-
const localeSlugs = product.localeSlugs ?? {};
|
|
75
|
-
|
|
76
|
-
// Canonical: use the locale-specific slug for this locale when available.
|
|
77
|
-
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
78
|
-
const canonicalPath =
|
|
79
|
-
locale && locale !== defaultLoc
|
|
80
|
-
? `/${locale}/products/${canonicalSlug}`
|
|
81
|
-
: `/products/${canonicalSlug}`;
|
|
82
|
-
|
|
83
|
-
const hreflangLanguages =
|
|
84
|
-
supportedLocales.length > 1
|
|
85
|
-
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
86
|
-
: undefined;
|
|
87
|
-
|
|
88
|
-
return {
|
|
89
|
-
title: seoTitle,
|
|
90
|
-
description: seoDescription,
|
|
91
|
-
alternates: {
|
|
92
|
-
canonical: canonicalPath,
|
|
93
|
-
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
94
|
-
},
|
|
95
|
-
openGraph: {
|
|
96
|
-
title: seoTitle,
|
|
97
|
-
description: seoDescription,
|
|
98
|
-
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
99
|
-
type: 'website',
|
|
100
|
-
},
|
|
101
|
-
twitter: {
|
|
102
|
-
card: 'summary_large_image',
|
|
103
|
-
title: seoTitle,
|
|
104
|
-
description: seoDescription,
|
|
105
|
-
images: imageUrl ? [imageUrl] : [],
|
|
106
|
-
},
|
|
107
|
-
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
108
|
-
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
109
|
-
// can't determine a positive amount, rather than shipping "0".
|
|
110
|
-
other: {
|
|
111
|
-
...(priceAmount
|
|
112
|
-
? {
|
|
113
|
-
'og:price:amount': priceAmount,
|
|
114
|
-
'og:price:currency': currency,
|
|
115
|
-
'product:price:amount': priceAmount,
|
|
116
|
-
'product:price:currency': currency,
|
|
117
|
-
}
|
|
118
|
-
: {}),
|
|
119
|
-
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
120
|
-
'product:condition': 'new',
|
|
121
|
-
...(brandName ? { 'product:brand': brandName } : {}),
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
} catch {
|
|
125
|
-
return {
|
|
126
|
-
title: 'Product not found',
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export default async function ProductDetailPage({ params }: Props) {
|
|
132
|
-
const { slug: rawSlug, locale } = await params;
|
|
133
|
-
const slug = decodeSlug(rawSlug);
|
|
134
|
-
|
|
135
|
-
let product;
|
|
136
|
-
try {
|
|
137
|
-
const client = getServerClient(locale);
|
|
138
|
-
product = await client.getProductBySlug(slug);
|
|
139
|
-
} catch {
|
|
140
|
-
notFound();
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
144
|
-
const productUrl = `${baseUrl}/products/${slug}`;
|
|
145
|
-
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
146
|
-
// collapses both into one backend request per render. resolveCurrency owns
|
|
147
|
-
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
148
|
-
const storeInfo = await fetchStoreInfo(locale);
|
|
149
|
-
const currency = resolveCurrency(storeInfo);
|
|
150
|
-
const localePrefix = locale ? `/${locale}` : '';
|
|
151
|
-
const primaryCategory = product.categories?.find((c) => c.slug);
|
|
152
|
-
const breadcrumbItems = [
|
|
153
|
-
{ name: 'Home', href: `${localePrefix}/` },
|
|
154
|
-
...(primaryCategory
|
|
155
|
-
? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
|
|
156
|
-
: []),
|
|
157
|
-
{ name: product.name },
|
|
158
|
-
];
|
|
159
|
-
|
|
160
|
-
return (
|
|
161
|
-
<>
|
|
162
|
-
<ProductJsonLd
|
|
163
|
-
product={product}
|
|
164
|
-
url={productUrl}
|
|
165
|
-
currency={currency}
|
|
166
|
-
shipping={storeInfo?.shipping}
|
|
167
|
-
/>
|
|
168
|
-
<div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
|
|
169
|
-
<Breadcrumbs items={breadcrumbItems} />
|
|
170
|
-
</div>
|
|
171
|
-
<ProductClientSection product={product} />
|
|
172
|
-
<ReviewsSection productId={product.id} />
|
|
173
|
-
</>
|
|
174
|
-
);
|
|
175
|
-
}
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
4
|
+
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce';
|
|
5
|
+
import { resolveCurrency } from '@/core/lib/resolve-currency';
|
|
6
|
+
import { buildMetaDescription } from '@/core/lib/seo';
|
|
7
|
+
import { decodeSlug } from '@/core/lib/utils';
|
|
8
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
9
|
+
import { Breadcrumbs } from '@/components/seo/breadcrumbs';
|
|
10
|
+
import { ReviewsSection } from '@/ui/product/reviews-section';
|
|
11
|
+
import { ProductClientSection } from '@/ui/product/product-client-section';
|
|
12
|
+
|
|
13
|
+
type Props = {
|
|
14
|
+
params: Promise<{ slug: string; locale?: string }>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function buildHreflang(
|
|
18
|
+
baseUrl: string,
|
|
19
|
+
baseSlug: string,
|
|
20
|
+
localeSlugs: Record<string, string>,
|
|
21
|
+
locales: string[],
|
|
22
|
+
defaultLoc: string
|
|
23
|
+
): Record<string, string> {
|
|
24
|
+
const langs: Record<string, string> = {};
|
|
25
|
+
for (const loc of locales) {
|
|
26
|
+
const locSlug = localeSlugs[loc] || baseSlug;
|
|
27
|
+
const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
|
|
28
|
+
langs[loc] = `${baseUrl}${path}`;
|
|
29
|
+
}
|
|
30
|
+
// x-default points to the default-locale canonical (no prefix)
|
|
31
|
+
langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
|
|
32
|
+
return langs;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
36
|
+
const { slug: rawSlug, locale } = await params;
|
|
37
|
+
const slug = decodeSlug(rawSlug);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const client = getServerClient(locale);
|
|
41
|
+
// Fetch product + store info in parallel; storeInfo gives us the live
|
|
42
|
+
// currency for OG price tags (with env-var + USD fallbacks so we never
|
|
43
|
+
// silently emit a wrong currency when the API is briefly unreachable).
|
|
44
|
+
const [product, storeInfo] = await Promise.all([
|
|
45
|
+
client.getProductBySlug(slug),
|
|
46
|
+
fetchStoreInfo(locale),
|
|
47
|
+
]);
|
|
48
|
+
const imageUrl = product.images?.[0]?.url;
|
|
49
|
+
// Prefer merchant-authored SEO copy; fall back to a stripped+truncated
|
|
50
|
+
// version of the visible description, then product name. We must NEVER
|
|
51
|
+
// emit raw HTML or a mid-word cut into <meta name="description">.
|
|
52
|
+
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
53
|
+
const seoDescription =
|
|
54
|
+
(product as { seoDescription?: string | null }).seoDescription ||
|
|
55
|
+
buildMetaDescription(product.description) ||
|
|
56
|
+
product.name;
|
|
57
|
+
|
|
58
|
+
// OG product meta tags drive WhatsApp / Facebook / X link previews and
|
|
59
|
+
// Google Merchant Center product enrichment. Emitting the literal "0"
|
|
60
|
+
// here (the previous bug) causes price-zero link cards. Use the real
|
|
61
|
+
// effective price from the SDK helper.
|
|
62
|
+
const priceInfo = getProductPriceInfo(product);
|
|
63
|
+
const currency = resolveCurrency(storeInfo);
|
|
64
|
+
const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
|
|
65
|
+
const inStock = product.inventory?.canPurchase !== false;
|
|
66
|
+
const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
|
|
67
|
+
|
|
68
|
+
// Multilingual SEO: hreflang tags + correct canonical per locale.
|
|
69
|
+
// Locales come from storeInfo.i18n — already fetched above, zero extra cost.
|
|
70
|
+
const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
|
|
71
|
+
const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
|
|
72
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
+
const baseSlug = product.slug || slug;
|
|
74
|
+
const localeSlugs = product.localeSlugs ?? {};
|
|
75
|
+
|
|
76
|
+
// Canonical: use the locale-specific slug for this locale when available.
|
|
77
|
+
const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
|
|
78
|
+
const canonicalPath =
|
|
79
|
+
locale && locale !== defaultLoc
|
|
80
|
+
? `/${locale}/products/${canonicalSlug}`
|
|
81
|
+
: `/products/${canonicalSlug}`;
|
|
82
|
+
|
|
83
|
+
const hreflangLanguages =
|
|
84
|
+
supportedLocales.length > 1
|
|
85
|
+
? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
|
|
86
|
+
: undefined;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
title: seoTitle,
|
|
90
|
+
description: seoDescription,
|
|
91
|
+
alternates: {
|
|
92
|
+
canonical: canonicalPath,
|
|
93
|
+
...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
|
|
94
|
+
},
|
|
95
|
+
openGraph: {
|
|
96
|
+
title: seoTitle,
|
|
97
|
+
description: seoDescription,
|
|
98
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
99
|
+
type: 'website',
|
|
100
|
+
},
|
|
101
|
+
twitter: {
|
|
102
|
+
card: 'summary_large_image',
|
|
103
|
+
title: seoTitle,
|
|
104
|
+
description: seoDescription,
|
|
105
|
+
images: imageUrl ? [imageUrl] : [],
|
|
106
|
+
},
|
|
107
|
+
// Emit the OG product extension (Facebook / WhatsApp / X link previews,
|
|
108
|
+
// Google Merchant Center). Skips the price pair entirely when the SDK
|
|
109
|
+
// can't determine a positive amount, rather than shipping "0".
|
|
110
|
+
other: {
|
|
111
|
+
...(priceAmount
|
|
112
|
+
? {
|
|
113
|
+
'og:price:amount': priceAmount,
|
|
114
|
+
'og:price:currency': currency,
|
|
115
|
+
'product:price:amount': priceAmount,
|
|
116
|
+
'product:price:currency': currency,
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
'product:availability': inStock ? 'in stock' : 'out of stock',
|
|
120
|
+
'product:condition': 'new',
|
|
121
|
+
...(brandName ? { 'product:brand': brandName } : {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return {
|
|
126
|
+
title: 'Product not found',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
132
|
+
const { slug: rawSlug, locale } = await params;
|
|
133
|
+
const slug = decodeSlug(rawSlug);
|
|
134
|
+
|
|
135
|
+
let product;
|
|
136
|
+
try {
|
|
137
|
+
const client = getServerClient(locale);
|
|
138
|
+
product = await client.getProductBySlug(slug);
|
|
139
|
+
} catch {
|
|
140
|
+
notFound();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
144
|
+
const productUrl = `${baseUrl}/products/${slug}`;
|
|
145
|
+
// Reuse the cached storeInfo from generateMetadata's call — React cache()
|
|
146
|
+
// collapses both into one backend request per render. resolveCurrency owns
|
|
147
|
+
// the env-var + USD fallback chain so this stays a single source of truth.
|
|
148
|
+
const storeInfo = await fetchStoreInfo(locale);
|
|
149
|
+
const currency = resolveCurrency(storeInfo);
|
|
150
|
+
const localePrefix = locale ? `/${locale}` : '';
|
|
151
|
+
const primaryCategory = product.categories?.find((c) => c.slug);
|
|
152
|
+
const breadcrumbItems = [
|
|
153
|
+
{ name: 'Home', href: `${localePrefix}/` },
|
|
154
|
+
...(primaryCategory
|
|
155
|
+
? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
|
|
156
|
+
: []),
|
|
157
|
+
{ name: product.name },
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<>
|
|
162
|
+
<ProductJsonLd
|
|
163
|
+
product={product}
|
|
164
|
+
url={productUrl}
|
|
165
|
+
currency={currency}
|
|
166
|
+
shipping={storeInfo?.shipping}
|
|
167
|
+
/>
|
|
168
|
+
<div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
|
|
169
|
+
<Breadcrumbs items={breadcrumbItems} />
|
|
170
|
+
</div>
|
|
171
|
+
<ProductClientSection product={product} />
|
|
172
|
+
<ReviewsSection productId={product.id} />
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Suspense } from 'react';
|
|
4
|
-
import { ProductListing } from '@/ui/product/product-listing';
|
|
5
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
-
|
|
7
|
-
export default function ProductsPage() {
|
|
8
|
-
return (
|
|
9
|
-
<Suspense
|
|
10
|
-
fallback={
|
|
11
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
12
|
-
<LoadingSpinner size="lg" />
|
|
13
|
-
</div>
|
|
14
|
-
}
|
|
15
|
-
>
|
|
16
|
-
<ProductListing />
|
|
17
|
-
</Suspense>
|
|
18
|
-
);
|
|
19
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense } from 'react';
|
|
4
|
+
import { ProductListing } from '@/ui/product/product-listing';
|
|
5
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
+
|
|
7
|
+
export default function ProductsPage() {
|
|
8
|
+
return (
|
|
9
|
+
<Suspense
|
|
10
|
+
fallback={
|
|
11
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
12
|
+
<LoadingSpinner size="lg" />
|
|
13
|
+
</div>
|
|
14
|
+
}
|
|
15
|
+
>
|
|
16
|
+
<ProductListing />
|
|
17
|
+
</Suspense>
|
|
18
|
+
);
|
|
19
|
+
}
|