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.
Files changed (36) hide show
  1. package/dist/index.js +27 -2
  2. package/messages/en.json +12 -3
  3. package/messages/he.json +12 -3
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  13. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  14. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  15. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  16. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  17. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  18. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  19. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  23. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  24. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  28. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  29. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  30. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  35. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  36. 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 { decodeSlug } from '@/core/lib/utils';
9
- import { ProductJsonLd } from '@/components/seo/product-json-ld';
10
- import { getCanonicalSiteUrl } from '@/core/lib/site-url';
11
- import { Breadcrumbs } from '@/components/seo/breadcrumbs';
12
- import { ReviewsSection } from '@/ui/product/reviews-section';
13
- import { ProductFaqSection } from '@/ui/product/faq-section';
14
- import { ProductClientSection } from '@/ui/product/product-client-section';
15
-
16
- type Props = {
17
- params: Promise<{ slug: string; locale?: string }>;
18
- };
19
-
20
- function buildHreflang(
21
- baseUrl: string,
22
- baseSlug: string,
23
- localeSlugs: Record<string, string>,
24
- locales: string[],
25
- defaultLoc: string
26
- ): Record<string, string> {
27
- const langs: Record<string, string> = {};
28
- for (const loc of locales) {
29
- const locSlug = localeSlugs[loc] || baseSlug;
30
- const path = loc === defaultLoc ? `/products/${locSlug}` : `/${loc}/products/${locSlug}`;
31
- langs[loc] = `${baseUrl}${path}`;
32
- }
33
- // x-default points to the default-locale canonical (no prefix)
34
- langs['x-default'] = `${baseUrl}/products/${localeSlugs[defaultLoc] || baseSlug}`;
35
- return langs;
36
- }
37
-
38
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
39
- const { slug: rawSlug, locale } = await params;
40
- const slug = decodeSlug(rawSlug);
41
-
42
- try {
43
- const client = await getServerClient(locale);
44
- // Fetch product + store info in parallel; storeInfo gives us the live
45
- // currency for OG price tags (with env-var + USD fallbacks so we never
46
- // silently emit a wrong currency when the API is briefly unreachable).
47
- // `regionId` belongs on EVERY product read. Without it the backend
48
- // prices in the default region, so a shopper elsewhere gets the wrong
49
- // currency in the OG price meta that link previews and Merchant Center
50
- // read. `getRegionId()` is `cache()`-wrapped and shared with the layout
51
- // and the page body below, so this is not an extra round trip. It
52
- // resolves to `undefined` on a store with no regions, and the SDK then
53
- // omits the query param entirely.
54
- const regionId = await getRegionId();
55
- const [product, storeInfo] = await Promise.all([
56
- client.getProductBySlug(slug, { regionId }),
57
- fetchStoreInfo(locale),
58
- ]);
59
- const imageUrl = product.images?.[0]?.url;
60
- // Prefer merchant-authored SEO copy; fall back to a stripped+truncated
61
- // version of the visible description, then product name. We must NEVER
62
- // emit raw HTML or a mid-word cut into <meta name="description">.
63
- const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
64
- const seoDescription =
65
- (product as { seoDescription?: string | null }).seoDescription ||
66
- buildMetaDescription(product.description) ||
67
- product.name;
68
-
69
- // OG product meta tags drive WhatsApp / Facebook / X link previews and
70
- // Google Merchant Center product enrichment. Emitting the literal "0"
71
- // here (the previous bug) causes price-zero link cards. Use the real
72
- // effective price from the SDK helper.
73
- const priceInfo = getProductPriceInfo(product);
74
- const currency = resolveCurrency(storeInfo);
75
- const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
76
- const inStock = product.inventory?.canPurchase !== false;
77
- const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
78
-
79
- // Multilingual SEO: hreflang tags + correct canonical per locale.
80
- // Locales come from storeInfo.i18n already fetched above, zero extra cost.
81
- const supportedLocales = storeInfo?.i18n?.supportedLocales ?? [];
82
- const defaultLoc = storeInfo?.i18n?.defaultLocale ?? storeInfo?.language ?? '';
83
- const baseUrl = await getCanonicalSiteUrl();
84
- const baseSlug = product.slug || slug;
85
- const localeSlugs = product.localeSlugs ?? {};
86
-
87
- // Canonical: use the locale-specific slug for this locale when available.
88
- const canonicalSlug = (locale && localeSlugs[locale]) || baseSlug;
89
- const canonicalPath =
90
- locale && locale !== defaultLoc
91
- ? `/${locale}/products/${canonicalSlug}`
92
- : `/products/${canonicalSlug}`;
93
-
94
- const hreflangLanguages =
95
- supportedLocales.length > 1
96
- ? buildHreflang(baseUrl, baseSlug, localeSlugs, supportedLocales, defaultLoc)
97
- : undefined;
98
-
99
- return {
100
- title: seoTitle,
101
- description: seoDescription,
102
- alternates: {
103
- canonical: canonicalPath,
104
- ...(hreflangLanguages ? { languages: hreflangLanguages } : {}),
105
- },
106
- openGraph: {
107
- title: seoTitle,
108
- description: seoDescription,
109
- images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
110
- type: 'website',
111
- },
112
- twitter: {
113
- card: 'summary_large_image',
114
- title: seoTitle,
115
- description: seoDescription,
116
- images: imageUrl ? [imageUrl] : [],
117
- },
118
- // Emit the OG product extension (Facebook / WhatsApp / X link previews,
119
- // Google Merchant Center). Skips the price pair entirely when the SDK
120
- // can't determine a positive amount, rather than shipping "0".
121
- other: {
122
- ...(priceAmount
123
- ? {
124
- 'og:price:amount': priceAmount,
125
- 'og:price:currency': currency,
126
- 'product:price:amount': priceAmount,
127
- 'product:price:currency': currency,
128
- }
129
- : {}),
130
- 'product:availability': inStock ? 'in stock' : 'out of stock',
131
- 'product:condition': 'new',
132
- ...(brandName ? { 'product:brand': brandName } : {}),
133
- },
134
- };
135
- } catch (err) {
136
- if (err instanceof BrainerceError && err.statusCode === 404) {
137
- return { title: 'Product not found' };
138
- }
139
- // Anything else (channel origin rejection, network failure) hits the page
140
- // render below too and surfaces there — don't stamp a misleading title.
141
- return {};
142
- }
143
- }
144
-
145
- export default async function ProductDetailPage({ params }: Props) {
146
- const { slug: rawSlug, locale } = await params;
147
- const slug = decodeSlug(rawSlug);
148
-
149
- const client = await getServerClient(locale);
150
- // Same region as the metadata above (one shared `cache()`d resolve), so the
151
- // price in the <head> and the price on the page cannot disagree.
152
- const regionId = await getRegionId();
153
- let product;
154
- try {
155
- product = await client.getProductBySlug(slug, { regionId });
156
- } catch (err) {
157
- // Only a genuine 404 may become a storefront 404. Anything else — a 403
158
- // from the channel's origin check, a network failure, a 5xx — is a
159
- // configuration or availability problem; disguising it as "product not
160
- // found" sends the merchant hunting for a slug bug that isn't there.
161
- if (!(err instanceof BrainerceError && err.statusCode === 404)) {
162
- throw err;
163
- }
164
- // The slug may have been RENAMED the platform records every rename.
165
- // 301 the old URL to the current slug so its ranking and inbound links
166
- // carry over; genuine unknowns fall through to the 404 page. Default
167
- // locale stays unprefixed (matches the sitemap/hreflang convention); the
168
- // destination page's canonical then points crawlers at the localized URL.
169
- const redirect = await client.resolveSlugRedirect('product', slug);
170
- if (redirect) {
171
- const info = await fetchStoreInfo(locale);
172
- const defaultLoc = info?.i18n?.defaultLocale ?? info?.language ?? '';
173
- const prefix = locale && locale !== defaultLoc ? `/${locale}` : '';
174
- permanentRedirect(`${prefix}/products/${redirect.currentSlug}`);
175
- }
176
- notFound();
177
- }
178
-
179
- const baseUrl = await getCanonicalSiteUrl();
180
- const productUrl = `${baseUrl}/products/${slug}`;
181
- // Reuse the cached storeInfo from generateMetadata's call — React cache()
182
- // collapses both into one backend request per render. resolveCurrency owns
183
- // the env-var + USD fallback chain so this stays a single source of truth.
184
- const storeInfo = await fetchStoreInfo(locale);
185
- const currency = resolveCurrency(storeInfo);
186
- const localePrefix = locale ? `/${locale}` : '';
187
- const primaryCategory = product.categories?.find((c) => c.slug);
188
- const breadcrumbItems = [
189
- { name: 'Home', href: `${localePrefix}/` },
190
- ...(primaryCategory
191
- ? [{ name: primaryCategory.name, href: `${localePrefix}/category/${primaryCategory.slug}` }]
192
- : []),
193
- { name: product.name },
194
- ];
195
-
196
- return (
197
- <>
198
- <ProductJsonLd
199
- product={product}
200
- url={productUrl}
201
- currency={currency}
202
- shipping={storeInfo?.shipping}
203
- />
204
- <div className="mx-auto max-w-7xl px-4 pt-6 sm:px-6 lg:px-8">
205
- <Breadcrumbs items={breadcrumbItems} />
206
- </div>
207
- <ProductClientSection product={product} stockAlertsEnabled={storeInfo?.stockAlertsEnabled} />
208
- {/* Visible Q&A + the FAQPage JSON-LD above read the same product.faq —
209
- the extractable format AI answer engines cite. */}
210
- <ProductFaqSection product={product} locale={locale} />
211
- <ReviewsSection productId={product.id} />
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 && typeof row.name === 'string' && typeof row.amount === 'number'
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; an order frozen before breakdowns existed
393
- carries none, so fall back to the single combined line. */}
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 > 1 ? (
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
- <p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
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
- <p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
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
- setError(err instanceof Error ? err.message : t('paymentError'));
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
- setError(err instanceof Error ? err.message : t('paymentError'));
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 & YouTube
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.