create-brainerce-store 1.41.1 → 1.43.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 (28) 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/scripts/fetch-store-info.mjs +10 -4
  6. package/templates/nextjs/base/src/app/checkout/page.tsx +982 -981
  7. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  8. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -80
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +2 -1
  10. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +2 -3
  11. package/templates/nextjs/base/src/components/cart/cart-item.tsx +2 -3
  12. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +3 -3
  13. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +2 -3
  14. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +4 -1
  15. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +2 -3
  16. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +2 -3
  17. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +2 -3
  18. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +2 -3
  19. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +5 -4
  20. package/templates/nextjs/base/src/components/products/product-card.tsx +3 -3
  21. package/templates/nextjs/base/src/components/products/variant-selector.tsx +2 -3
  22. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +129 -121
  23. package/templates/nextjs/base/src/components/shared/price-display.tsx +2 -3
  24. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  25. package/templates/nextjs/base/src/lib/resolve-currency.ts +25 -0
  26. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  27. package/templates/nextjs/base/src/lib/use-currency.ts +24 -0
  28. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -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,80 +1,118 @@
1
- import type { Metadata } from 'next';
2
- import { notFound } from 'next/navigation';
3
- import { getServerClient } from '@/lib/brainerce';
4
- import { buildMetaDescription } from '@/lib/seo';
5
- import { decodeSlug } from '@/lib/utils';
6
- import { ProductJsonLd } from '@/components/seo/product-json-ld';
7
- import { ReviewsSection } from '@/components/reviews/reviews-section';
8
- import { ProductClientSection } from './product-client-section';
9
-
10
- type Props = {
11
- params: Promise<{ slug: string; locale?: string }>;
12
- };
13
-
14
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
15
- const { slug: rawSlug, locale } = await params;
16
- const slug = decodeSlug(rawSlug);
17
-
18
- try {
19
- const client = getServerClient(locale);
20
- const product = await client.getProductBySlug(slug);
21
- const imageUrl = product.images?.[0]?.url;
22
- // Prefer merchant-authored SEO copy; fall back to a stripped+truncated
23
- // version of the visible description, then product name. We must NEVER
24
- // emit raw HTML or a mid-word cut into <meta name="description">.
25
- const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
26
- const seoDescription =
27
- (product as { seoDescription?: string | null }).seoDescription ||
28
- buildMetaDescription(product.description) ||
29
- product.name;
30
-
31
- return {
32
- title: seoTitle,
33
- description: seoDescription,
34
- alternates: {
35
- canonical: `/products/${slug}`,
36
- },
37
- openGraph: {
38
- title: seoTitle,
39
- description: seoDescription,
40
- images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
41
- type: 'website',
42
- },
43
- twitter: {
44
- card: 'summary_large_image',
45
- title: seoTitle,
46
- description: seoDescription,
47
- images: imageUrl ? [imageUrl] : [],
48
- },
49
- };
50
- } catch {
51
- return {
52
- title: 'Product not found',
53
- };
54
- }
55
- }
56
-
57
- export default async function ProductDetailPage({ params }: Props) {
58
- const { slug: rawSlug, locale } = await params;
59
- const slug = decodeSlug(rawSlug);
60
-
61
- let product;
62
- try {
63
- const client = getServerClient(locale);
64
- product = await client.getProductBySlug(slug);
65
- } catch {
66
- notFound();
67
- }
68
-
69
- const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
70
- const productUrl = `${baseUrl}/products/${slug}`;
71
- const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
72
-
73
- return (
74
- <>
75
- <ProductJsonLd product={product} url={productUrl} currency={currency} />
76
- <ProductClientSection product={product} />
77
- <ReviewsSection productId={product.id} />
78
- </>
79
- );
80
- }
1
+ import type { Metadata } from 'next';
2
+ import { notFound } from 'next/navigation';
3
+ import { getProductPriceInfo } from 'brainerce';
4
+ import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
5
+ import { resolveCurrency } from '@/lib/resolve-currency';
6
+ import { buildMetaDescription } from '@/lib/seo';
7
+ import { decodeSlug } from '@/lib/utils';
8
+ import { ProductJsonLd } from '@/components/seo/product-json-ld';
9
+ import { ReviewsSection } from '@/components/reviews/reviews-section';
10
+ import { ProductClientSection } from './product-client-section';
11
+
12
+ type Props = {
13
+ params: Promise<{ slug: string; locale?: string }>;
14
+ };
15
+
16
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
17
+ const { slug: rawSlug, locale } = await params;
18
+ const slug = decodeSlug(rawSlug);
19
+
20
+ try {
21
+ const client = getServerClient(locale);
22
+ // Fetch product + store info in parallel; storeInfo gives us the live
23
+ // currency for OG price tags (with env-var + USD fallbacks so we never
24
+ // silently emit a wrong currency when the API is briefly unreachable).
25
+ const [product, storeInfo] = await Promise.all([
26
+ client.getProductBySlug(slug),
27
+ fetchStoreInfo(locale),
28
+ ]);
29
+ const imageUrl = product.images?.[0]?.url;
30
+ // Prefer merchant-authored SEO copy; fall back to a stripped+truncated
31
+ // version of the visible description, then product name. We must NEVER
32
+ // emit raw HTML or a mid-word cut into <meta name="description">.
33
+ const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
34
+ const seoDescription =
35
+ (product as { seoDescription?: string | null }).seoDescription ||
36
+ buildMetaDescription(product.description) ||
37
+ product.name;
38
+
39
+ // OG product meta tags drive WhatsApp / Facebook / X link previews and
40
+ // Google Merchant Center product enrichment. Emitting the literal "0"
41
+ // here (the previous bug) causes price-zero link cards. Use the real
42
+ // effective price from the SDK helper.
43
+ const priceInfo = getProductPriceInfo(product);
44
+ const currency = resolveCurrency(storeInfo);
45
+ const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
46
+ const inStock = product.inventory?.canPurchase !== false;
47
+ const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
48
+
49
+ return {
50
+ title: seoTitle,
51
+ description: seoDescription,
52
+ alternates: {
53
+ canonical: `/products/${slug}`,
54
+ },
55
+ openGraph: {
56
+ title: seoTitle,
57
+ description: seoDescription,
58
+ images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
59
+ type: 'website',
60
+ },
61
+ twitter: {
62
+ card: 'summary_large_image',
63
+ title: seoTitle,
64
+ description: seoDescription,
65
+ images: imageUrl ? [imageUrl] : [],
66
+ },
67
+ // Emit the OG product extension (Facebook / WhatsApp / X link previews,
68
+ // Google Merchant Center). Skips the price pair entirely when the SDK
69
+ // can't determine a positive amount, rather than shipping "0".
70
+ other: {
71
+ ...(priceAmount
72
+ ? {
73
+ 'og:price:amount': priceAmount,
74
+ 'og:price:currency': currency,
75
+ 'product:price:amount': priceAmount,
76
+ 'product:price:currency': currency,
77
+ }
78
+ : {}),
79
+ 'product:availability': inStock ? 'in stock' : 'out of stock',
80
+ 'product:condition': 'new',
81
+ ...(brandName ? { 'product:brand': brandName } : {}),
82
+ },
83
+ };
84
+ } catch {
85
+ return {
86
+ title: 'Product not found',
87
+ };
88
+ }
89
+ }
90
+
91
+ export default async function ProductDetailPage({ params }: Props) {
92
+ const { slug: rawSlug, locale } = await params;
93
+ const slug = decodeSlug(rawSlug);
94
+
95
+ let product;
96
+ try {
97
+ const client = getServerClient(locale);
98
+ product = await client.getProductBySlug(slug);
99
+ } catch {
100
+ notFound();
101
+ }
102
+
103
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
104
+ const productUrl = `${baseUrl}/products/${slug}`;
105
+ // Reuse the cached storeInfo from generateMetadata's call — React cache()
106
+ // collapses both into one backend request per render. resolveCurrency owns
107
+ // the env-var + USD fallback chain so this stays a single source of truth.
108
+ const storeInfo = await fetchStoreInfo(locale);
109
+ const currency = resolveCurrency(storeInfo);
110
+
111
+ return (
112
+ <>
113
+ <ProductJsonLd product={product} url={productUrl} currency={currency} />
114
+ <ProductClientSection product={product} />
115
+ <ReviewsSection productId={product.id} />
116
+ </>
117
+ );
118
+ }
@@ -5,6 +5,7 @@ import Image from 'next/image';
5
5
  import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
