create-brainerce-store 1.42.0 → 1.43.1

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 +1 -1
  2. package/messages/en.json +1 -0
  3. package/messages/he.json +1 -0
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/next.config.ts +68 -69
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +91 -87
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +120 -97
  8. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -4
  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 +37 -28
  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 +226 -226
  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 +7 -7
  23. package/templates/nextjs/base/src/components/shared/price-display.tsx +2 -6
  24. package/templates/nextjs/base/src/lib/resolve-currency.ts +20 -0
  25. package/templates/nextjs/base/src/lib/use-currency.ts +19 -0
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
2
2
  import { notFound } from 'next/navigation';
3
3
  import { getProductPriceInfo } from 'brainerce';
4
4
  import { getServerClient, fetchStoreInfo } from '@/lib/brainerce';
5
+ import { resolveCurrency } from '@/lib/resolve-currency';
5
6
  import { buildMetaDescription } from '@/lib/seo';
6
7
  import { decodeSlug } from '@/lib/utils';
7
8
  import { ProductJsonLd } from '@/components/seo/product-json-ld';
@@ -40,7 +41,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
40
41
  // here (the previous bug) causes price-zero link cards. Use the real
41
42
  // effective price from the SDK helper.
42
43
  const priceInfo = getProductPriceInfo(product);
43
- const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
44
+ const currency = resolveCurrency(storeInfo);
44
45
  const priceAmount = priceInfo.price > 0 ? priceInfo.price.toFixed(2) : null;
45
46
  const inStock = product.inventory?.canPurchase !== false;
46
47
  const brandName = (product as { brand?: { name?: string } | null }).brand?.name;
@@ -102,10 +103,10 @@ export default async function ProductDetailPage({ params }: Props) {
102
103
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
103
104
  const productUrl = `${baseUrl}/products/${slug}`;
104
105
  // 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.
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.
107
108
  const storeInfo = await fetchStoreInfo(locale);
108
- const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
109
+ const currency = resolveCurrency(storeInfo);
109
110
 
110
111
  return (
111
112
  <>
@@ -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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_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 || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
22
+ const currency = useCurrency();
24
23
 
25
24
  // Before address is set
26
25
  if (!addressSet) {
@@ -32,34 +31,44 @@ export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: T
32
31
  );
33
32
  }
34
33
 
35
- // After address, show tax amount
36
- const tax = taxAmount ? parseFloat(taxAmount) : 0;
34
+ // Inclusive-pricing stores back the tax out of the displayed price, so
35
+ // `checkout.taxAmount` is stored as 0 and the real VAT lives on the
36
+ // aggregated breakdown. Surface whichever is present.
37
+ const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
38
+ const tax =
39
+ explicitTax > 0
40
+ ? explicitTax
41
+ : typeof taxBreakdown?.totalTax === 'number'
42
+ ? taxBreakdown.totalTax
43
+ : 0;
37
44
 
38
- return (
39
- <div className={cn('space-y-1', className)}>
40
- <div className="flex items-center justify-between text-sm">
41
- <span className="text-muted-foreground">{tc('tax')}</span>
42
- <span className="text-foreground font-medium">
43
- {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
44
- </span>
45
+ // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
46
+ // Otherwise show a single aggregated tax line.
47
+ const hasBreakdown = !!taxBreakdown?.breakdown && taxBreakdown.breakdown.length > 0;
48
+
49
+ if (hasBreakdown) {
50
+ return (
51
+ <div className={cn('space-y-1', className)}>
52
+ {taxBreakdown!.breakdown.map((item, index) => (
53
+ <div key={index} className="flex items-center justify-between text-sm">
54
+ <span className="text-muted-foreground">
55
+ {item.name} ({(item.rate * 100).toFixed(1)}%)
56
+ </span>
57
+ <span className="text-foreground font-medium">
58
+ {formatPrice(item.amount, { currency }) as string}
59
+ </span>
60
+ </div>
61
+ ))}
45
62
  </div>
63
+ );
64
+ }
46
65
 
47
- {/* Tax breakdown details */}
48
- {taxBreakdown && taxBreakdown.breakdown?.length > 0 && tax > 0 && (
49
- <div className="space-y-0.5 ps-4">
50
- {taxBreakdown.breakdown.map((item, index) => (
51
- <div
52
- key={index}
53
- className="text-muted-foreground flex items-center justify-between text-xs"
54
- >
55
- <span>
56
- {item.name} ({(item.rate * 100).toFixed(1)}%)
57
- </span>
58
- <span>{formatPrice(item.amount, { currency }) as string}</span>
59
- </div>
60
- ))}
61
- </div>
62
- )}
66
+ return (
67
+ <div className={cn('flex items-center justify-between text-sm', className)}>
68
+ <span className="text-muted-foreground">{tc('tax')}</span>
69
+ <span className="text-foreground font-medium">
70
+ {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
71
+ </span>
63
72
  </div>
64
73
  );
65
74
  }
@@ -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 || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
105
-
106
107
  const currentPrice = getEffectivePrice(currentProduct);
107
108
  const currentImage = currentProduct.images?.[0];
108
109
  const currentImageUrl = currentImage