create-brainerce-store 1.41.1 → 1.42.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 (25) hide show
  1. package/dist/index.js +16 -11
  2. package/package.json +1 -1
  3. package/templates/nextjs/base/TRANSLATIONS.md +200 -0
  4. package/templates/nextjs/base/next.config.ts +22 -0
  5. package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
  6. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  7. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +40 -3
  8. package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
  9. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
  10. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  11. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  12. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  13. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  14. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
  15. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  16. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  17. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  18. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  19. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  20. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  21. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
  22. package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
  23. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  24. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  25. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.41.1",
34
+ version: "1.42.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
@@ -578,11 +578,18 @@ async function fetchStoreInfo(connectionId, baseUrl = KNOWN_API_URLS.production)
578
578
  }
579
579
  const storeName = json.storeName || json.name || "My Store";
580
580
  const displayName = json.channelName || storeName;
581
+ const currency = json.currency;
582
+ const language = json.language;
583
+ if (!currency || !language) {
584
+ throw new Error(
585
+ `Malformed /info response from ${baseUrl} (missing ${!currency ? "currency" : "language"}).`
586
+ );
587
+ }
581
588
  return {
582
589
  name: displayName,
583
590
  storeName,
584
- currency: json.currency || "USD",
585
- language: json.language || "en",
591
+ currency,
592
+ language,
586
593
  ...json.i18n ? { i18n: json.i18n } : {}
587
594
  };
588
595
  }
@@ -828,15 +835,13 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
828
835
  );
829
836
  } catch (err) {
830
837
  spinner.fail("Could not fetch store info");
831
- logger.warn(
832
- err instanceof Error ? err.message : "Using defaults. Make sure the connection ID is correct and the Brainerce API is reachable."
838
+ logger.error(
839
+ err instanceof Error ? err.message : "Could not reach the Brainerce API to read this connection."
840
+ );
841
+ logger.info(
842
+ "Re-run after verifying:\n \u2022 the connection ID matches a real sales channel in your dashboard\n \u2022 the machine running create-brainerce-store can reach the Brainerce API\n \u2022 if you are offline / behind a firewall, scaffold from a machine that can reach the API"
833
843
  );
834
- storeInfo = {
835
- name: projectName,
836
- storeName: projectName,
837
- currency: "USD",
838
- language: "en"
839
- };
844
+ process.exit(1);
840
845
  }
841
846
  }
842
847
  if (!pkgManager) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.41.1",
