create-brainerce-store 1.72.0 → 1.74.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 (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,191 +1,197 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { Image as ImageIcon } from 'lucide-react';
5
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
- import type { Product, ProductRecommendation } from 'brainerce';
7
- import { formatPrice } from 'brainerce';
8
- import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
- import { useCurrency } from '@/core/lib/use-currency';
10
- import { useTranslations } from '@/core/lib/translations';
11
- import { Button } from '@/components/ui/button';
12
- import { Card } from '@/components/ui/card';
13
- import { Checkbox } from '@/components/ui/checkbox';
14
- import { cn } from '@/core/lib/utils';
15
-
16
- interface FrequentlyBoughtTogetherProps {
17
- items: ProductRecommendation[];
18
- currentProduct: Product;
19
- className?: string;
20
- }
21
-
22
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
23
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
24
- const base = parseFloat(item.basePrice);
25
- return sale != null && sale < base ? sale : base;
26
- }
27
-
28
- function ProductThumb({
29
- name,
30
- imageUrl,
31
- price,
32
- currency,
33
- checked,
34
- onToggle,
35
- disabled,
36
- }: {
37
- name: string;
38
- imageUrl: string | null;
39
- price: number;
40
- currency: string;
41
- checked: boolean;
42
- onToggle?: () => void;
43
- disabled?: boolean;
44
- }) {
45
- return (
46
- <label
47
- className={cn(
48
- 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
49
- checked ? 'ring-primary ring-2' : 'opacity-60',
50
- disabled && 'pointer-events-none'
51
- )}
52
- >
53
- {onToggle && (
54
- <Checkbox
55
- checked={checked}
56
- onCheckedChange={onToggle}
57
- className="absolute start-2 top-2"
58
- />
59
- )}
60
- <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
61
- {imageUrl ? (
62
- <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
63
- ) : (
64
- <div className="flex h-full w-full items-center justify-center">
65
- <ImageIcon
66
- className="text-muted-foreground h-8 w-8"
67
- strokeWidth={1.5}
68
- aria-hidden="true"
69
- />
70
- </div>
71
- )}
72
- </div>
73
- <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
74
- <span className="text-muted-foreground mt-1 text-xs">
75
- {formatPrice(price, { currency }) as string}
76
- </span>
77
- </label>
78
- );
79
- }
80
-
81
- export function FrequentlyBoughtTogether({
82
- items,
83
- currentProduct,
84
- className,
85
- }: FrequentlyBoughtTogetherProps) {
86
- // Hooks must be called unconditionally and in the same order on every
87
- // render — keep all of them above any early `return null` branch.
88
- const { storeInfo } = useStoreInfo();
89
- const { refreshCart } = useCart();
90
- const t = useTranslations('productDetail');
91
- const currency = useCurrency();
92
-
93
- // Only show up to 3 cross-sells
94
- const crossSells = items.slice(0, 3);
95
-
96
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
97
- const [adding, setAdding] = useState(false);
98
-
99
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
100
- if (crossSells.length === 0) return null;
101
-
102
- const currentPrice = getEffectivePrice(currentProduct);
103
- const currentImage = currentProduct.images?.[0];
104
- const currentImageUrl = currentImage
105
- ? typeof currentImage === 'string'
106
- ? currentImage
107
- : currentImage.url
108
- : null;
109
-
110
- const totalPrice = crossSells
111
- .filter((item) => selected.has(item.id))
112
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
113
-
114
- const toggleItem = (id: string) => {
115
- setSelected((prev) => {
116
- const next = new Set(prev);
117
- if (next.has(id)) {
118
- next.delete(id);
119
- } else {
120
- next.add(id);
121
- }
122
- return next;
123
- });
124
- };
125
-
126
- async function handleAddAll() {
127
- if (adding || selected.size === 0) return;
128
- try {
129
- setAdding(true);
130
- const { getClient } = await import('@/core/lib/brainerce');
131
- const client = getClient();
132
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
133
- for (const item of selectedItems) {
134
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
135
- }
136
- await refreshCart();
137
- } catch (err) {
138
- console.error('Failed to add items to cart:', err);
139
- } finally {
140
- setAdding(false);
141
- }
142
- }
143
-
144
- return (
145
- <Card className={cn('shadow-none p-6', className)}>
146
- <h2 className="text-foreground mb-4 text-xl font-semibold">
147
- {t('frequentlyBoughtTogether')}
148
- </h2>
149
-
150
- <div className="flex flex-wrap items-center gap-3">
151
- {/* Current product (always included, no checkbox) */}
152
- <ProductThumb
153
- name={currentProduct.name}
154
- imageUrl={currentImageUrl}
155
- price={currentPrice}
156
- currency={currency}
157
- checked={true}
158
- disabled
159
- />
160
-
161
- {crossSells.map((item) => {
162
- const img = item.images?.[0];
163
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
164
- return (
165
- <div key={item.id} className="flex items-center gap-3">
166
- <span className="text-muted-foreground text-lg font-light">+</span>
167
- <ProductThumb
168
- name={item.name}
169
- imageUrl={imgUrl}
170
- price={getEffectivePrice(item)}
171
- currency={currency}
172
- checked={selected.has(item.id)}
173
- onToggle={() => toggleItem(item.id)}
174
- />
175
- </div>
176
- );
177
- })}
178
- </div>
179
-
180
- {/* Total + Add button */}
181
- <div className="mt-4 flex flex-wrap items-center gap-4">
182
- <span className="text-foreground text-lg font-semibold">
183
- {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
184
- </span>
185
- <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
186
- {adding ? t('addingAll') : t('addSelectedToCart')}
187
- </Button>
188
- </div>
189
- </Card>
190
- );
191
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Image as ImageIcon } from 'lucide-react';
5
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
+ import type { Product, ProductRecommendation } from 'brainerce';
7
+ import { formatPrice } from 'brainerce';
8
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { useTranslations } from '@/core/lib/translations';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Card } from '@/components/ui/card';
13
+ import { Checkbox } from '@/components/ui/checkbox';
14
+ import { cn } from '@/core/lib/utils';
15
+
16
+ interface FrequentlyBoughtTogetherProps {
17
+ items: ProductRecommendation[];
18
+ currentProduct: Product;
19
+ className?: string;
20
+ }
21
+
22
+ // STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
23
+ // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
24
+ // so there is nothing to convert them to. Converting only `currentProduct`
25
+ // (which is a full Product and does carry FX fields) would add a euro amount to
26
+ // two dollar amounts and print the result as one total. One currency wins, and
27
+ // it has to be the one the cart actually charges.
28
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
29
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
30
+ const base = parseFloat(item.basePrice);
31
+ return sale != null && sale < base ? sale : base;
32
+ }
33
+
34
+ function ProductThumb({
35
+ name,
36
+ imageUrl,
37
+ price,
38
+ currency,
39
+ checked,
40
+ onToggle,
41
+ disabled,
42
+ }: {
43
+ name: string;
44
+ imageUrl: string | null;
45
+ price: number;
46
+ currency: string;
47
+ checked: boolean;
48
+ onToggle?: () => void;
49
+ disabled?: boolean;
50
+ }) {
51
+ return (
52
+ <label
53
+ className={cn(
54
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
55
+ checked ? 'ring-primary ring-2' : 'opacity-60',
56
+ disabled && 'pointer-events-none'
57
+ )}
58
+ >
59
+ {onToggle && (
60
+ <Checkbox
61
+ checked={checked}
62
+ onCheckedChange={onToggle}
63
+ className="absolute start-2 top-2"
64
+ />
65
+ )}
66
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
67
+ {imageUrl ? (
68
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
69
+ ) : (
70
+ <div className="flex h-full w-full items-center justify-center">
71
+ <ImageIcon
72
+ className="text-muted-foreground h-8 w-8"
73
+ strokeWidth={1.5}
74
+ aria-hidden="true"
75
+ />
76
+ </div>
77
+ )}
78
+ </div>
79
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
80
+ <span className="text-muted-foreground mt-1 text-xs">
81
+ {formatPrice(price, { currency }) as string}
82
+ </span>
83
+ </label>
84
+ );
85
+ }
86
+
87
+ export function FrequentlyBoughtTogether({
88
+ items,
89
+ currentProduct,
90
+ className,
91
+ }: FrequentlyBoughtTogetherProps) {
92
+ // Hooks must be called unconditionally and in the same order on every
93
+ // render keep all of them above any early `return null` branch.
94
+ const { storeInfo } = useStoreInfo();
95
+ const { refreshCart } = useCart();
96
+ const t = useTranslations('productDetail');
97
+ const currency = useCurrency();
98
+
99
+ // Only show up to 3 cross-sells
100
+ const crossSells = items.slice(0, 3);
101
+
102
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
103
+ const [adding, setAdding] = useState(false);
104
+
105
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
106
+ if (crossSells.length === 0) return null;
107
+
108
+ const currentPrice = getEffectivePrice(currentProduct);
109
+ const currentImage = currentProduct.images?.[0];
110
+ const currentImageUrl = currentImage
111
+ ? typeof currentImage === 'string'
112
+ ? currentImage
113
+ : currentImage.url
114
+ : null;
115
+
116
+ const totalPrice = crossSells
117
+ .filter((item) => selected.has(item.id))
118
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
119
+
120
+ const toggleItem = (id: string) => {
121
+ setSelected((prev) => {
122
+ const next = new Set(prev);
123
+ if (next.has(id)) {
124
+ next.delete(id);
125
+ } else {
126
+ next.add(id);
127
+ }
128
+ return next;
129
+ });
130
+ };
131
+
132
+ async function handleAddAll() {
133
+ if (adding || selected.size === 0) return;
134
+ try {
135
+ setAdding(true);
136
+ const { getClient } = await import('@/core/lib/brainerce');
137
+ const client = getClient();
138
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
139
+ for (const item of selectedItems) {
140
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
141
+ }
142
+ await refreshCart();
143
+ } catch (err) {
144
+ console.error('Failed to add items to cart:', err);
145
+ } finally {
146
+ setAdding(false);
147
+ }
148
+ }
149
+
150
+ return (
151
+ <Card className={cn('shadow-none p-6', className)}>
152
+ <h2 className="text-foreground mb-4 text-xl font-semibold">
153
+ {t('frequentlyBoughtTogether')}
154
+ </h2>
155
+
156
+ <div className="flex flex-wrap items-center gap-3">
157
+ {/* Current product (always included, no checkbox) */}
158
+ <ProductThumb
159
+ name={currentProduct.name}
160
+ imageUrl={currentImageUrl}
161
+ price={currentPrice}
162
+ currency={currency}
163
+ checked={true}
164
+ disabled
165
+ />
166
+
167
+ {crossSells.map((item) => {
168
+ const img = item.images?.[0];
169
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
170
+ return (
171
+ <div key={item.id} className="flex items-center gap-3">
172
+ <span className="text-muted-foreground text-lg font-light">+</span>
173
+ <ProductThumb
174
+ name={item.name}
175
+ imageUrl={imgUrl}
176
+ price={getEffectivePrice(item)}
177
+ currency={currency}
178
+ checked={selected.has(item.id)}
179
+ onToggle={() => toggleItem(item.id)}
180
+ />
181
+ </div>
182
+ );
183
+ })}
184
+ </div>
185
+
186
+ {/* Total + Add button */}
187
+ <div className="mt-4 flex flex-wrap items-center gap-4">
188
+ <span className="text-foreground text-lg font-semibold">
189
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
190
+ </span>
191
+ <Button onClick={handleAddAll} disabled={adding || selected.size === 0} className="rounded px-5">
192
+ {adding ? t('addingAll') : t('addSelectedToCart')}
193
+ </Button>
194
+ </div>
195
+ </Card>
196
+ );
197
+ }
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import { Check, Image as ImageIcon, Plus, ShoppingCart } from 'lucide-react';
4
+ import { Check, Image as ImageIcon, Plus, ShoppingCart, Star } from 'lucide-react';
5
5
  import { Link, useRouter } from '@/core/lib/navigation';
