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.
- 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 +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +259 -221
- 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,19 +1,184 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import type { PublicMetafieldDefinition } from 'brainerce';
|
|
5
|
+
import { getServerClient } from '@/core/lib/brainerce.server';
|
|
6
|
+
import { getRegionId } from '@/core/lib/region.server';
|
|
7
|
+
import {
|
|
8
|
+
buildProductQuery,
|
|
9
|
+
isFilterableMetafield,
|
|
10
|
+
listingSignature,
|
|
11
|
+
paramSourceFromObject,
|
|
12
|
+
readListingUrlState,
|
|
13
|
+
selectedMetafieldsKey,
|
|
14
|
+
} from '@/core/lib/listing-query';
|
|
15
|
+
import { localePath } from '@/core/lib/locale-path';
|
|
16
|
+
import { getServerNamespace } from '@/core/lib/server-messages';
|
|
17
|
+
import { ListingSeedProvider, type ListingSeed } from '@/core/providers/listing-seed';
|
|
18
|
+
import { ProductListing } from '@/ui/product/product-listing';
|
|
19
|
+
|
|
20
|
+
type Props = {
|
|
21
|
+
// `locale` exists only on a multi-language storefront (the `[locale]` segment).
|
|
22
|
+
params: Promise<{ locale?: string }>;
|
|
23
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Title and description come from the message files in the request locale. They
|
|
27
|
+
// used to be a scaffold-time ternary on `language === 'he'`, which gave every
|
|
28
|
+
// visitor of a multi-language store the default language's copy.
|
|
29
|
+
//
|
|
30
|
+
// Canonical: each page of the catalog is its own canonical (`?page=2` is not a
|
|
31
|
+
// duplicate of page 1, and pointing it there tells Google to drop its products).
|
|
32
|
+
// A filtered, searched or sorted view is a permutation of the same catalog and
|
|
33
|
+
// canonicalizes to the plain listing. The locale prefix is added only for a
|
|
34
|
+
// non-default locale; the middleware 308-redirects `/{default}/x` to `/x`.
|
|
35
|
+
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
|
|
36
|
+
const { locale } = await params;
|
|
37
|
+
const state = readListingUrlState(paramSourceFromObject(await searchParams));
|
|
38
|
+
const seo = await getServerNamespace('seo', locale);
|
|
39
|
+
const title = seo.products ?? '';
|
|
40
|
+
const description = seo.productsDescription ?? '';
|
|
41
|
+
|
|
42
|
+
const base = localePath(locale, '/products');
|
|
43
|
+
const isPlainListing =
|
|
44
|
+
!state.search &&
|
|
45
|
+
!state.categoryId &&
|
|
46
|
+
!state.brandId &&
|
|
47
|
+
!state.tagId &&
|
|
48
|
+
!state.minPrice &&
|
|
49
|
+
!state.maxPrice &&
|
|
50
|
+
state.sortIndex === 0 &&
|
|
51
|
+
Object.keys(state.metafields).length === 0;
|
|
52
|
+
const canonical = isPlainListing && state.page > 1 ? `${base}?page=${state.page}` : base;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
title,
|
|
56
|
+
description,
|
|
57
|
+
alternates: { canonical },
|
|
58
|
+
// `openGraph` replaces the root layout's object wholesale rather than
|
|
59
|
+
// merging into it; the store name is added by the title template.
|
|
60
|
+
openGraph: { title, description, type: 'website' },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The catalog page. A SERVER component that fetches the page the URL asks for
|
|
66
|
+
* (search, category, brand, tag, price range, sort, custom-field facets and
|
|
67
|
+
* `?page=`) and hands it to the client listing, so the initial HTML carries real
|
|
68
|
+
* products for every one of those URLs. It used to be `'use client'` end to end:
|
|
69
|
+
* the server sent a spinner and the browser fetched, so a crawler that does not
|
|
70
|
+
* run JavaScript saw an empty page at the second most important URL on the site.
|
|
71
|
+
*
|
|
72
|
+
* Shopper interaction after the first render is unchanged: filters push a new
|
|
73
|
+
* URL, "Load more" appends, and the client hook fetches for anything the seed
|
|
74
|
+
* does not answer.
|
|
75
|
+
*/
|
|
76
|
+
export default async function ProductsPage({ params, searchParams }: Props) {
|
|
77
|
+
const { locale } = await params;
|
|
78
|
+
const query = await searchParams;
|
|
79
|
+
const state = readListingUrlState(paramSourceFromObject(query));
|
|
80
|
+
|
|
81
|
+
let seed: ListingSeed | null = null;
|
|
82
|
+
try {
|
|
83
|
+
const client = await getServerClient(locale);
|
|
84
|
+
const regionId = await getRegionId();
|
|
85
|
+
|
|
86
|
+
// Facets only filter when the store exposes them as filterable, so the
|
|
87
|
+
// definitions are needed to turn `mf_<key>` into a query, and only when the
|
|
88
|
+
// URL selected one.
|
|
89
|
+
let metafieldDefs: PublicMetafieldDefinition[] = [];
|
|
90
|
+
if (Object.keys(state.metafields).length > 0) {
|
|
91
|
+
const defs = await client.getPublicMetafieldDefinitions();
|
|
92
|
+
metafieldDefs = defs.definitions.filter(isFilterableMetafield);
|
|
93
|
+
}
|
|
94
|
+
const metafieldsKey = selectedMetafieldsKey(
|
|
95
|
+
state.metafields,
|
|
96
|
+
metafieldDefs.map((d) => d.key)
|
|
97
|
+
);
|
|
98
|
+
const opts = { regionId, metafieldsKey, page: state.page };
|
|
99
|
+
|
|
100
|
+
const result = await client.getProducts(buildProductQuery(state, opts));
|
|
101
|
+
seed = {
|
|
102
|
+
products: result.data,
|
|
103
|
+
page: state.page,
|
|
104
|
+
totalPages: result.meta.totalPages,
|
|
105
|
+
total: result.meta.total,
|
|
106
|
+
signature: listingSignature(state, opts),
|
|
107
|
+
metafieldDefs,
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
// Non-fatal: the client hook fetches the same query itself.
|
|
111
|
+
seed = null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// No `<Suspense>` around the listing, deliberately. The listing reads the URL
|
|
115
|
+
// through `useSearchParams`, which only needs a boundary when a route is
|
|
116
|
+
// prerendered statically; this page is dynamic (it reads `searchParams`), so
|
|
117
|
+
// there is nothing to bail out of. The wrapper was not neutral: in the base
|
|
118
|
+
// design it made the server flush a spinner fallback and stream the real grid
|
|
119
|
+
// after it, which is the opposite of what the seed is for.
|
|
120
|
+
// A page past the end of the result set is not a page. Rendering it empty with
|
|
121
|
+
// its own canonical hands crawlers an endless supply of soft-404 URLs
|
|
122
|
+
// (`?page=99`, `?page=100`, ...). Only when the query answered, never when the
|
|
123
|
+
// fetch failed (then `seed` is null and the client hook tries again).
|
|
124
|
+
if (seed && state.page > 1 && seed.products.length === 0) notFound();
|
|
125
|
+
|
|
126
|
+
// Crawlable pagination. "Load more" is a button, so without real links nothing
|
|
127
|
+
// on the page leads to `?page=2`, and a crawler that does not run JavaScript
|
|
128
|
+
// can only reach page 2+ of the catalog by guessing the URL. These are plain
|
|
129
|
+
// `<a>` links rendered on the server, keeping whatever filters the shopper has
|
|
130
|
+
// chosen; shoppers keep using "Load more".
|
|
131
|
+
const pageHref = (target: number): string => {
|
|
132
|
+
const params = new URLSearchParams();
|
|
133
|
+
for (const [key, value] of Object.entries(query)) {
|
|
134
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
135
|
+
if (first !== undefined && key !== 'page') params.set(key, first);
|
|
136
|
+
}
|
|
137
|
+
if (target > 1) params.set('page', String(target));
|
|
138
|
+
const qs = params.toString();
|
|
139
|
+
return `${localePath(locale, '/products')}${qs ? `?${qs}` : ''}`;
|
|
140
|
+
};
|
|
141
|
+
const strings = seed && seed.totalPages > 1 ? await getServerNamespace('seo', locale) : null;
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<ListingSeedProvider listing={seed}>
|
|
146
|
+
<ProductListing />
|
|
147
|
+
</ListingSeedProvider>
|
|
148
|
+
{seed && strings ? (
|
|
149
|
+
<nav
|
|
150
|
+
aria-label={strings.pagination}
|
|
151
|
+
className="mx-auto flex max-w-7xl items-center justify-center gap-6 px-4 pb-10 text-sm sm:px-6 lg:px-8"
|
|
152
|
+
>
|
|
153
|
+
{seed.page > 1 ? (
|
|
154
|
+
<Link
|
|
155
|
+
href={pageHref(Math.min(seed.page - 1, seed.totalPages))}
|
|
156
|
+
rel="prev"
|
|
157
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
|
158
|
+
>
|
|
159
|
+
<span aria-hidden="true" className="rtl:rotate-180">
|
|
160
|
+
←
|
|
161
|
+
</span>
|
|
162
|
+
{strings.previousPage}
|
|
163
|
+
</Link>
|
|
164
|
+
) : null}
|
|
165
|
+
<span className="text-muted-foreground">
|
|
166
|
+
{seed.page} / {seed.totalPages}
|
|
167
|
+
</span>
|
|
168
|
+
{seed.page < seed.totalPages ? (
|
|
169
|
+
<Link
|
|
170
|
+
href={pageHref(seed.page + 1)}
|
|
171
|
+
rel="next"
|
|
172
|
+
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
|
173
|
+
>
|
|
174
|
+
{strings.nextPage}
|
|
175
|
+
<span aria-hidden="true" className="rtl:rotate-180">
|
|
176
|
+
→
|
|
177
|
+
</span>
|
|
178
|
+
</Link>
|
|
179
|
+
) : null}
|
|
180
|
+
</nav>
|
|
181
|
+
) : null}
|
|
182
|
+
</>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
4
|
import type { CustomerOAuthProvider } from 'brainerce';
|
|
5
5
|
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
import { readReferralCookie } from '@/core/lib/referral';
|
|
6
7
|
import { useTranslations } from '@/core/lib/translations';
|
|
7
8
|
import { cn } from '@/core/lib/utils';
|
|
8
9
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
@@ -106,7 +107,14 @@ export function OAuthButtons({ className }: OAuthButtonsProps) {
|
|
|
106
107
|
setFailedProvider(null);
|
|
107
108
|
const client = getClient();
|
|
108
109
|
const redirectUrl = window.location.origin + '/api/auth/oauth-callback';
|
|
109
|
-
|
|
110
|
+
// Carry the captured ?ref= code through the round trip. The backend
|
|
111
|
+
// applies it only if this sign-in creates the account, so passing it on
|
|
112
|
+
// every click is safe; without it a Google sign-up loses the referral.
|
|
113
|
+
const referralCode = readReferralCookie();
|
|
114
|
+
const result = await client.getOAuthAuthorizeUrl(provider, {
|
|
115
|
+
redirectUrl,
|
|
116
|
+
...(referralCode ? { referralCode } : {}),
|
|
117
|
+
});
|
|
110
118
|
window.location.href = result.authorizationUrl;
|
|
111
119
|
} catch (err) {
|
|
112
120
|
// The 400 body is a rule for the merchant (`redirect_not_allowed` and
|
|
@@ -111,6 +111,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
111
111
|
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
112
112
|
const [loading, setLoading] = useState(true);
|
|
113
113
|
const [error, setError] = useState<string | null>(null);
|
|
114
|
+
// True when the server refused this checkout as "already being processed"
|
|
115
|
+
// (409 `checkout_claim_failed`). Retrying the same checkout cannot succeed, so the error box
|
|
116
|
+
// offers a fresh checkout instead of a dead end.
|
|
117
|
+
const [canRestart, setCanRestart] = useState(false);
|
|
114
118
|
const [sdkReady, setSdkReady] = useState(false);
|
|
115
119
|
// Set by the Cardcom OpenFields embed page via `brainerce:resize` postMessage.
|
|
116
120
|
// Presence of this value is how we distinguish our own compact embed page
|
|
@@ -454,7 +458,21 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
454
458
|
// from the provider SDK on a different path and keep their own wording,
|
|
455
459
|
// which is the part a shopper can actually act on.
|
|
456
460
|
console.error('[checkout] could not start payment', err);
|
|
457
|
-
|
|
461
|
+
if (
|
|
462
|
+
err instanceof BrainerceError &&
|
|
463
|
+
err.statusCode === 409 &&
|
|
464
|
+
(err.details as { code?: string } | undefined)?.code === 'checkout_claim_failed'
|
|
465
|
+
) {
|
|
466
|
+
// The checkout is locked by an earlier attempt (a lost response, a
|
|
467
|
+
// reload) and will never accept another intent. A new checkout from the
|
|
468
|
+
// same cart is the way forward, not "contact the store". Only THIS 409:
|
|
469
|
+
// a provider's own 409 (e.g. `provider_not_ready`, KYC pending) would hit
|
|
470
|
+
// the same wall on a fresh checkout, so it keeps the generic message.
|
|
471
|
+
setCanRestart(true);
|
|
472
|
+
setError(t('paymentSessionStuck'));
|
|
473
|
+
} else {
|
|
474
|
+
setError(t('paymentUnavailable'));
|
|
475
|
+
}
|
|
458
476
|
return null;
|
|
459
477
|
})
|
|
460
478
|
.finally(() => setLoading(false));
|
|
@@ -656,6 +674,17 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
656
674
|
<p className="text-muted-foreground mx-auto max-w-md text-sm">
|
|
657
675
|
{isNotConfigured ? t('paymentNotConfiguredDesc') : error}
|
|
658
676
|
</p>
|
|
677
|
+
{canRestart && (
|
|
678
|
+
<button
|
|
679
|
+
type="button"
|
|
680
|
+
// Same page without `?checkout_id=`: the bare /checkout link opens a
|
|
681
|
+
// fresh checkout for the cart and never resumes a locked one.
|
|
682
|
+
onClick={() => window.location.assign(window.location.pathname)}
|
|
683
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
684
|
+
>
|
|
685
|
+
{t('startNewCheckout')}
|
|
686
|
+
</button>
|
|
687
|
+
)}
|
|
659
688
|
</div>
|
|
660
689
|
);
|
|
661
690
|
}
|
|
@@ -8,6 +8,11 @@ interface ArticleJsonLdProps {
|
|
|
8
8
|
url: string;
|
|
9
9
|
/** Store display name — feeds Article.publisher (and the author fallback). */
|
|
10
10
|
organizationName?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The BreadcrumbList trail, absolute URLs, last item = this post. Names come
|
|
13
|
+
* from the message files in the request locale, never hardcoded here.
|
|
14
|
+
*/
|
|
15
|
+
breadcrumbs: Array<{ name: string; url: string }>;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
/**
|
|
@@ -16,7 +21,12 @@ interface ArticleJsonLdProps {
|
|
|
16
21
|
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
17
22
|
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
18
23
|
*/
|
|
19
|
-
export async function ArticleJsonLd({
|
|
24
|
+
export async function ArticleJsonLd({
|
|
25
|
+
post,
|
|
26
|
+
url,
|
|
27
|
+
organizationName,
|
|
28
|
+
breadcrumbs,
|
|
29
|
+
}: ArticleJsonLdProps) {
|
|
20
30
|
const nonce = await getNonce();
|
|
21
31
|
const baseUrl = await getCanonicalSiteUrl();
|
|
22
32
|
|
|
@@ -26,11 +36,7 @@ export async function ArticleJsonLd({ post, url, organizationName }: ArticleJson
|
|
|
26
36
|
organizationName,
|
|
27
37
|
});
|
|
28
38
|
|
|
29
|
-
const breadcrumbJsonLd = buildBreadcrumbJsonLd(
|
|
30
|
-
{ name: 'Home', url: baseUrl || '/' },
|
|
31
|
-
{ name: 'Blog', url: `${baseUrl}/blog` },
|
|
32
|
-
{ name: post.title, url },
|
|
33
|
-
]);
|
|
39
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd(breadcrumbs);
|
|
34
40
|
|
|
35
41
|
return (
|
|
36
42
|
<>
|
|
@@ -6,6 +6,12 @@ import { getCanonicalSiteUrl } from '@/core/lib/site-url';
|
|
|
6
6
|
interface CategoryJsonLdProps {
|
|
7
7
|
category: CategoryDetail;
|
|
8
8
|
url: string;
|
|
9
|
+
/**
|
|
10
|
+
* The BreadcrumbList trail, absolute URLs, last item = this category. Pass the
|
|
11
|
+
* SAME items the page renders as its visible breadcrumb. Names come from the
|
|
12
|
+
* message files, never hardcoded here.
|
|
13
|
+
*/
|
|
14
|
+
breadcrumbs: Array<{ name: string; url: string }>;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -16,21 +22,14 @@ interface CategoryJsonLdProps {
|
|
|
16
22
|
* Uses the SDK builders for the schema shape, but renders the <script> here so
|
|
17
23
|
* we can attach the CSP nonce (the SDK's jsonLdScriptProps can't).
|
|
18
24
|
*/
|
|
19
|
-
export async function CategoryJsonLd({ category, url }: CategoryJsonLdProps) {
|
|
25
|
+
export async function CategoryJsonLd({ category, url, breadcrumbs }: CategoryJsonLdProps) {
|
|
20
26
|
const nonce = await getNonce();
|
|
21
27
|
const baseUrl = await getCanonicalSiteUrl();
|
|
22
28
|
|
|
23
29
|
const collectionJsonLd = buildCollectionPageJsonLd(category, { siteUrl: baseUrl, path: url });
|
|
24
30
|
|
|
25
|
-
// Breadcrumb: Home → ancestor categories → this category.
|
|
26
|
-
const
|
|
27
|
-
for (const ancestor of category.breadcrumb) {
|
|
28
|
-
if (ancestor.slug) {
|
|
29
|
-
crumbs.push({ name: ancestor.name, url: `${baseUrl}/category/${ancestor.slug}` });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
crumbs.push({ name: category.name, url });
|
|
33
|
-
const breadcrumbJsonLd = buildBreadcrumbJsonLd(crumbs);
|
|
31
|
+
// Breadcrumb: Home → ancestor categories → this category, as the page shows it.
|
|
32
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd(breadcrumbs);
|
|
34
33
|
|
|
35
34
|
return (
|
|
36
35
|
<>
|
|
@@ -10,6 +10,13 @@ interface ProductJsonLdProps {
|
|
|
10
10
|
currency?: string;
|
|
11
11
|
/** From `storeInfo.shipping` — real flat-rate/free zones only. Omitted entirely when unset, never fabricated. */
|
|
12
12
|
shipping?: ShippingSummaryEntry[];
|
|
13
|
+
/**
|
|
14
|
+
* The BreadcrumbList trail, absolute URLs, last item = this product. Pass the
|
|
15
|
+
* SAME items the page renders as its visible breadcrumb: structured data that
|
|
16
|
+
* names a different path than the page shows is a mismatch Google can flag.
|
|
17
|
+
* Names come from the message files, never hardcoded here.
|
|
18
|
+
*/
|
|
19
|
+
breadcrumbs: Array<{ name: string; url: string }>;
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -23,7 +30,13 @@ interface ProductJsonLdProps {
|
|
|
23
30
|
* The <script> is rendered locally only to attach the CSP nonce (the SDK's
|
|
24
31
|
* jsonLdScriptProps can't).
|
|
25
32
|
*/
|
|
26
|
-
export async function ProductJsonLd({
|
|
33
|
+
export async function ProductJsonLd({
|
|
34
|
+
product,
|
|
35
|
+
url,
|
|
36
|
+
currency,
|
|
37
|
+
shipping,
|
|
38
|
+
breadcrumbs,
|
|
39
|
+
}: ProductJsonLdProps) {
|
|
27
40
|
// Defer to the shared server helper so the env-var + USD fallback chain
|
|
28
41
|
// lives in exactly one place across all server-side currency reads.
|
|
29
42
|
const resolvedCurrency = resolveCurrency(null, currency);
|
|
@@ -37,11 +50,7 @@ export async function ProductJsonLd({ product, url, currency, shipping }: Produc
|
|
|
37
50
|
shipping,
|
|
38
51
|
});
|
|
39
52
|
|
|
40
|
-
const breadcrumbJsonLd = buildBreadcrumbJsonLd(
|
|
41
|
-
{ name: 'Home', url: baseUrl || '/' },
|
|
42
|
-
{ name: 'Products', url: `${baseUrl}/products` },
|
|
43
|
-
{ name: product.name, url },
|
|
44
|
-
]);
|
|
53
|
+
const breadcrumbJsonLd = buildBreadcrumbJsonLd(breadcrumbs);
|
|
45
54
|
|
|
46
55
|
// FAQPage from the product's Q&A — null (no script) when the product has
|
|
47
56
|
// none. The visible FAQ section on the PDP renders the SAME pairs; this is
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
interface ProductOgMetaProps {
|
|
2
|
+
/** Positive price, already formatted to two decimals; null when it cannot be determined. */
|
|
3
|
+
priceAmount: string | null;
|
|
4
|
+
currency: string;
|
|
5
|
+
inStock: boolean;
|
|
6
|
+
brandName?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Open Graph `product` object: the type tag plus the price, availability, condition
|
|
11
|
+
* and brand tags that Facebook, Pinterest and Google Merchant Center read off a
|
|
12
|
+
* product page.
|
|
13
|
+
*
|
|
14
|
+
* Rendered as real `<meta property="...">` elements instead of through Next's
|
|
15
|
+
* `metadata` for two reasons, both about what ends up in the HTML:
|
|
16
|
+
* - `openGraph.type` only accepts a fixed list of values and `product` is not on
|
|
17
|
+
* it, so the metadata API cannot emit `og:type: product`.
|
|
18
|
+
* - `metadata.other` renders `<meta name="...">`, and these tags are read from
|
|
19
|
+
* `property=`. Emitting them as `name=` looks right in the source and is
|
|
20
|
+
* ignored by the crawlers they exist for.
|
|
21
|
+
* React hoists a `<meta>` rendered in a component into `<head>`.
|
|
22
|
+
*
|
|
23
|
+
* The product page must NOT also set `openGraph.type`, or the page carries two
|
|
24
|
+
* `og:type` tags.
|
|
25
|
+
*
|
|
26
|
+
* Skips the price pair entirely when no positive amount is known, rather than
|
|
27
|
+
* shipping "0", which turns every link preview into a free product.
|
|
28
|
+
*/
|
|
29
|
+
export function ProductOgMeta({ priceAmount, currency, inStock, brandName }: ProductOgMetaProps) {
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<meta property="og:type" content="product" />
|
|
33
|
+
{priceAmount ? (
|
|
34
|
+
<>
|
|
35
|
+
<meta property="og:price:amount" content={priceAmount} />
|
|
36
|
+
<meta property="og:price:currency" content={currency} />
|
|
37
|
+
<meta property="product:price:amount" content={priceAmount} />
|
|
38
|
+
<meta property="product:price:currency" content={currency} />
|
|
39
|
+
</>
|
|
40
|
+
) : null}
|
|
41
|
+
<meta property="product:availability" content={inStock ? 'in stock' : 'out of stock'} />
|
|
42
|
+
<meta property="product:condition" content="new" />
|
|
43
|
+
{brandName ? <meta property="product:brand" content={brandName} /> : null}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import type { Product } from 'brainerce';
|
|
5
5
|
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
import { HOME_FEATURED_LIMIT } from '@/core/lib/listing-query';
|
|
7
|
+
import { useHomeSeed } from '@/core/providers/listing-seed';
|
|
6
8
|
import { useRegion } from '@/core/providers/store-provider';
|
|
7
9
|
|
|
8
10
|
export interface UseHomeDataResult {
|
|
@@ -15,6 +17,12 @@ export interface UseHomeDataResult {
|
|
|
15
17
|
/**
|
|
16
18
|
* Homepage data: the featured product grid.
|
|
17
19
|
*
|
|
20
|
+
* The server fetches the first render's products (`app/page.tsx` wraps the page
|
|
21
|
+
* in `<ListingSeedProvider home={...}>`), so the initial HTML carries real
|
|
22
|
+
* products. This hook then starts from that seed and only fetches when the seed
|
|
23
|
+
* does not apply, which is when the shopper's region differs from the one the
|
|
24
|
+
* server priced in, or when no seed was provided.
|
|
25
|
+
*
|
|
18
26
|
* Discount banners are deliberately NOT fetched here. They render below the
|
|
19
27
|
* header on every page, so the root layout fetches them server-side and hands
|
|
20
28
|
* them to `<DiscountBannerStrip>`. Fetching them again on the home page would
|
|
@@ -23,21 +31,33 @@ export interface UseHomeDataResult {
|
|
|
23
31
|
* Pure data/behavior — rendering lives in `ui/home/`.
|
|
24
32
|
*/
|
|
25
33
|
export function useHomeData(): UseHomeDataResult {
|
|
26
|
-
const
|
|
27
|
-
const [
|
|
34
|
+
const seed = useHomeSeed();
|
|
35
|
+
const [products, setProducts] = useState<Product[]>(seed?.products ?? []);
|
|
36
|
+
const [loading, setLoading] = useState(!seed);
|
|
28
37
|
// ⛔ Every product read carries the region, or the featured grid quotes the
|
|
29
38
|
// default region's currency to a shopper the product page then prices
|
|
30
39
|
// differently. `undefined` on a store with no regions, and the SDK omits
|
|
31
40
|
// the param, so a single-region store behaves exactly as before.
|
|
32
41
|
const { regionId } = useRegion();
|
|
33
42
|
|
|
43
|
+
// The region the seed was priced in, or null once it no longer applies. A ref
|
|
44
|
+
// rather than a flag that is flipped on first use, so React's dev-only double
|
|
45
|
+
// effect run cannot spend the seed and then fetch anyway.
|
|
46
|
+
const seededRegion = useRef<{ id: string | undefined } | null>(
|
|
47
|
+
seed ? { id: seed.regionId } : null
|
|
48
|
+
);
|
|
49
|
+
|
|
34
50
|
useEffect(() => {
|
|
51
|
+
if (seededRegion.current && seededRegion.current.id === regionId) return;
|
|
52
|
+
seededRegion.current = null;
|
|
53
|
+
|
|
35
54
|
async function load() {
|
|
36
55
|
try {
|
|
56
|
+
setLoading(true);
|
|
37
57
|
const client = getClient();
|
|
38
58
|
const [productsRes] = await Promise.allSettled([
|
|
39
59
|
client.getProducts({
|
|
40
|
-
limit:
|
|
60
|
+
limit: HOME_FEATURED_LIMIT,
|
|
41
61
|
sortBy: 'createdAt',
|
|
42
62
|
sortOrder: 'desc',
|
|
43
63
|
...(regionId ? { regionId } : {}),
|