3
+ "version": "1.42.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -0,0 +1,200 @@
1
+ # Multi-language storefronts
2
+
3
+ Your scaffolded Brainerce store is already wired for multi-language out of the box. This doc explains the moving parts so you can customize them — you do **not** need to write per-locale fetch code; the SDK + middleware do it for you.
4
+
5
+ ## Status check
6
+
7
+ ```typescript
8
+ const store = await client.getStoreInfo();
9
+ store.i18n?.enabled; // → true / false
10
+ store.i18n?.defaultLocale; // → e.g. "en"
11
+ store.i18n?.supportedLocales; // → e.g. ["en", "he"]
12
+ ```
13
+
14
+ If `i18n.enabled` is `false` or only one locale is supported, the rest of this doc is a no-op — the app behaves as a single-language store.
15
+
16
+ ## URL strategy: "as-needed" locale prefix
17
+
18
+ This template uses the as-needed pattern (the most common approach for SEO):
19
+
20
+ | URL | Locale | Notes |
21
+ | -------------- | ------- | ------------------------------------------------- |
22
+ | `/` | default | Clean URL — no `/en` prefix on the default locale |
23
+ | `/products` | default | Same |
24
+ | `/he` | Hebrew | Secondary locales get a path prefix |
25
+ | `/he/products` | Hebrew | Same |
26
+
27
+ The middleware (`src/middleware.ts`) handles two transitions:
28
+
29
+ 1. `/{defaultLocale}/X` → 308 redirect to `/X` (canonicalize away the redundant prefix)
30
+ 2. `/X` → internal rewrite to `/{defaultLocale}/X` so the Next.js `[locale]` route segment still resolves
31
+
32
+ Every response carries an `x-locale` header so Server Components can read the resolved locale via `headers()`.
33
+
34
+ ## How translated content shows up on the page
35
+
36
+ You write **one** `fetch` and it works for every language.
37
+
38
+ ```tsx
39
+ // src/app/[locale]/products/[slug]/page.tsx
40
+ import { getServerClient } from '@/lib/server-client';
41
+ import { headers } from 'next/headers';
42
+
43
+ export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
44
+ const { slug } = await params;
45
+ const locale = (await headers()).get('x-locale') ?? undefined;
46
+ const client = getServerClient();
47
+ client.setLocale(locale);
48
+
49
+ const product = await client.getProductBySlug(slug);
50
+ // product.name, product.description, product.categories[].name, modifier groups,
51
+ // metafield labels — all already translated by the server.
52
+
53
+ return <ProductDetail product={product} />;
54
+ }
55
+ ```
56
+
57
+ The `StoreProvider` (`src/providers/store-provider.tsx`) calls `client.setLocale(locale)` on the client side, so React Server Components and Client Components both get translated content.
58
+
59
+ ## What's translatable (full list)
60
+
61
+ | Entity | Fields |
62
+ | ----------------------- | ----------------------------------------------------------- |
63
+ | **Product** | `name`, `description`, `slug`, `seoTitle`, `seoDescription` |
64
+ | **ProductVariant** | `name` |
65
+ | **Category** | `name` |
66
+ | **Brand** | `name` |
67
+ | **Tag** | `name` |
68
+ | **Attribute** | `name` (e.g. "Color") |
69
+ | **AttributeOption** | `name` (e.g. "Red") |
70
+ | **ModifierGroup** | `name`, `description` (e.g. "Toppings" → "תוספות") |
71
+ | **Modifier** | `name`, `description` (e.g. "Olives" → "זיתים") |
72
+ | **ProductMetafield** | `value` (free-text custom field values) |
73
+ | **MetafieldDefinition** | `name`, `description` (custom-field labels) |
74
+ | **BundleOffer** | `name`, `description` (bundle marketing label) |
75
+ | **OrderBumpConfig** | `title`, `description` (bump headline at checkout) |
76
+ | **DiscountRule** | `name`, `description` (rule label, used in banners) |
77
+ | **ContactForm** | `name`, `description`, `submitButton`, `successMessage` |
78
+ | **ContactFormField** | `label`, `placeholder`, `helpText` |
79
+
80
+ You never overlay translations yourself — the SDK does it on every request.
81
+
82
+ ## RTL (Hebrew, Arabic, Persian, Urdu, Yiddish)
83
+
84
+ `src/i18n.ts` exports `getDirection(locale)` that delegates to the SDK's `getDirectionForLocale()`. The layout uses it on `<html dir={…}>`:
85
+
86
+ ```tsx
87
+ // src/app/[locale]/layout.tsx
88
+ import { getDirection } from '@/i18n';
89
+
90
+ export default async function LocaleLayout({ children, params }: Props) {
91
+ const { locale } = await params;
92
+ const dir = getDirection(locale);
93
+ return (
94
+ <html lang={locale} dir={dir}>
95
+ <body>{children}</body>
96
+ </html>
97
+ );
98
+ }
99
+ ```
100
+
101
+ This automatically reverses flexbox row order — **do not add `flex-row-reverse`** on top, that's a double-swap. **Do** swap directional icons (chevrons, arrows) using `useDirection()` from `@radix-ui/react-direction`.
102
+
103
+ Use logical Tailwind classes (`ms-*`/`me-*` for margin, `ps-*`/`pe-*` for padding, `start-*`/`end-*` for positioning) instead of physical ones (`ml-*`, `mr-*`, `left-*`, `right-*`) so the layout mirrors automatically.
104
+
105
+ ## Language switcher
106
+
107
+ ```tsx
108
+ 'use client';
109
+ import { useStore } from '@/providers/store-provider';
110
+ import Link from 'next/link';
111
+ import { useParams, usePathname } from 'next/navigation';
112
+
113
+ export function LanguageSwitcher() {
114
+ const { storeInfo } = useStore();
115
+ const pathname = usePathname();
116
+ const { locale: current } = useParams<{ locale?: string }>();
117
+
118
+ if (!storeInfo?.i18n?.enabled) return null;
119
+ const locales = storeInfo.i18n.supportedLocales;
120
+ const defaultLocale = storeInfo.i18n.defaultLocale;
121
+
122
+ return (
123
+ <nav className="flex gap-2">
124
+ {locales.map((loc) => {
125
+ const isCurrent = (current ?? defaultLocale) === loc;
126
+ const href = loc === defaultLocale ? pathname : `/${loc}${pathname}`;
127
+ return (
128
+ <Link key={loc} href={href} className={isCurrent ? 'font-bold' : ''}>
129
+ {loc.toUpperCase()}
130
+ </Link>
131
+ );
132
+ })}
133
+ </nav>
134
+ );
135
+ }
136
+ ```
137
+
138
+ The merchant configures supported locales in `Dashboard → Settings → Languages`. Your switcher reads them from `storeInfo.i18n.supportedLocales` — never hardcode a list.
139
+
140
+ ## SEO: per-locale slugs and hreflang
141
+
142
+ When the merchant translates a product's `slug`, every locale gets its own URL (e.g. `/cheese-pizza` and `/he/פיצה-גבינה`). Pull all alternates in one call for the `<head>`:
143
+
144
+ ```tsx
145
+ const alternates = await client.getProductAlternates(product.id);
146
+ // → [{ locale: 'en', slug: 'cheese-pizza' }, { locale: 'he', slug: 'פיצה-גבינה' }]
147
+
148
+ // In generateMetadata:
149
+ return {
150
+ alternates: {
151
+ languages: Object.fromEntries(
152
+ alternates.map((a) => [a.locale, `/${a.locale}/products/${a.slug}`])
153
+ ),
154
+ },
155
+ };
156
+ ```
157
+
158
+ ## Promotional surfaces: bundles, bumps, discount banners
159
+
160
+ The same overlay applies to every promotional surface — you don't need locale-aware code:
161
+
162
+ ```tsx
163
+ // Cart bundles (cross-sell) — locale-aware automatically:
164
+ const cart = await client.getCart(cartId);
165
+ cart.bundles[0].name; // "ארוחת צהריים" (bundle's own label)
166
+ cart.bundles[0].offeredProducts[0].name; // "פיצה גבינה" (each offered product)
167
+
168
+ // Order bumps at checkout:
169
+ const { bumps } = await client.getOrderBumps(checkoutId);
170
+ bumps[0].title; // translated bump headline (or merchant override)
171
+ bumps[0].bumpProduct.name; // translated product name
172
+
173
+ // Discount-rule banners (rendered from rule.name + rule.displayConfig):
174
+ const rules = await client.getActiveDiscountRules();
175
+ rules[0].name; // translated rule name
176
+ ```
177
+
178
+ ## How merchants populate translations
179
+
180
+ For background — your storefront doesn't need to call these endpoints, but knowing the merchant flow helps when debugging unexpectedly-empty translations:
181
+
182
+ - **Per-row overlay** on every taxonomy/product list page in the dashboard.
183
+ - **Inside the entity create/edit modal** — a `LocaleSelector` in the header + "Translate with AI" button populates target-locale fields (Products, Attributes, Modifier Groups, Modifiers, Custom Fields).
184
+ - **Translate icon button** on bundle / order-bump rows (`/products/.../offers`) and on discount-rule rows (`/discount-rules`) opens a standalone translation modal with one-click AI.
185
+ - **Bulk** — select N rows on a list page → toolbar action "Translate to Hebrew" enqueues an AI translation job for everything selected (including children, e.g. all modifiers under selected groups).
186
+
187
+ Translations are persisted via the dashboard-only endpoint `PUT /api/stores/:storeId/translations/:entityType/:entityId/:locale`. The storefront SDK never calls this — it only consumes the overlay on read.
188
+
189
+ ## Troubleshooting
190
+
191
+ | Symptom | Likely cause | Fix |
192
+ | -------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------- |
193
+ | `product.name` is English even though locale is `he` | Store doesn't have `he` in `supportedLocales` | Add the locale in `Dashboard → Settings → Languages` |
194
+ | Some fields translate, others don't | Merchant translated subset | Per-field fallback is by design — fill the rest in dashboard |
195
+ | Layout broken in Hebrew | Missing `<html dir="rtl">` | Use `getDirection(locale)` in the layout |
196
+ | Modifier names in default language but product name translates | Merchant translated `Product` only | Bulk translate on `/products/modifier-groups` covers groups + all their modifiers |
197
+ | Custom-field label "Warranty" doesn't translate | Merchant didn't translate the `MetafieldDefinition` | Per-row "Translate" on `/products/custom-fields` |
198
+ | Bundle name "Summer Sale" stays in English in Hebrew cart | Merchant didn't translate the `BundleOffer` itself | Click the Languages icon on the bundle row in `/products/.../offers` |
199
+
200
+ See [the Brainerce docs](https://brainerce.com/docs/concepts/translations) for the canonical reference.
@@ -1,5 +1,27 @@
1
1
  import type { NextConfig } from 'next';
2
2
 
3
+ // Build-time invariant — fail loud if NEXT_PUBLIC_STORE_CURRENCY is missing
4
+ // for a production build. Next.js inlines NEXT_PUBLIC_* into the client
5
+ // bundle at `next build` time; once inlined, the value cannot be patched at
6
+ // deploy time. A missing env here is the root cause of non-USD stores ending
7
+ // up with `$5,096` baked into their HTML (and indexed by Googlebot).
8
+ //
9
+ // In dev (`next dev`) we only warn, so local-only experiments don't break.
10
+ if (!process.env.NEXT_PUBLIC_STORE_CURRENCY) {
11
+ const msg =
12
+ 'NEXT_PUBLIC_STORE_CURRENCY is not set.\n' +
13
+ 'Next.js inlines NEXT_PUBLIC_* vars into the client bundle at build time;\n' +
14
+ 'a missing value will silently fall back to USD in storefront price displays\n' +
15
+ 'and ship that to search-engine crawlers. Set it in .env.local (written by\n' +
16
+ 'create-brainerce-store) or in your CI / hosting build env (Coolify / Vercel).';
17
+ if (process.env.NODE_ENV === 'production') {
18
+ throw new Error(`[next.config] ${msg}`);
19
+ } else {
20
+ // eslint-disable-next-line no-console
21
+ console.warn(`[next.config] warning: ${msg}`);
22
+ }
23
+ }
24
+
3
25
  const nextConfig: NextConfig = {
4
26
  // isomorphic-dompurify ships jsdom, which at runtime reads stylesheet files
5
27
  // from its own package directory. Webpack bundling breaks those relative
@@ -38,7 +38,7 @@ function CheckoutContent() {
38
38
  const { storeInfo } = useStoreInfo();
39
39
  const { cart, refreshCart } = useCart();
40
40
  const { isLoggedIn } = useAuth();
41
- const currency = storeInfo?.currency || 'USD';
41
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
42
42
  const t = useTranslations('checkout');
43
43
  const tc = useTranslations('common');
44
44
 
@@ -5,7 +5,7 @@ import { StoreProvider } from '@/providers/store-provider';
5
5
  import { AnnouncementBar } from '@/components/content/announcement-bar';
6
6
  import { SiteHeader } from '@/components/content/site-header';
7
7
  import { SiteFooter } from '@/components/content/site-footer';
8
- import { getServerClient } from '@/lib/brainerce';
8
+ import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
9
9
  import { getDirection, supportedLocales } from '@/i18n';
10
10
  import { getNonce } from '@/lib/nonce';
11
11
  import '../globals.css';
@@ -61,10 +61,14 @@ export default async function RootLayout({
61
61
  // seeded a particular content type yet. New stores ship with default rows
62
62
  // seeded by the backend (StoresService.seedDefaultContent).
63
63
  const client = getServerClient(locale);
64
- const [announcements, siteHeader, siteFooter] = await Promise.all([
64
+ const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
65
65
  client.content.announcement.list(locale).catch(() => []),
66
66
  client.content.header.get('main', locale).catch(() => null),
67
67
  client.content.footer.get('main', locale).catch(() => null),
68
+ // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
69
+ // render with the real currency, feature flags, and i18n config at frame 0
70
+ // — without this, Googlebot sees USD/defaults baked into the HTML.
71
+ fetchStoreInfo(locale),
68
72
  ]);
69
73
 
70
74
  return (
@@ -83,7 +87,7 @@ export default async function RootLayout({
83
87
  />
84
88
  </head>
85
89
  <body className={font.className}>
86
- <StoreProvider locale={locale}>
90
+ <StoreProvider locale={locale} initialStoreInfo={storeInfo}>
87
91
  <div className="min-h-screen flex flex-col">
88
92
  <AnnouncementBar announcements={announcements} />
89
93
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
@@ -102,7 +106,7 @@ import { StoreProvider } from '@/providers/store-provider';
102
106
  import { AnnouncementBar } from '@/components/content/announcement-bar';
103
107
  import { SiteHeader } from '@/components/content/site-header';
104
108
  import { SiteFooter } from '@/components/content/site-footer';
105
- import { getServerClient } from '@/lib/brainerce';
109
+ import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
106
110
  import { getNonce } from '@/lib/nonce';
107
111
  import './globals.css';
108
112
 
@@ -150,10 +154,14 @@ export default async function RootLayout({
150
154
  // seeded a particular content type yet. New stores ship with default rows
151
155
  // seeded by the backend (StoresService.seedDefaultContent).
152
156
  const client = getServerClient();
153
- const [announcements, siteHeader, siteFooter] = await Promise.all([
157
+ const [announcements, siteHeader, siteFooter, storeInfo] = await Promise.all([
154
158
  client.content.announcement.list().catch(() => []),
155
159
  client.content.header.get('main').catch(() => null),
156
160
  client.content.footer.get('main').catch(() => null),
161
+ // SSR-fetch store config so PriceDisplay / FreeShippingBar / upsell UI
162
+ // render with the real currency, feature flags, and i18n config at frame 0
163
+ // — without this, Googlebot sees USD/defaults baked into the HTML.
164
+ fetchStoreInfo(),
157
165
  ]);
158
166
 
159
167
  return (
@@ -172,7 +180,7 @@ export default async function RootLayout({
172
180
  />
173
181
  </head>
174
182
  <body className={font.className}>
175
- <StoreProvider>
183
+ <StoreProvider initialStoreInfo={storeInfo}>
176
184
  <div className="min-h-screen flex flex-col">
177
185
  <AnnouncementBar announcements={announcements} />
178
186
  <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
@@ -1,6 +1,7 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { notFound } from 'next/navigation';
3
- import { getServerClient } from '@/lib/brainerce';
3
+ import { getProductPriceInfo } from 'brainerce';
4
+ import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
4
5
  import { buildMetaDescription } from '@/lib/seo';
5
6
  import { decodeSlug } from '@/lib/utils';
6
7
  import { ProductJsonLd } from '@/components/seo/product-json-ld';
@@ -17,7 +18,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
17
18
 
18
19
  try {
19
20
  const client = getServerClient(locale);
20
- const product = await client.getProductBySlug(slug);
21
+ // Fetch product + store info in parallel; storeInfo gives us the live
22
+ // currency for OG price tags (with env-var + USD fallbacks so we never
23
+ // silently emit a wrong currency when the API is briefly unreachable).
24
+ const [product, storeInfo] = await Promise.all([
25
+ client.getProductBySlug(slug),
26
+ fetchStoreInfo(locale),
27
+ ]);
21
28
  const imageUrl = product.images?.[0]?.url;
22
29
  // Prefer merchant-authored SEO copy; fall back to a stripped+truncated
23
30
  // version of the visible description, then product name. We must NEVER
@@ -28,6 +35,16 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
28
35
  buildMetaDescription(product.description) ||
29
36
  product.name;
30
37
 
38
+ // OG product meta tags drive WhatsApp / Facebook / X link previews and
39
+ // Google Merchant Center product enrichment. Emitting the literal "0"
40
+ // here (the previous bug) causes price-zero link cards. Use the real
41
+ // effective price from the SDK helper.
42
+ const priceInfo = getProductPriceInfo(product);
43
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
44
+ const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
45
+ const inStock = product.inventory?.canPurchase !== false;
46
+ const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
47
+
31
48
  return {
32
49
  title: seoTitle,
33
50
  description: seoDescription,
@@ -46,6 +63,22 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
46
63
  description: seoDescription,
47
64
  images: imageUrl ? [imageUrl] : [],
48
65
  },
66
+ // Emit the OG product extension (Facebook / WhatsApp / X link previews,
67
+ // Google Merchant Center). Skips the price pair entirely when the SDK
68
+ // can't determine a positive amount, rather than shipping "0".
69
+ other: {
70
+ ...(priceAmount
71
+ ? {
72
+ 'og:price:amount': priceAmount,
73
+ 'og:price:currency': currency,
74
+ 'product:price:amount': priceAmount,
75
+ 'product:price:currency': currency,
76
+ }
77
+ : {}),
78
+ 'product:availability': inStock ? 'in stock' : 'out of stock',
79
+ 'product:condition': 'new',
80
+ ...(brandName ? { 'product:brand': brandName } : {}),
81
+ },
49
82
  };
50
83
  } catch {
51
84
  return {
@@ -68,7 +101,11 @@ export default async function ProductDetailPage({ params }: Props) {
68
101
 
69
102
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
70
103
  const productUrl = `${baseUrl}/products/${slug}`;
71
- const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
104
+ // Reuse the cached storeInfo from generateMetadata's call — React cache()
105
+ // collapses both into one backend request per render. Falls back to env
106
+ // var then USD if the server fetch returned null.
107
+ const storeInfo = await fetchStoreInfo(locale);
108
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
72
109
 
73
110
  return (
74
111
  <>