6
6
  import { CdnImage as Image } from '@/ui/shared/cdn-image';
7
7
  import type { Product } from 'brainerce';
8
- import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
8
+ import { getProductPriceInfo, formatPrice } from 'brainerce';
9
+ import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
9
10
  import { useTranslations } from '@/core/lib/translations';
10
11
  import { PriceDisplay } from '@/ui/product/price-display';
11
12
  import { StockBadge } from '@/ui/product/stock-badge';
@@ -23,57 +24,14 @@ interface ProductCardProps {
23
24
  className?: string;
24
25
  }
25
26
 
26
- /**
27
- * Pick the price + currency to render for a given product (PRD §23 FX overlay).
28
- *
29
- * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
30
- * region currency differs from the store currency, the backend attaches
31
- * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
32
- * to the canonical store-currency `basePrice` / `salePrice`. Either way the
33
- * cart still charges in the store currency — this only affects display.
34
- */
35
- function pickDisplayPrice(
36
- product: Product,
37
- fallbackCurrency: string
38
- ): { price: number | undefined; salePrice: number | null; currency: string } {
39
- if (product.displayPrice != null && product.displayCurrency) {
40
- const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
41
- return {
42
- // `displayPrice` is the base price converted to the region currency —
43
- // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
44
- price: parseFloat(product.displayPrice),
45
- salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
46
- currency: product.displayCurrency,
47
- };
48
- }
49
- // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
50
- // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
51
- // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
52
- const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
53
- return {
54
- price: originalPrice,
55
- salePrice: isOnSale ? effective : null,
56
- currency: fallbackCurrency,
57
- };
58
- }
59
-
60
27
  function VariantPriceRange({ product }: { product: Product }) {
61
- const currency = useCurrency();
62
-
63
- let min: number;
64
- let max: number;
65
- if (product.priceMin && product.priceMax) {
66
- min = parseFloat(product.priceMin);
67
- max = parseFloat(product.priceMax);
68
- } else {
69
- const variants = product.variants ?? [];
70
- if (variants.length === 0) return null;
71
- const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
72
- min = Math.min(...prices);
73
- max = Math.max(...prices);
74
- }
75
-
76
- if (isNaN(min) || isNaN(max)) return null;
28
+ const fallbackCurrency = useCurrency();
29
+ // ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
30
+ // store currency while the single-price card beside it converts, so a
31
+ // variable product would quote a different currency from its neighbours.
32
+ const range = pickDisplayPriceRange(product, fallbackCurrency);
33
+ if (!range) return null;
34
+ const { min, max, currency } = range;
77
35
 
78
36
  return (
79
37
  <span className="text-foreground text-sm font-medium">
@@ -88,6 +46,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
88
46
  const t = useTranslations('common');
89
47
  const tp = useTranslations('productDetail');
90
48
  const tProd = useTranslations('products');
49
+ const tr = useTranslations('reviews');
91
50
  const router = useRouter();
92
51
  const { refreshCart } = useCart();
93
52
  const fallbackCurrency = useCurrency();
@@ -223,6 +182,20 @@ export function ProductCard({ product, className }: ProductCardProps) {
223
182
  </h3>
224
183
  </Link>
225
184
 
185
+ {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
186
+ the API returns on every product fetch. Auto-hides on a store with
187
+ no reviews yet: `reviewCount` is 0 (or absent) and the whole block
188
+ is skipped, so nothing renders and the card keeps its layout. */}
189
+ {product.reviewCount ? (
190
+ <div className="flex items-center gap-1" aria-label={tr('starRating')}>
191
+ <Star className="h-3.5 w-3.5 fill-amber-400 text-amber-400" aria-hidden="true" />
192
+ <span className="text-foreground text-xs font-medium">
193
+ {(product.avgRating ?? 0).toFixed(1)}
194
+ </span>
195
+ <span className="text-muted-foreground text-xs">({product.reviewCount})</span>
196
+ </div>
197
+ ) : null}
198
+
226
199
  {/* Price */}
227
200
  {isVariable ? (
228
201
  <VariantPriceRange product={product} />
@@ -8,6 +8,7 @@ import { PriceDisplay } from '@/ui/product/price-display';
8
8
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
9
9
  import { VariantSelector } from '@/ui/product/variant-selector';
10
10
  import { StockBadge } from '@/ui/product/stock-badge';
11
+ import { DiscountBadge } from '@/ui/product/discount-badge';
11
12
  import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
12
13
  import { RecommendationSection } from '@/ui/product/recommendation-section';
13
14
  import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
@@ -132,6 +133,7 @@ export function ProductClientSection({
132
133
  selectedVariant,
133
134
  setSelectedVariant,
134
135
  priceInfo,
136
+ displayPrice,
135
137
  inventory,
136
138
  canPurchase,
137
139
  description,
@@ -253,10 +255,17 @@ export function ProductClientSection({
253
255
  </div>
254
256
  )}
255
257
 
258
+ {/* Active discount rule on this product, as a badge. `product.discount`
259
+ is returned by getProductBySlug and is null/absent when no rule
260
+ applies, and DiscountBadge returns null on a falsy `discount`, so
261
+ this renders nothing on a store with no discounts configured. */}
262
+ <DiscountBadge discount={product.discount} className="w-fit" />
263
+
256
264
  {/* Price */}
257
265
  <PriceDisplay
258
- price={priceInfo.originalPrice}
259
- salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
266
+ price={displayPrice.price}
267
+ salePrice={displayPrice.salePrice ?? undefined}
268
+ currency={displayPrice.currency}
260
269
  size="lg"
261
270
  />
262
271
 
@@ -270,7 +279,7 @@ export function ProductClientSection({
270
279
  {t('instantDownload')}
271
280
  </Badge>
272
281
  ) : (
273
- <StockBadge inventory={inventory} lowStockThreshold={5} />
282
+ <StockBadge inventory={inventory} />
274
283
  )}
275
284
 
276
285
  {/* Downloadable files info */}