create-brainerce-store 1.88.1 → 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.
- package/dist/index.js +3 -2
- package/messages/en.json +21 -0
- package/messages/he.json +21 -0
- package/package.json +3 -2
- package/templates/nextjs/base/.eslintrc.json +1 -0
- package/templates/nextjs/base/AI-GUIDE.md +35 -0
- package/templates/nextjs/base/TRANSLATIONS.md +6 -0
- package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
- package/templates/nextjs/base/scripts/connect.mjs +6 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
- package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
- package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
- package/templates/nextjs/base/src/app/page.tsx +109 -75
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
- package/templates/nextjs/base/src/app/products/page.tsx +184 -19
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
- package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
- package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
- package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
- package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
- package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
- package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
- package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
- 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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const storeInfo = await
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
}
|