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,196 +1,202 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { Product, ProductRecommendation } from 'brainerce';
6
- import { formatPrice } from 'brainerce';
7
- import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
- import { useCurrency } from '@/core/lib/use-currency';
9
- import { useTranslations } from '@/core/lib/translations';
10
- import { cn } from '@/core/lib/utils';
11
- import { IconPlus, IconBag } from '@/ui/shared/icons';
12
-
13
- interface FrequentlyBoughtTogetherProps {
14
- items: ProductRecommendation[];
15
- currentProduct: Product;
16
- className?: string;
17
- }
18
-
19
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
20
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
21
- const base = parseFloat(item.basePrice);
22
- return sale != null && sale < base ? sale : base;
23
- }
24
-
25
- function ProductThumb({
26
- name,
27
- imageUrl,
28
- price,
29
- currency,
30
- checked,
31
- onToggle,
32
- disabled,
33
- }: {
34
- name: string;
35
- imageUrl: string | null;
36
- price: number;
37
- currency: string;
38
- checked: boolean;
39
- onToggle?: () => void;
40
- disabled?: boolean;
41
- }) {
42
- return (
43
- <label
44
- className={cn(
45
- 'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
46
- checked ? 'border-primary' : 'border-border',
47
- disabled && 'cursor-default'
48
- )}
49
- >
50
- {/* `relative` + fixed box are layout-critical for next/image fill */}
51
- <span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
52
- {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" /> : null}
53
- {onToggle && (
54
- <input
55
- type="checkbox"
56
- checked={checked}
57
- onChange={onToggle}
58
- disabled={disabled}
59
- className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
60
- aria-label={name}
61
- />
62
- )}
63
- </span>
64
- <span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
65
- {name}
66
- </span>
67
- <span className="text-sm font-semibold text-foreground">
68
- {formatPrice(price, { currency }) as string}
69
- </span>
70
- </label>
71
- );
72
- }
73
-
74
- /**
75
- * "Frequently bought together" bundle on the product page: current product +
76
- * selectable cross-sells + combined total + add-all button; items come from
77
- * useProductPage().recommendations.crossSells, the feature gate from
78
- * useStoreInfo().upsell.
79
- */
80
- export function FrequentlyBoughtTogether({
81
- items,
82
- currentProduct,
83
- className,
84
- }: FrequentlyBoughtTogetherProps) {
85
- // Hooks must be called unconditionally and in the same order on every
86
- // render — keep all of them above any early `return null` branch.
87
- const { storeInfo } = useStoreInfo();
88
- const { refreshCart } = useCart();
89
- const t = useTranslations('productDetail');
90
- const currency = useCurrency();
91
-
92
- // Only show up to 3 cross-sells
93
- const crossSells = items.slice(0, 3);
94
-
95
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
96
- const [adding, setAdding] = useState(false);
97
-
98
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
99
- if (crossSells.length === 0) return null;
100
-
101
- const currentPrice = getEffectivePrice(currentProduct);
102
- const currentImage = currentProduct.images?.[0];
103
- const currentImageUrl = currentImage
104
- ? typeof currentImage === 'string'
105
- ? currentImage
106
- : currentImage.url
107
- : null;
108
-
109
- const totalPrice = crossSells
110
- .filter((item) => selected.has(item.id))
111
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
112
-
113
- const toggleItem = (id: string) => {
114
- setSelected((prev) => {
115
- const next = new Set(prev);
116
- if (next.has(id)) {
117
- next.delete(id);
118
- } else {
119
- next.add(id);
120
- }
121
- return next;
122
- });
123
- };
124
-
125
- async function handleAddAll() {
126
- if (adding || selected.size === 0) return;
127
- try {
128
- setAdding(true);
129
- const { getClient } = await import('@/core/lib/brainerce');
130
- const client = getClient();
131
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
132
- for (const item of selectedItems) {
133
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
134
- }
135
- await refreshCart();
136
- } catch (err) {
137
- console.error('Failed to add items to cart:', err);
138
- } finally {
139
- setAdding(false);
140
- }
141
- }
142
-
143
- return (
144
- <section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
145
- <h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
146
-
147
- <div className="flex flex-wrap items-center gap-3">
148
- {/* Current product (always included, no checkbox) */}
149
- <ProductThumb
150
- name={currentProduct.name}
151
- imageUrl={currentImageUrl}
152
- price={currentPrice}
153
- currency={currency}
154
- checked={true}
155
- disabled
156
- />
157
-
158
- {crossSells.map((item) => {
159
- const img = item.images?.[0];
160
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
161
- return (
162
- <div key={item.id} className="flex items-center gap-3">
163
- <span aria-hidden="true" className="text-muted-foreground">
164
- <IconPlus size={20} />
165
- </span>
166
- <ProductThumb
167
- name={item.name}
168
- imageUrl={imgUrl}
169
- price={getEffectivePrice(item)}
170
- currency={currency}
171
- checked={selected.has(item.id)}
172
- onToggle={() => toggleItem(item.id)}
173
- />
174
- </div>
175
- );
176
- })}
177
- </div>
178
-
179
- {/* Total + Add button */}
180
- <div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
181
- <span className="text-lg font-semibold text-foreground">
182
- {t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
183
- </span>
184
- <button
185
- type="button"
186
- onClick={handleAddAll}
187
- disabled={adding || selected.size === 0}
188
- className="btn-primary btn-md"
189
- >
190
- <IconBag size={18} />
191
- {adding ? t('addingAll') : t('addSelectedToCart')}
192
- </button>
193
- </div>
194
- </section>
195
- );
196
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
+ import { useCurrency } from '@/core/lib/use-currency';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { cn } from '@/core/lib/utils';
11
+ import { IconPlus, IconBag } from '@/ui/shared/icons';
12
+
13
+ interface FrequentlyBoughtTogetherProps {
14
+ items: ProductRecommendation[];
15
+ currentProduct: Product;
16
+ className?: string;
17
+ }
18
+
19
+ // STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
20
+ // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
21
+ // so there is nothing to convert them to. Converting only `currentProduct`
22
+ // (which is a full Product and does carry FX fields) would add a euro amount to
23
+ // two dollar amounts and print the result as one total. One currency wins, and
24
+ // it has to be the one the cart actually charges.
25
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
26
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
27
+ const base = parseFloat(item.basePrice);
28
+ return sale != null && sale < base ? sale : base;
29
+ }
30
+
31
+ function ProductThumb({
32
+ name,
33
+ imageUrl,
34
+ price,
35
+ currency,
36
+ checked,
37
+ onToggle,
38
+ disabled,
39
+ }: {
40
+ name: string;
41
+ imageUrl: string | null;
42
+ price: number;
43
+ currency: string;
44
+ checked: boolean;
45
+ onToggle?: () => void;
46
+ disabled?: boolean;
47
+ }) {
48
+ return (
49
+ <label
50
+ className={cn(
51
+ 'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
52
+ checked ? 'border-primary' : 'border-border',
53
+ disabled && 'cursor-default'
54
+ )}
55
+ >
56
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
57
+ <span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
58
+ {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" /> : null}
59
+ {onToggle && (
60
+ <input
61
+ type="checkbox"
62
+ checked={checked}
63
+ onChange={onToggle}
64
+ disabled={disabled}
65
+ className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
66
+ aria-label={name}
67
+ />
68
+ )}
69
+ </span>
70
+ <span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
71
+ {name}
72
+ </span>
73
+ <span className="text-sm font-semibold text-foreground">
74
+ {formatPrice(price, { currency }) as string}
75
+ </span>
76
+ </label>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * "Frequently bought together" bundle on the product page: current product +
82
+ * selectable cross-sells + combined total + add-all button; items come from
83
+ * useProductPage().recommendations.crossSells, the feature gate from
84
+ * useStoreInfo().upsell.
85
+ */
86
+ export function FrequentlyBoughtTogether({
87
+ items,
88
+ currentProduct,
89
+ className,
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.
93
+ const { storeInfo } = useStoreInfo();
94
+ const { refreshCart } = useCart();
95
+ const t = useTranslations('productDetail');
96
+ const currency = useCurrency();
97
+
98
+ // Only show up to 3 cross-sells
99
+ const crossSells = items.slice(0, 3);
100
+
101
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
102
+ const [adding, setAdding] = useState(false);
103
+
104
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
105
+ if (crossSells.length === 0) return null;
106
+
107
+ const currentPrice = getEffectivePrice(currentProduct);
108
+ const currentImage = currentProduct.images?.[0];
109
+ const currentImageUrl = currentImage
110
+ ? typeof currentImage === 'string'
111
+ ? currentImage
112
+ : currentImage.url
113
+ : null;
114
+
115
+ const totalPrice = crossSells
116
+ .filter((item) => selected.has(item.id))
117
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
118
+
119
+ const toggleItem = (id: string) => {
120
+ setSelected((prev) => {
121
+ const next = new Set(prev);
122
+ if (next.has(id)) {
123
+ next.delete(id);
124
+ } else {
125
+ next.add(id);
126
+ }
127
+ return next;
128
+ });
129
+ };
130
+
131
+ async function handleAddAll() {
132
+ if (adding || selected.size === 0) return;
133
+ try {
134
+ setAdding(true);
135
+ const { getClient } = await import('@/core/lib/brainerce');
136
+ const client = getClient();
137
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
138
+ for (const item of selectedItems) {
139
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
140
+ }
141
+ await refreshCart();
142
+ } catch (err) {
143
+ console.error('Failed to add items to cart:', err);
144
+ } finally {
145
+ setAdding(false);
146
+ }
147
+ }
148
+
149
+ return (
150
+ <section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
151
+ <h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
152
+
153
+ <div className="flex flex-wrap items-center gap-3">
154
+ {/* Current product (always included, no checkbox) */}
155
+ <ProductThumb
156
+ name={currentProduct.name}
157
+ imageUrl={currentImageUrl}
158
+ price={currentPrice}
159
+ currency={currency}
160
+ checked={true}
161
+ disabled
162
+ />
163
+
164
+ {crossSells.map((item) => {
165
+ const img = item.images?.[0];
166
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
167
+ return (
168
+ <div key={item.id} className="flex items-center gap-3">
169
+ <span aria-hidden="true" className="text-muted-foreground">
170
+ <IconPlus size={20} />
171
+ </span>
172
+ <ProductThumb
173
+ name={item.name}
174
+ imageUrl={imgUrl}
175
+ price={getEffectivePrice(item)}
176
+ currency={currency}
177
+ checked={selected.has(item.id)}
178
+ onToggle={() => toggleItem(item.id)}
179
+ />
180
+ </div>
181
+ );
182
+ })}
183
+ </div>
184
+
185
+ {/* Total + Add button */}
186
+ <div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
187
+ <span className="text-lg font-semibold text-foreground">
188
+ {t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
189
+ </span>
190
+ <button
191
+ type="button"
192
+ onClick={handleAddAll}
193
+ disabled={adding || selected.size === 0}
194
+ className="btn-primary btn-md"
195
+ >
196
+ <IconBag size={18} />
197
+ {adding ? t('addingAll') : t('addSelectedToCart')}
198
+ </button>
199
+ </div>
200
+ </section>
201
+ );
202
+ }
@@ -4,7 +4,8 @@ import { useState, useRef } from 'react';
4
4
  import { Link, useRouter } from '@/core/lib/navigation';
5
5
  import { CdnImage as Image } from '@/ui/shared/cdn-image';
6
6
  import type { Product } from 'brainerce';
7
- import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
7
+ import { getProductPriceInfo, formatPrice } from 'brainerce';
8
+ import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price';
8
9
  import { useTranslations } from '@/core/lib/translations';
9
10
  import { PriceDisplay } from '@/ui/product/price-display';
10
11
  import { StockBadge } from '@/ui/product/stock-badge';
@@ -15,64 +16,21 @@ import { openCartDrawer } from '@/ui/cart/cart-drawer';
15
16
  import { useCurrency } from '@/core/lib/use-currency';
16
17
  import { trackAddToCart } from '@/core/lib/tracking';
17
18
  import { cn } from '@/core/lib/utils';
18
- import { IconBag, IconCheck, IconDownload } from '@/ui/shared/icons';
19
+ import { IconBag, IconCheck, IconDownload, IconStar } from '@/ui/shared/icons';
19
20
 
20
21
  interface ProductCardProps {
21
22
  product: Product;
22
23
  className?: string;
23
24
  }
24
25
 
25
- /**
26
- * Pick the price + currency to render for a given product (PRD §23 FX overlay).
27
- *
28
- * When the storefront passed `regionId` to `getProducts({ regionId })` AND the
29
- * region currency differs from the store currency, the backend attaches
30
- * additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
31
- * to the canonical store-currency `basePrice` / `salePrice`. Either way the
32
- * cart still charges in the store currency — this only affects display.
33
- */
34
- function pickDisplayPrice(
35
- product: Product,
36
- fallbackCurrency: string
37
- ): { price: number | undefined; salePrice: number | null; currency: string } {
38
- if (product.displayPrice != null && product.displayCurrency) {
39
- const sale = product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
40
- return {
41
- // `displayPrice` is the base price converted to the region currency —
42
- // it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
43
- price: parseFloat(product.displayPrice),
44
- salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
45
- currency: product.displayCurrency,
46
- };
47
- }
48
- // Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
49
- // charged amount (= salePrice when on sale, base otherwise); `originalPrice`
50
- // is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
51
- const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
52
- return {
53
- price: originalPrice,
54
- salePrice: isOnSale ? effective : null,
55
- currency: fallbackCurrency,
56
- };
57
- }
58
-
59
26
  function VariantPriceRange({ product }: { product: Product }) {
60
- const currency = useCurrency();
61
-
62
- let min: number;
63
- let max: number;
64
- if (product.priceMin && product.priceMax) {
65
- min = parseFloat(product.priceMin);
66
- max = parseFloat(product.priceMax);
67
- } else {
68
- const variants = product.variants ?? [];
69
- if (variants.length === 0) return null;
70
- const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
71
- min = Math.min(...prices);
72
- max = Math.max(...prices);
73
- }
74
-
75
- if (isNaN(min) || isNaN(max)) return null;
27
+ const fallbackCurrency = useCurrency();
28
+ // ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the
29
+ // store currency while the single-price card beside it converts, so a
30
+ // variable product would quote a different currency from its neighbours.
31
+ const range = pickDisplayPriceRange(product, fallbackCurrency);
32
+ if (!range) return null;
33
+ const { min, max, currency } = range;
76
34
 
77
35
  return (
78
36
  <span className="text-foreground text-base font-semibold">
@@ -87,6 +45,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
87
45
  const t = useTranslations('common');
88
46
  const tp = useTranslations('productDetail');
89
47
  const tProd = useTranslations('products');
48
+ const tr = useTranslations('reviews');
90
49
  const router = useRouter();
91
50
  const { refreshCart } = useCart();
92
51
  const fallbackCurrency = useCurrency();
@@ -214,6 +173,22 @@ export function ProductCard({ product, className }: ProductCardProps) {
214
173
  </Link>
215
174
  </h3>
216
175
 
176
+ {/* Star rating. `avgRating` / `reviewCount` are denormalized rollups
177
+ the API returns on every product fetch. Auto-hides on a store with
178
+ no reviews yet: `reviewCount` is 0 (or absent) and the whole block
179
+ is skipped, so nothing renders and the card keeps its layout. */}
180
+ {product.reviewCount ? (
181
+ <div className="flex items-center gap-1" aria-label={tr('starRating')}>
182
+ <span aria-hidden="true" className="text-primary">
183
+ <IconStar size={16} />
184
+ </span>
185
+ <span className="text-foreground text-xs font-semibold">
186
+ {(product.avgRating ?? 0).toFixed(1)}
187
+ </span>
188
+ <span className="text-muted-foreground text-xs">({product.reviewCount})</span>
189
+ </div>
190
+ ) : null}
191
+
217
192
  {/* Price */}
218
193
  <div>
219
194
  {isVariable ? (
@@ -7,6 +7,7 @@ import { useProductPage } from '@/core/hooks/use-product-page';
7
7
  import { PriceDisplay } from '@/ui/product/price-display';
8
8
  import { VariantSelector } from '@/ui/product/variant-selector';
9
9
  import { StockBadge } from '@/ui/product/stock-badge';
10
+ import { DiscountBadge } from '@/ui/product/discount-badge';
10
11
  import { BackInStockForm, canOfferStockAlert } from '@/ui/product/back-in-stock-form';
11
12
  import { RecommendationSection } from '@/ui/product/recommendation-section';
12
13
  import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
@@ -138,6 +139,7 @@ export function ProductClientSection({
138
139
  selectedVariant,
139
140
  setSelectedVariant,
140
141
  priceInfo,
142
+ displayPrice,
141
143
  inventory,
142
144
  canPurchase,
143
145
  description,
@@ -262,17 +264,24 @@ export function ProductClientSection({
262
264
  {/* Price + stock */}
263
265
  <div className="mt-5 flex flex-wrap items-center gap-4 border-b pb-6">
264
266
  <PriceDisplay
265
- price={priceInfo.originalPrice}
266
- salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
267
+ price={displayPrice.price}
268
+ salePrice={displayPrice.salePrice ?? undefined}
269
+ currency={displayPrice.currency}
267
270
  size="lg"
268
271
  />
272
+ {/* Active discount rule on this product, as a badge.
273
+ `product.discount` is returned by getProductBySlug and is
274
+ null/absent when no rule applies, and DiscountBadge returns
275
+ null on a falsy `discount`, so this renders nothing on a store
276
+ with no discounts configured. */}
277
+ <DiscountBadge discount={product.discount} />
269
278
  {product.isDownloadable ? (
270
279
  <span className="badge-soft">
271
280
  <IconDownload size={16} />
272
281
  {t('instantDownload')}
273
282
  </span>
274
283
  ) : (
275
- <StockBadge inventory={inventory} lowStockThreshold={5} />
284
+ <StockBadge inventory={inventory} />
276
285
  )}
277
286
  </div>
278
287