create-brainerce-store 1.88.0 → 1.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.js +3 -2
  2. package/messages/en.json +21 -0
  3. package/messages/he.json +21 -0
  4. package/package.json +3 -2
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +259 -221
  7. package/templates/nextjs/base/TRANSLATIONS.md +6 -0
  8. package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
  9. package/templates/nextjs/base/scripts/connect.mjs +6 -0
  10. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
  11. package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
  12. package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
  13. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
  15. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
  16. package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
  17. package/templates/nextjs/base/src/app/page.tsx +109 -75
  18. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
  20. package/templates/nextjs/base/src/app/products/page.tsx +184 -19
  21. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
  23. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
  24. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
  26. package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
  27. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
  28. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
  29. package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
  30. package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
  31. package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
  32. package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
  33. package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
  34. package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
  35. package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
  36. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
  39. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
  42. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
  43. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
  44. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
@@ -1,220 +1,267 @@
1
- import type { Metadata } from 'next';
2
- import { notFound, permanentRedirect } from 'next/navigation';
3
- import { BrainerceError, getProductPriceInfo } from 'brainerce';
4
- import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
5
- import { getRegionId } from '@/core/lib/region.server';
6
- import { resolveCurrency } from '@/core/lib/resolve-currency';
7
- import { buildMetaDescription } from '@/core/lib/seo';
8
- import { canPurchaseProduct } from '@/core/lib/kit';
9
- import { decodeSlug } from '@/core/lib/utils';
10
- import { ProductJsonLd } from '@/components/seo/product-json-ld';
11
- import { getCanonicalSiteUrl } from '@/core/lib/site-url';
12
- import { Breadcrumbs } from '@/components/seo/breadcrumbs';
13
- import { ReviewsSection } from '@/ui/product/reviews-section';
14
- import { ProductFaqSection } from '@/ui/product/faq-section';
15
- import { ProductClientSection } from '@/ui/product/product-client-section';
16
-
17
- type Props = {
18
- params: Promise<{ slug: string; locale?: string }>;
19
- };
20
-
21
- function buildHreflang(
22
- baseUrl: string,
23
- baseSlug: string,
24
- localeSlugs: Record<string, string>,
25
- locales: string[],
26
- defaultLoc: string
27
- ): Record<string, string> {
28
- const langs: Record<string, string> = {};
29
- for (const loc of locales) {
30
- const locSlug = localeSlugs[loc] || baseSlug;
31
- const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
32
- langs[loc] = `${baseUrl}${path}`;
33
- }
34
- // x-default points to the default-locale canonical (no prefix)
35
- langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
36
- return langs;
37
- }
38
-
39
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
40
- const { slug: rawSlug, locale } = await params;
41
- const slug = decodeSlug(rawSlug);
42
-
43
- try {
44
- const client = await getServerClient(locale);
45
- // Fetch product + store info in parallel; storeInfo gives us the live
46
- // currency for OG price tags (with env-var + USD fallbacks so we never
47
- // silently emit a wrong currency when the API is briefly unreachable).
48
- // ⛔ `regionId` belongs on EVERY product read. Without it the backend
49
- // prices in the default region, so a shopper elsewhere gets the wrong
50
- // currency in the OG price meta that link previews and Merchant Center
51
- // read. `getRegionId()` is `cache()`-wrapped and shared with the layout
52
- // and the page body below, so this is not an extra round trip. It
53
- // resolves to `undefined` on a store with no regions, and the SDK then
54
- // omits the query param entirely.
55
- const regionId = await getRegionId();
56
- const [product, storeInfo] = await Promise.all([
57
- client.getProductBySlug(slug, { regionId }),
58
- fetchStoreInfo(locale),
59
- ]);
60
- const imageUrl = product.images?.[0]?.url;
61
- // Prefer merchant-authored SEO copy; fall back to a stripped+truncated
62
- // version of the visible description, then product name. We must NEVER
63
- // emit raw HTML or a mid-word cut into <meta name="description">.
64
- const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
65
- const seoDescription =
66
- (product as { seoDescription?: string | null }).seoDescription ||
67
- buildMetaDescription(product.description) ||
68
- product.name;
69
-
70
- // OG product meta tags drive WhatsApp / Facebook / X link previews and
71
- // Google Merchant Center product enrichment. Emitting the literal "0"
72
- // here (the previous bug) causes price-zero link cards. Use the real
73
- // effective price from the SDK helper.
74
- const priceInfo = getProductPriceInfo(product);
75
- const currency = resolveCurrency(storeInfo);
76
- const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
77
- // ⛔ A KIT has NO `inventory` block — its stock is `product.kitAvailable`.
78
- // Reading `inventory` here published `product:availability: in stock` for a
79
- // sold-out kit, so Facebook/WhatsApp link previews and Google Merchant
80
- // Center advertised something nobody could buy. `canPurchaseProduct` reads
81
- // the right field per product type.
82
- const inStock = canPurchaseProduct(product);
83
- const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
84
-
85
- // Multilingual SEO: hreflang tags + correct canonical per locale.
86
- // Locales come from storeInfo.i18n — already fetched above, zero extra cost.
87
- const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
88
- const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
89
- const baseUrl = await getCanonicalSiteUrl();
90
- const baseSlug = product.slug || slug;
91
- const localeSlugs = product.localeSlugs ?? {};
92
-
93
- // Canonical: use the locale-specific slug for this locale when available.
94
- const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
95
- const canonicalPath =
96
- locale && locale !== defaultLoc
97
- ? `/${locale}/products/${canonicalSlug}`
98
- : `/products/${canonicalSlug}`;
99
-
100
- const hreflangLanguages =
101
- supportedLocales.length > 1
102
- ? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
103
- : undefined;
104
-
105
- return {
106
- title: seoTitle,
107
- description: seoDescription,
108
- alternates: {
109
- canonical: canonicalPath,
110
- ...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
111
- },
112
- openGraph: {
113
- title: seoTitle,
114
- description: seoDescription,
115
- images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
116
- type: 'website',
117
- },
118
- twitter: {
119
- card: 'summary_large_image',
120
- title: seoTitle,
121
- description: seoDescription,
122
- images: imageUrl ? [imageUrl] : [],
123
- },
124
- // Emit the OG product extension (Facebook / WhatsApp / X link previews,
125
- // Google Merchant Center). Skips the price pair entirely when the SDK
126
- // can't determine a positive amount, rather than shipping "0".
127
- other: {
128
- ...(priceAmount
129
- ? {
130
- 'og:price:amount': priceAmount,
131
- 'og:price:currency': currency,
132
- 'product:price:amount': priceAmount,
133
- 'product:price:currency': currency,
134
- }
135
- : {}),
136
- 'product:availability': inStock ? 'in stock' : 'out of stock',
137
- 'product:condition': 'new',
138
- ...(brandName ? { 'product:brand': brandName } : {}),
139
- },
140
- };
141
- } catch (err) {
142
- if (err instanceof BrainerceError && err.statusCode === 404) {
143
- return { title: 'Product not found' };
144
- }
145
- // Anything else (channel origin rejection, network failure) hits the page
146
- // render below too and surfaces there — don't stamp a misleading title.
147
- return {};
148
- }
149
- }
150
-
151
- export default async function ProductDetailPage({ params }: Props) {
152
- const { slug: rawSlug, locale } = await params;
153
- const slug = decodeSlug(rawSlug);
154
-
155
- const client = await getServerClient(locale);
156
- // Same region as the metadata above (one shared `cache()`d resolve), so the
157
- // price in the <head> and the price on the page cannot disagree.
158
- const regionId = await getRegionId();
159
- let product;
160
- try {
161
- product = await client.getProductBySlug(slug, { regionId });
162
- } catch (err) {
163
- // Only a genuine 404 may become a storefront 404. Anything else — a 403
164
- // from the channel's origin check, a network failure, a 5xx — is a
165
- // configuration or availability problem; disguising it as "product not
166
- // found" sends the merchant hunting for a slug bug that isn't there.
167
- if (!(err instanceof BrainerceError && err.statusCode === 404)) {
168
- throw err;
169
- }
170
- // The slug may have been RENAMED — the platform records every rename.
171
- // 301 the old URL to the current slug so its ranking and inbound links
172
- // carry over; genuine unknowns fall through to the 404 page. Default
173
- // locale stays unprefixed (matches the sitemap/hreflang convention); the
174
- // destination page's canonical then points crawlers at the localized URL.
175
- const redirect = await client.resolveSlugRedirect('product', slug);
176
- if (redirect) {
177
- const info = await fetchStoreInfo(locale);
178
- const defaultLoc = info?.i18n?.defaultLocale ?? info?.language ?? '';
179
- const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
180
- permanentRedirect(`${prefix}/products/${redirect.currentSlug}`);
181
- }
182
- notFound();
183
- }
184
-
185
- const baseUrl = await getCanonicalSiteUrl();
186
- const productUrl = `${baseUrl}/products/${slug}`;
187
- // Reuse the cached storeInfo from generateMetadata's call — React cache()
188
- // collapses both into one backend request per render. resolveCurrency owns
189
- // the env-var + USD fallback chain so this stays a single source of truth.
190
- const storeInfo = await fetchStoreInfo(locale);
191
- const currency = resolveCurrency(storeInfo);
192
- const localePrefix = locale ? `/${locale}` : '';
193
- const primaryCategory = product.categories?.find((c) => c.slug);
194
- const breadcrumbItems = [
195
- { name: 'Home', href: `${localePrefix}/` },
196
- ...(primaryCategory
197
- ? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
198
- : []),
199
- { name: product.name },
200
- ];
201
-
202
- return (
203
- <>
204
- <ProductJsonLd
205
- product={product}
206
- url={productUrl}
207
- currency={currency}
208
- shipping={storeInfo?.shipping}
209
- />
210
- <div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
211
- <Breadcrumbs items={breadcrumbItems} />
212
- </div>
213
- <ProductClientSection product={product} stockAlertsEnabled={storeInfo?.stockAlertsEnabled} />
214
- {/* Visible Q&A + the FAQPage JSON-LD above read the same product.faq —
215
- the extractable format AI answer engines cite. */}
216
- <ProductFaqSection product={product} locale={locale} />
217
- <ReviewsSection productId={product.id} />
218
- </>
219
- );
220
- }
1
+ import type { Metadata } from 'next';
2
+ import { notFound, permanentRedirect } from 'next/navigation';
3
+ import { BrainerceError, deriveSeoDescription, getProductPriceInfo } from 'brainerce';
4
+ import type { Product } from 'brainerce';
5
+ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
6
+ import { getRegionId } from '@/core/lib/region.server';
7
+ import { resolveCurrency } from '@/core/lib/resolve-currency';
8
+ import { reviewsAreOn } from '@/core/lib/store-info';
9
+ import { canPurchaseProduct } from '@/core/lib/kit';
10
+ import { localePath } from '@/core/lib/locale-path';
11
+ import { getServerNamespace } from '@/core/lib/server-messages';
12
+ import { decodeSlug } from '@/core/lib/utils';
13
+ import { ProductJsonLd } from '@/components/seo/product-json-ld';
14
+ import { ProductOgMeta } from '@/components/seo/product-og-meta';
15
+ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
16
+ import { Breadcrumbs } from '@/components/seo/breadcrumbs';
17
+ import { ReviewsSection } from '@/ui/product/reviews-section';
18
+ import { ProductFaqSection } from '@/ui/product/faq-section';
19
+ import { ProductClientSection } from '@/ui/product/product-client-section';
20
+
21
+ type Props = {
22
+ params: Promise<{ slug: string; locale?: string }>;
23
+ };
24
+
25
+ function buildHreflang(
26
+ baseUrl: string,
27
+ baseSlug: string,
28
+ localeSlugs: Record<string, string>,
29
+ locales: string[],
30
+ defaultLoc: string
31
+ ): Record<string, string> {
32
+ const langs: Record<string, string> = {};
33
+ for (const loc of locales) {
34
+ const locSlug = localeSlugs[loc] || baseSlug;
35
+ const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
36
+ langs[loc] = `${baseUrl}${path}`;
37
+ }
38
+ // x-default points to the default-locale canonical (no prefix)
39
+ langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
40
+ return langs;
41
+ }
42
+
43
+ /**
44
+ * The public path of a product in a locale: the locale's own slug when it has
45
+ * one, else the product's slug, else the one in the URL. The canonical tag, the
46
+ * JSON-LD `url` and the breadcrumb all use this, so the three cannot name three
47
+ * different URLs for one page (the JSON-LD used to carry the unprefixed,
48
+ * requested slug while the canonical carried the locale prefix and the locale's
49
+ * slug).
50
+ */
51
+ function productPath(
52
+ locale: string | undefined,
53
+ product: Pick<Product, 'slug' | 'localeSlugs'>,
54
+ requestedSlug: string
55
+ ): string {
56
+ const slug = (locale && product.localeSlugs?.[locale]) || product.slug || requestedSlug;
57
+ return localePath(locale, `/products/${slug}`);
58
+ }
59
+
60
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
61
+ const { slug: rawSlug, locale } = await params;
62
+ const slug = decodeSlug(rawSlug);
63
+
64
+ try {
65
+ const client = await getServerClient(locale);
66
+ // Fetch product + store info in parallel; storeInfo gives us the store name,
67
+ // the hreflang locales and the live currency.
68
+ // ⛔ `regionId` belongs on EVERY product read. Without it the backend
69
+ // prices in the default region, so a shopper elsewhere gets the wrong
70
+ // currency in the OG price meta that link previews and Merchant Center
71
+ // read. `getRegionId()` is `cache()`-wrapped and shared with the layout
72
+ // and the page body below, so this is not an extra round trip. It
73
+ // resolves to `undefined` on a store with no regions, and the SDK then
74
+ // omits the query param entirely.
75
+ const regionId = await getRegionId();
76
+ const [product, storeInfo] = await Promise.all([
77
+ client.getProductBySlug(slug, { regionId }),
78
+ fetchStoreInfo(locale),
79
+ ]);
80
+ const imageUrl = product.images?.[0]?.url;
81
+ // The merchant's own SEO copy first, then a tag-stripped, word-boundary
82
+ // truncated description, then the name. `deriveSeoDescription` reads BOTH
83
+ // `seoDescription` and `metaDescription`: a salesChannelId read can carry the
84
+ // merchant's text under either name, and reading only `seoDescription` meant
85
+ // the meta description they wrote never reached the page (every product fell
86
+ // back to the start of its description, with no error and no sign).
87
+ const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
88
+ const seoDescription = deriveSeoDescription(product) || product.name;
89
+
90
+ // Multilingual SEO: hreflang tags + correct canonical per locale.
91
+ // Locales come from storeInfo.i18n — already fetched above, zero extra cost.
92
+ const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
93
+ const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
94
+ const baseUrl = await getCanonicalSiteUrl();
95
+ const baseSlug = product.slug || slug;
96
+ const localeSlugs = product.localeSlugs ?? {};
97
+
98
+ const hreflangLanguages =
99
+ supportedLocales.length > 1
100
+ ? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
101
+ : undefined;
102
+
103
+ return {
104
+ title: seoTitle,
105
+ description: seoDescription,
106
+ alternates: {
107
+ canonical: productPath(locale, product, slug),
108
+ ...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
109
+ },
110
+ // No `type` here, deliberately. Next's `openGraph.type` has no `product`
111
+ // value, and the page emits `og:type: product` itself (<ProductOgMeta/>,
112
+ // with the price and availability tags). A `type` here would put a second
113
+ // `og:type` in the head. `siteName` is repeated because this object
114
+ // replaces the root layout's rather than merging into it.
115
+ openGraph: {
116
+ title: seoTitle,
117
+ description: seoDescription,
118
+ ...(storeInfo?.name ? { siteName: storeInfo.name } : {}),
119
+ images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
120
+ },
121
+ twitter: {
122
+ card: 'summary_large_image',
123
+ title: seoTitle,
124
+ description: seoDescription,
125
+ images: imageUrl ? [imageUrl] : [],
126
+ },
127
+ };
128
+ } catch (err) {
129
+ if (err instanceof BrainerceError && err.statusCode === 404) {
130
+ const seo = await getServerNamespace('seo', locale);
131
+ return { title: seo.productNotFound ?? '' };
132
+ }
133
+ // Anything else (channel origin rejection, network failure) hits the page
134
+ // render below too and surfaces there — don't stamp a misleading title.
135
+ return {};
136
+ }
137
+ }
138
+
139
+ export default async function ProductDetailPage({ params }: Props) {
140
+ const { slug: rawSlug, locale } = await params;
141
+ const slug = decodeSlug(rawSlug);
142
+
143
+ const client = await getServerClient(locale);
144
+ // Same region as the metadata above (one shared `cache()`d resolve), so the
145
+ // price in the <head> and the price on the page cannot disagree.
146
+ const regionId = await getRegionId();
147
+ let product;
148
+ try {
149
+ product = await client.getProductBySlug(slug, { regionId });
150
+ } catch (err) {
151
+ // Only a genuine 404 may become a storefront 404. Anything else — a 403
152
+ // from the channel's origin check, a network failure, a 5xx — is a
153
+ // configuration or availability problem; disguising it as "product not
154
+ // found" sends the merchant hunting for a slug bug that isn't there.
155
+ if (!(err instanceof BrainerceError && err.statusCode === 404)) {
156
+ throw err;
157
+ }
158
+ // The slug may have been RENAMED — the platform records every rename.
159
+ // 301 the old URL to the current slug so its ranking and inbound links
160
+ // carry over; genuine unknowns fall through to the 404 page. Default
161
+ // locale stays unprefixed (matches the sitemap/hreflang convention); the
162
+ // destination page's canonical then points crawlers at the localized URL.
163
+ const redirect = await client.resolveSlugRedirect('product', slug);
164
+ if (redirect) {
165
+ permanentRedirect(localePath(locale, `/products/${redirect.currentSlug}`));
166
+ }
167
+ notFound();
168
+ }
169
+
170
+ // Reviews are read HERE, once, and gate the whole section. The reviews API
171
+ // refuses (403) when the store has reviews turned off, and the section used to
172
+ // swallow that and render its heading, "no reviews yet" and the submit form
173
+ // anyway: a storefront inviting reviews the store does not collect. A failed
174
+ // read hides the section for the same reason, instead of showing a false
175
+ // "no reviews yet".
176
+ //
177
+ // `storeInfo.reviewsEnabled === false` skips the call outright (no failing
178
+ // request per page view). A backend too old to send the flag answers the call
179
+ // with a 403 `REVIEWS_DISABLED`, which lands in the same `null`.
180
+ const readReviews = async () => {
181
+ // Cached by generateMetadata's call, so this costs no second request.
182
+ if (!reviewsAreOn(await fetchStoreInfo(locale))) return null;
183
+ const result = await client.listProductReviews(product.id, {
184
+ page: 1,
185
+ limit: 20,
186
+ sort: 'photos_first',
187
+ });
188
+ return result.data;
189
+ };
190
+ const [storeInfo, seo, reviews] = await Promise.all([
191
+ // Reuse the cached storeInfo from generateMetadata's call — React cache()
192
+ // collapses both into one backend request per render.
193
+ fetchStoreInfo(locale),
194
+ getServerNamespace('seo', locale),
195
+ readReviews().catch(() => null),
196
+ ]);
197
+ const baseUrl = await getCanonicalSiteUrl();
198
+ // resolveCurrency owns the env-var + USD fallback chain so this stays a single
199
+ // source of truth.
200
+ const currency = resolveCurrency(storeInfo);
201
+
202
+ const productHref = productPath(locale, product, slug);
203
+ const productUrl = `${baseUrl}${productHref}`;
204
+ const primaryCategory = product.categories?.find((c) => c.slug);
205
+ // One trail, two renderings: the visible breadcrumb and the BreadcrumbList
206
+ // JSON-LD are built from the SAME items, so they cannot disagree (the JSON-LD
207
+ // used to say Home > Products > name while the page showed Home > Category).
208
+ const breadcrumbItems = [
209
+ { name: seo.home ?? '', href: localePath(locale, '/') },
210
+ ...(primaryCategory
211
+ ? [
212
+ {
213
+ name: primaryCategory.name,
214
+ href: localePath(locale, `/category/${primaryCategory.slug}`),
215
+ },
216
+ ]
217
+ : []),
218
+ { name: product.name, href: undefined as string | undefined },
219
+ ];
220
+ const breadcrumbJsonLd = breadcrumbItems.map((item) => ({
221
+ name: item.name,
222
+ url: item.href ? `${baseUrl}${item.href}` : productUrl,
223
+ }));
224
+
225
+ // `og:type` and the price/availability tags, from the same helpers the rest of
226
+ // the page uses. Emitting the literal "0" for a missing price causes
227
+ // price-zero link cards, so the amount is null unless the SDK finds a real one.
228
+ const priceInfo = getProductPriceInfo(product);
229
+ const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
230
+ // ⛔ A KIT has NO `inventory` block — its stock is `product.kitAvailable`.
231
+ // Reading `inventory` published `product:availability: in stock` for a sold-out
232
+ // kit, so link previews and Merchant Center advertised something nobody could
233
+ // buy. `canPurchaseProduct` reads the right field per product type.
234
+ const inStock = canPurchaseProduct(product);
235
+ const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
236
+
237
+ // aggregateRating markup describes reviews a visitor can see. With the section
238
+ // hidden (reviews off, or unreadable) the rating is withheld too, whatever
239
+ // counts the product still carries from before.
240
+ const jsonLdProduct = reviews ? product : { ...product, avgRating: 0, reviewCount: 0 };
241
+
242
+ return (
243
+ <>
244
+ <ProductOgMeta
245
+ priceAmount={priceAmount}
246
+ currency={currency}
247
+ inStock={inStock}
248
+ brandName={brandName}
249
+ />
250
+ <ProductJsonLd
251
+ product={jsonLdProduct}
252
+ url={productUrl}
253
+ currency={currency}
254
+ shipping={storeInfo?.shipping}
255
+ breadcrumbs={breadcrumbJsonLd}
256
+ />
257
+ <div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
258
+ <Breadcrumbs items={breadcrumbItems} />
259
+ </div>
260
+ <ProductClientSection product={product} stockAlertsEnabled={storeInfo?.stockAlertsEnabled} />
261
+ {/* Visible Q&A + the FAQPage JSON-LD above read the same product.faq —
262
+ the extractable format AI answer engines cite. */}
263
+ <ProductFaqSection product={product} locale={locale} />
264
+ {reviews ? <ReviewsSection productId={product.id} initialReviews={reviews} locale={locale} /> : null}
265
+ </>
266
+ );
267
+ }