7
  import { getClient } from '@/lib/brainerce';
8
+ import { useCurrency } from '@/lib/use-currency';
8
9
  import { useTranslations } from '@/lib/translations';
9
10
  import { cn } from '@/lib/utils';
10
11
  import { OrderCustomizations } from './order-customizations';
@@ -83,7 +84,7 @@ function OrderCard({ order }: { order: Order }) {
83
84
  const [expanded, setExpanded] = useState(false);
84
85
  const statusConfig =
85
86
  STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
86
- const currency = order.currency || 'USD';
87
+ const currency = useCurrency(order.currency);
87
88
  const totalAmount = order.totalAmount || order.total || '0';
88
89
 
89
90
  return (
@@ -4,7 +4,7 @@ import { useState } from 'react';
4
4
  import Image from 'next/image';
5
5
  import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
- import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useCurrency } from '@/lib/use-currency';
8
8
  import { useTranslations } from '@/lib/translations';
9
9
  import { cn } from '@/lib/utils';
10
10
 
@@ -16,9 +16,8 @@ interface CartBundleOfferCardProps {
16
16
  }
17
17
 
18
18
  export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
19
- const { storeInfo } = useStoreInfo();
20
19
  const t = useTranslations('cart');
21
- const currency = storeInfo?.currency || 'USD';
20
+ const currency = useCurrency();
22
21
  const [adding, setAdding] = useState(false);
23
22
 
24
23
  const offered = offer.offeredProducts;
@@ -6,7 +6,7 @@ import type { CartItem as CartItemType } from 'brainerce';
6
6
  import { getCartItemImage, formatPrice } from 'brainerce';
7
7
  import { getClient } from '@/lib/brainerce';
8
8
  import { useTranslations } from '@/lib/translations';
9
- import { useStoreInfo } from '@/providers/store-provider';
9
+ import { useCurrency } from '@/lib/use-currency';
10
10
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
11
11
  import { cn } from '@/lib/utils';
12
12
 
@@ -19,8 +19,7 @@ interface CartItemProps {
19
19
  export function CartItem({ item, onUpdate, className }: CartItemProps) {
20
20
  const t = useTranslations('common');
21
21
  const td = useTranslations('productDetail');
22
- const { storeInfo } = useStoreInfo();
23
- const currency = storeInfo?.currency || 'USD';
22
+ const currency = useCurrency();
24
23
  const [updating, setUpdating] = useState(false);
25
24
  const [removing, setRemoving] = useState(false);
26
25
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  import { formatPrice } from 'brainerce';
4
4
  import { useTranslations } from '@/lib/translations';
5
- import { useStoreInfo, useCart } from '@/providers/store-provider';
5
+ import { useCart } from '@/providers/store-provider';
6
+ import { useCurrency } from '@/lib/use-currency';
6
7
  import { cn } from '@/lib/utils';
7
8
 
8
9
  interface CartSummaryProps {
@@ -12,9 +13,8 @@ interface CartSummaryProps {
12
13
  export function CartSummary({ className }: CartSummaryProps) {
13
14
  const t = useTranslations('cart');
14
15
  const tc = useTranslations('common');
15
- const { storeInfo } = useStoreInfo();
16
16
  const { totals, cart } = useCart();
17
- const currency = storeInfo?.currency || 'USD';
17
+ const currency = useCurrency();
18
18
 
19
19
  const rules = cart?.appliedDiscounts;
20
20
  const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
@@ -5,7 +5,7 @@ import Image from 'next/image';
5
5
  import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
7
  import { getClient } from '@/lib/brainerce';
8
- import { useStoreInfo } from '@/providers/store-provider';
8
+ import { useCurrency } from '@/lib/use-currency';
9
9
  import { useTranslations } from '@/lib/translations';
10
10
  import { cn } from '@/lib/utils';
11
11
 
@@ -22,9 +22,8 @@ export function CartUpgradeBanner({
22
22
  onUpgrade,
23
23
  className,
24
24
  }: CartUpgradeBannerProps) {
25
- const { storeInfo } = useStoreInfo();
26
25
  const t = useTranslations('cart');
27
- const currency = storeInfo?.currency || 'USD';
26
+ const currency = useCurrency();
28
27
  const [upgrading, setUpgrading] = useState(false);
29
28
  const [dismissed, setDismissed] = useState(false);
30
29
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { formatPrice } from 'brainerce';
4
4
  import { useStoreInfo, useCart } from '@/providers/store-provider';
5
+ import { useCurrency } from '@/lib/use-currency';
5
6
  import { useTranslations } from '@/lib/translations';
6
7
  import { cn } from '@/lib/utils';
7
8
 
@@ -10,9 +11,12 @@ interface FreeShippingBarProps {
10
11
  }
11
12
 
12
13
  export function FreeShippingBar({ className }: FreeShippingBarProps) {
14
+ // Hooks must be called unconditionally and in the same order on every
15
+ // render — keep all of them above any early `return null` branch.
13
16
  const t = useTranslations('cart');
14
17
  const { storeInfo } = useStoreInfo();
15
18
  const { totals } = useCart();
19
+ const currency = useCurrency();
16
20
 
17
21
  const upsell = storeInfo?.upsell;
18
22
  const threshold = upsell?.freeShippingThreshold;
@@ -25,7 +29,6 @@ export function FreeShippingBar({ className }: FreeShippingBarProps) {
25
29
  const remaining = Math.max(0, threshold - subtotal);
26
30
  const progress = Math.min(100, (subtotal / threshold) * 100);
27
31
  const qualified = remaining <= 0;
28
- const currency = storeInfo?.currency || 'USD';
29
32
 
30
33
  // Don't show if already qualified
31
34
  if (qualified) {
@@ -4,7 +4,7 @@ import { useState, useMemo } from 'react';
4
4
  import Image from 'next/image';
5
5
  import type { OrderBump, RecommendationVariant } from 'brainerce';
6
6
  import { formatPrice, getVariantOptions } from 'brainerce';
7
- import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useCurrency } from '@/lib/use-currency';
8
8
  import { useTranslations } from '@/lib/translations';
9
9
  import { cn } from '@/lib/utils';
10
10
 
@@ -17,9 +17,8 @@ interface OrderBumpCardProps {
17
17
  }
18
18
 
19
19
  export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: OrderBumpCardProps) {
20
- const { storeInfo } = useStoreInfo();
21
20
  const t = useTranslations('checkout');
22
- const currency = storeInfo?.currency || 'USD';
21
+ const currency = useCurrency();
23
22
  const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
24
23
 
25
24
  const product = bump.bumpProduct;
@@ -4,7 +4,7 @@ import { useState } from 'react';
4
4
  import type { PickupLocation } from 'brainerce';
5
5
  import { formatPrice } from 'brainerce';
6
6
  import { useTranslations } from '@/lib/translations';
7
- import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useCurrency } from '@/lib/use-currency';
8
8
  import { cn } from '@/lib/utils';
9
9
 
10
10
  interface PickupStepProps {
@@ -28,8 +28,7 @@ export function PickupStep({
28
28
  const t = useTranslations('checkout');
29
29
  const tf = useTranslations('checkoutForm');
30
30
  const tc = useTranslations('common');
31
- const { storeInfo } = useStoreInfo();
32
- const currency = storeInfo?.currency || 'USD';
31
+ const currency = useCurrency();
33
32
 
34
33
  const [selectedId, setSelectedId] = useState<string | null>(null);
35
34
  const [email, setEmail] = useState(initialEmail);
@@ -3,7 +3,7 @@
3
3
  import type { ShippingRate } from 'brainerce';
4
4
  import { formatPrice } from 'brainerce';
5
5
  import { useTranslations } from '@/lib/translations';
6
- import { useStoreInfo } from '@/providers/store-provider';
6
+ import { useCurrency } from '@/lib/use-currency';
7
7
  import { cn } from '@/lib/utils';
8
8
 
9
9
  interface ShippingStepProps {
@@ -23,8 +23,7 @@ export function ShippingStep({
23
23
  }: ShippingStepProps) {
24
24
  const t = useTranslations('checkout');
25
25
  const tc = useTranslations('common');
26
- const { storeInfo } = useStoreInfo();
27
- const currency = storeInfo?.currency || 'USD';
26
+ const currency = useCurrency();
28
27
 
29
28
  if (rates.length === 0) {
30
29
  return (
@@ -3,7 +3,7 @@
3
3
  import type { TaxBreakdown } from 'brainerce';
4
4
  import { formatPrice } from 'brainerce';
5
5
  import { useTranslations } from '@/lib/translations';
6
- import { useStoreInfo } from '@/providers/store-provider';
6
+ import { useCurrency } from '@/lib/use-currency';
7
7
  import { cn } from '@/lib/utils';
8
8
 
9
9
  interface TaxDisplayProps {
@@ -19,8 +19,7 @@ interface TaxDisplayProps {
19
19
  export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
20
  const t = useTranslations('checkout');
21
21
  const tc = useTranslations('common');
22
- const { storeInfo } = useStoreInfo();
23
- const currency = storeInfo?.currency || 'USD';
22
+ const currency = useCurrency();
24
23
 
25
24
  // Before address is set
26
25
  if (!addressSet) {
@@ -4,8 +4,8 @@ import { useState } from 'react';
4
4
  import Image from 'next/image';
5
5
  import type { Product, ProductRecommendation } from 'brainerce';
6
6
  import { formatPrice } from 'brainerce';
7
- import { useCart } from '@/providers/store-provider';
8
- import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useCart, useStoreInfo } from '@/providers/store-provider';
8
+ import { useCurrency } from '@/lib/use-currency';
9
9
  import { useTranslations } from '@/lib/translations';
10
10
  import { cn } from '@/lib/utils';
11
11
 
@@ -88,9 +88,12 @@ export function FrequentlyBoughtTogether({
88
88
  currentProduct,
89
89
  className,
90
90
  }: FrequentlyBoughtTogetherProps) {
91
+ // Hooks must be called unconditionally and in the same order on every
92
+ // render — keep all of them above any early `return null` branch.
91
93
  const { storeInfo } = useStoreInfo();
92
94
  const { refreshCart } = useCart();
93
95
  const t = useTranslations('productDetail');
96
+ const currency = useCurrency();
94
97
 
95
98
  // Only show up to 3 cross-sells
96
99
  const crossSells = items.slice(0, 3);
@@ -101,8 +104,6 @@ export function FrequentlyBoughtTogether({
101
104
  if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
102
105
  if (crossSells.length === 0) return null;
103
106
 
104
- const currency = storeInfo.currency || 'USD';
105
-
106
107
  const currentPrice = getEffectivePrice(currentProduct);
107
108
  const currentImage = currentProduct.images?.[0];
108
109
  const currentImageUrl = currentImage
@@ -9,7 +9,8 @@ import { useTranslations } from '@/lib/translations';
9
9
  import { PriceDisplay } from '@/components/shared/price-display';
10
10
  import { StockBadge } from '@/components/products/stock-badge';
11
11
  import { DiscountBadge } from '@/components/products/discount-badge';
12
- import { useCart, useStoreInfo } from '@/providers/store-provider';
12
+ import { useCart } from '@/providers/store-provider';
13
+ import { useCurrency } from '@/lib/use-currency';
13
14
  import { cn } from '@/lib/utils';
14
15
 
15
16
  interface ProductCardProps {
@@ -18,8 +19,7 @@ interface ProductCardProps {
18
19
  }
19
20
 
20
21
  function VariantPriceRange({ product }: { product: Product }) {
21
- const { storeInfo } = useStoreInfo();
22
- const currency = storeInfo?.currency || 'USD';
22
+ const currency = useCurrency();
23
23
 
24
24
  let min: number;
25
25
  let max: number;
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
4
4
  import type { Product, ProductVariant } from 'brainerce';
5
5
  import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
6
6
  import type { InventoryInfo } from 'brainerce';
7
- import { useStoreInfo } from '@/providers/store-provider';
7
+ import { useCurrency } from '@/lib/use-currency';
8
8
  import { useTranslations } from '@/lib/translations';
9
9
  import { cn } from '@/lib/utils';
10
10
 
@@ -33,9 +33,8 @@ export function VariantSelector({
33
33
  onVariantChange,
34
34
  className,
35
35
  }: VariantSelectorProps) {
36
- const { storeInfo } = useStoreInfo();
37
36
  const t = useTranslations('productDetail');
38
- const currency = storeInfo?.currency || 'USD';
37
+ const currency = useCurrency();
39
38
  const variants = useMemo(() => product.variants || [], [product.variants]);
40
39
 
41
40
  // Get swatch metadata from product attribute options