create-brainerce-store 1.78.0 → 1.80.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 +27 -2
- package/messages/en.json +12 -3
- package/messages/he.json +12 -3
- package/package.json +10 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AGENTS.md.ejs +23 -5
- package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
- package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
- package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
- package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
- package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
- package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
- package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
- package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
- package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
|
@@ -1,214 +1,220 @@
|
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const slug =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
product.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
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
|
-
const
|
|
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
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
|
@@ -328,7 +328,11 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
|
|
|
328
328
|
// an older order can carry rows without a name or an amount.
|
|
329
329
|
const taxRows = (order.taxBreakdown?.breakdown ?? []).filter(
|
|
330
330
|
(row): row is { name: string; rate: number; amount: number } =>
|
|
331
|
-
!!row &&
|
|
331
|
+
!!row &&
|
|
332
|
+
typeof row.name === 'string' &&
|
|
333
|
+
row.name.length > 0 &&
|
|
334
|
+
typeof row.amount === 'number' &&
|
|
335
|
+
Number.isFinite(row.amount)
|
|
332
336
|
);
|
|
333
337
|
const rules = order.appliedDiscounts;
|
|
334
338
|
|
|
@@ -389,10 +393,13 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
|
|
|
389
393
|
)}
|
|
390
394
|
|
|
391
395
|
{/* `breakdown` holds ONE ROW PER TAX. A Quebec order carries GST and QST
|
|
392
|
-
and both have to appear
|
|
393
|
-
|
|
396
|
+
and both have to appear. Name the tax whenever there is one to name —
|
|
397
|
+
the checkout this shopper just paid through prints "GST", so printing
|
|
398
|
+
"Tax" back to them here for the same order reads as a different figure.
|
|
399
|
+
An order frozen before breakdowns existed carries no printable row and
|
|
400
|
+
still falls back to the single combined line. */}
|
|
394
401
|
{tax > 0 &&
|
|
395
|
-
(taxRows.length >
|
|
402
|
+
(taxRows.length > 0 ? (
|
|
396
403
|
taxRows.map((row, i) => (
|
|
397
404
|
<div key={`${row.name}-${i}`} className="flex items-center justify-between">
|
|
398
405
|
<span className="text-muted-foreground">
|
|
@@ -7,6 +7,7 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
7
7
|
import { cn } from '@/core/lib/utils';
|
|
8
8
|
import { birthMonthKey, toBirthdayNumber } from '@/core/lib/birthday';
|
|
9
9
|
import { BirthdayPicker } from '@/components/shared/birthday-picker';
|
|
10
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
10
11
|
|
|
11
12
|
interface ProfileSectionProps {
|
|
12
13
|
profile: CustomerProfile;
|
|
@@ -17,6 +18,9 @@ interface ProfileSectionProps {
|
|
|
17
18
|
export function ProfileSection({ profile, onProfileUpdate, className }: ProfileSectionProps) {
|
|
18
19
|
const t = useTranslations('account');
|
|
19
20
|
const tc = useTranslations('common');
|
|
21
|
+
const { capabilities } = useStoreCapabilities();
|
|
22
|
+
/** Explicit `true` only — see the note on the same gate in `register-form.tsx`. */
|
|
23
|
+
const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
|
|
20
24
|
const [editing, setEditing] = useState(false);
|
|
21
25
|
const [saving, setSaving] = useState(false);
|
|
22
26
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
@@ -182,7 +186,9 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
|
|
|
182
186
|
onChange={selectBirthday}
|
|
183
187
|
triggerClassName="rounded-md"
|
|
184
188
|
/>
|
|
185
|
-
|
|
189
|
+
{birthdayGiftOn && (
|
|
190
|
+
<p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
|
|
191
|
+
)}
|
|
186
192
|
</div>
|
|
187
193
|
<p className="text-muted-foreground truncate text-sm">{profile.email}</p>
|
|
188
194
|
<div className="flex items-center gap-2">
|
|
@@ -9,6 +9,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
|
|
|
9
9
|
import { toBirthdayNumber } from '@/core/lib/birthday';
|
|
10
10
|
import { BirthdayPicker } from '@/components/shared/birthday-picker';
|
|
11
11
|
import { readReferralCookie } from '@/core/lib/referral';
|
|
12
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
12
13
|
|
|
13
14
|
interface RegisterData {
|
|
14
15
|
firstName: string;
|
|
@@ -66,6 +67,20 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
66
67
|
const [privacyError, setPrivacyError] = useState(false);
|
|
67
68
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
68
69
|
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
70
|
+
const { capabilities } = useStoreCapabilities();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether to promise a birthday gift beside the picker.
|
|
74
|
+
*
|
|
75
|
+
* The gate is inverted relative to `ReferralGreeting`, and deliberately so.
|
|
76
|
+
* There, an undecided capability must not suppress a real referral, so only
|
|
77
|
+
* an explicit `false` hides anything. Here the text makes a PROMISE, and a
|
|
78
|
+
* promise may only be printed on an explicit `true`: a store with birthday
|
|
79
|
+
* rewards switched off — which is the default — would otherwise tell every
|
|
80
|
+
* customer a gift is coming and send nothing, and `requireBirthday` can make
|
|
81
|
+
* handing over the date compulsory on top of that.
|
|
82
|
+
*/
|
|
83
|
+
const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
|
|
69
84
|
const [birthMonth, setBirthMonth] = useState('');
|
|
70
85
|
const [birthDay, setBirthDay] = useState('');
|
|
71
86
|
const [birthdayError, setBirthdayError] = useState<string | null>(null);
|
|
@@ -293,7 +308,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
293
308
|
required={birthdayRequired}
|
|
294
309
|
invalid={birthdayError !== null}
|
|
295
310
|
/>
|
|
296
|
-
|
|
311
|
+
{birthdayGiftOn && (
|
|
312
|
+
<p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
|
|
313
|
+
)}
|
|
297
314
|
{birthdayError && <p className="text-destructive mt-1 text-xs">{birthdayError}</p>}
|
|
298
315
|
</div>
|
|
299
316
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState, useRef, useCallback, type CSSProperties } from 'react';
|
|
4
4
|
import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
|
|
5
5
|
import { formatPrice } from 'brainerce';
|
|
6
|
+
import { BrainerceError } from 'brainerce';
|
|
6
7
|
import { getClient } from '@/core/lib/brainerce';
|
|
7
8
|
import { useTranslations } from '@/core/lib/translations';
|
|
8
9
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
@@ -360,7 +361,15 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
360
361
|
return intent;
|
|
361
362
|
})
|
|
362
363
|
.catch((err) => {
|
|
363
|
-
|
|
364
|
+
// This is intent CREATION, before any card is entered, so a failure
|
|
365
|
+
// here is the merchant's configuration and never the shopper's card.
|
|
366
|
+
// The server said "Stripe account is not connected" and the storefront
|
|
367
|
+
// printed it verbatim: raw English on a Hebrew store, naming a provider
|
|
368
|
+
// to someone who could not install one if they wanted to. Declines come
|
|
369
|
+
// from the provider SDK on a different path and keep their own wording,
|
|
370
|
+
// which is the part a shopper can actually act on.
|
|
371
|
+
console.error('[checkout] could not start payment', err);
|
|
372
|
+
setError(t('paymentUnavailable'));
|
|
364
373
|
return null;
|
|
365
374
|
})
|
|
366
375
|
.finally(() => setLoading(false));
|
|
@@ -558,7 +567,10 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
558
567
|
await client.completeGuestCheckout(checkoutId);
|
|
559
568
|
window.location.href = `/order-confirmation?checkout_id=${checkoutId}`;
|
|
560
569
|
} catch (err) {
|
|
561
|
-
|
|
570
|
+
// Sandbox never declines, so there is nothing here a shopper can act on
|
|
571
|
+
// either.
|
|
572
|
+
console.error('[checkout] sandbox completion failed', err);
|
|
573
|
+
setError(t('paymentError'));
|
|
562
574
|
setLoading(false);
|
|
563
575
|
}
|
|
564
576
|
};
|
|
@@ -12,7 +12,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
|
|
|
12
12
|
*
|
|
13
13
|
* There is nothing to configure here and no environment variable to set. The
|
|
14
14
|
* tag ids arrive inside `storeInfo.tracking`, which the backend resolves from
|
|
15
|
-
* the merchant's connected marketplace apps: connecting the Google
|
|
15
|
+
* the merchant's connected marketplace apps: connecting the Google
|
|
16
16
|
* app runs GA4 discovery and the measurement id shows up on its own, likewise
|
|
17
17
|
* the Meta and TikTok pixels. Connect an app in the dashboard and this file
|
|
18
18
|
* starts loading its tag within a minute — no redeploy, no code change.
|