create-brainerce-store 1.84.0 → 1.86.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 (52) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +9 -2
  4. package/messages/he.json +9 -2
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
  15. package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
  16. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  17. package/templates/nextjs/base/src/app/icon.svg +11 -0
  18. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  19. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  20. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  21. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  22. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
  23. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  24. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +130 -12
  25. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  26. package/templates/nextjs/base/src/core/lib/tracking.ts +25 -14
  27. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  28. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  29. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  30. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  31. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  32. package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
  33. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
  34. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  35. package/templates/nextjs/designs/atelier/globals.css +408 -402
  36. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  37. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  38. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  39. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  40. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  41. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  42. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  43. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  44. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  45. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  46. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
  47. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  48. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +560 -553
  49. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  50. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
  51. package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
  52. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
@@ -1,227 +1,232 @@
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
11
- import { cn } from '@/core/lib/utils';
12
- import { IconPlus, IconBag } from '@/ui/shared/icons';
13
-
14
- interface FrequentlyBoughtTogetherProps {
15
- items: ProductRecommendation[];
16
- currentProduct: Product;
17
- className?: string;
18
- }
19
-
20
- // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
21
- // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
22
- // so there is nothing to convert them to. Converting only `currentProduct`
23
- // (which is a full Product and does carry FX fields) would add a euro amount to
24
- // two dollar amounts and print the result as one total. One currency wins, and
25
- // it has to be the one the cart actually charges.
26
- function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
27
- const sale = item.salePrice ? parseFloat(item.salePrice) : null;
28
- const base = parseFloat(item.basePrice);
29
- return sale != null && sale < base ? sale : base;
30
- }
31
-
32
- function ProductThumb({
33
- name,
34
- imageUrl,
35
- price,
36
- currency,
37
- checked,
38
- onToggle,
39
- disabled,
40
- }: {
41
- name: string;
42
- imageUrl: string | null;
43
- price: number;
44
- currency: string;
45
- checked: boolean;
46
- onToggle?: () => void;
47
- disabled?: boolean;
48
- }) {
49
- return (
50
- <label
51
- className={cn(
52
- 'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
53
- checked ? 'border-primary' : 'border-border',
54
- disabled && 'cursor-default'
55
- )}
56
- >
57
- {/* `relative` + fixed box are layout-critical for next/image fill */}
58
- <span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
59
- {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" /> : null}
60
- {onToggle && (
61
- <input
62
- type="checkbox"
63
- checked={checked}
64
- onChange={onToggle}
65
- disabled={disabled}
66
- className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
67
- aria-label={name}
68
- />
69
- )}
70
- </span>
71
- <span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
72
- {name}
73
- </span>
74
- <span className="text-sm font-semibold text-foreground">
75
- {formatPrice(price, { currency }) as string}
76
- </span>
77
- </label>
78
- );
79
- }
80
-
81
- /**
82
- * "Frequently bought together" bundle on the product page: current product +
83
- * selectable cross-sells + combined total + add-all button; items come from
84
- * useProductPage().recommendations.crossSells, the feature gate from
85
- * useStoreInfo().upsell.
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
- // ⛔ KITs are dropped, not priced. A recommendation is a `ProductRecommendation`
100
- // — an UNRESOLVED list shape, and the recommendations endpoint does not resolve
101
- // kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every
102
- // kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one
103
- // "Total" beside a button that adds them all, so a wrong figure here is a
104
- // number the shopper commits to. Adding a kit by its own `productId` would in
105
- // fact be correct — it is the displayed price that cannot be trusted — so a
106
- // kit still sells fine from its own product page, where the API resolves it.
107
- // Only show up to 3 cross-sells.
108
- const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3);
109
-
110
- const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
111
- const [adding, setAdding] = useState(false);
112
- const [addError, setAddError] = useState<AddToCartError | null>(null);
113
-
114
- if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
115
- if (crossSells.length === 0) return null;
116
-
117
- const currentPrice = getEffectivePrice(currentProduct);
118
- const currentImage = currentProduct.images?.[0];
119
- const currentImageUrl = currentImage
120
- ? typeof currentImage === 'string'
121
- ? currentImage
122
- : currentImage.url
123
- : null;
124
-
125
- const totalPrice = crossSells
126
- .filter((item) => selected.has(item.id))
127
- .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
128
-
129
- const toggleItem = (id: string) => {
130
- setSelected((prev) => {
131
- const next = new Set(prev);
132
- if (next.has(id)) {
133
- next.delete(id);
134
- } else {
135
- next.add(id);
136
- }
137
- return next;
138
- });
139
- };
140
-
141
- async function handleAddAll() {
142
- if (adding || selected.size === 0) return;
143
- setAddError(null);
144
- try {
145
- setAdding(true);
146
- const { getClient } = await import('@/core/lib/brainerce');
147
- const client = getClient();
148
- const selectedItems = crossSells.filter((item) => selected.has(item.id));
149
- for (const item of selectedItems) {
150
- await client.smartAddToCart({ productId: item.id, quantity: 1 });
151
- }
152
- await refreshCart();
153
- } catch (err) {
154
- // The loop is not transactional: if the cart hits its 50-line cap
155
- // part-way through, the earlier items ARE in the cart and the rest are
156
- // not. Saying so beats a silent stop the shopper reads as a dead button.
157
- setAddError(toAddToCartError(err));
158
- console.error('Failed to add items to cart:', err);
159
- } finally {
160
- setAdding(false);
161
- }
162
- }
163
-
164
- return (
165
- <section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
166
- <h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
167
-
168
- <div className="flex flex-wrap items-center gap-3">
169
- {/* Current product (always included, no checkbox) */}
170
- <ProductThumb
171
- name={currentProduct.name}
172
- imageUrl={currentImageUrl}
173
- price={currentPrice}
174
- currency={currency}
175
- checked={true}
176
- disabled
177
- />
178
-
179
- {crossSells.map((item) => {
180
- const img = item.images?.[0];
181
- const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
182
- return (
183
- <div key={item.id} className="flex items-center gap-3">
184
- <span aria-hidden="true" className="text-muted-foreground">
185
- <IconPlus size={20} />
186
- </span>
187
- <ProductThumb
188
- name={item.name}
189
- imageUrl={imgUrl}
190
- price={getEffectivePrice(item)}
191
- currency={currency}
192
- checked={selected.has(item.id)}
193
- onToggle={() => toggleItem(item.id)}
194
- />
195
- </div>
196
- );
197
- })}
198
- </div>
199
-
200
- {/* Total + Add button */}
201
- <div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
202
- <span className="text-lg font-semibold text-foreground">
203
- {t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
204
- </span>
205
- <button
206
- type="button"
207
- onClick={handleAddAll}
208
- disabled={adding || selected.size === 0}
209
- className="btn-primary btn-md"
210
- >
211
- <IconBag size={18} />
212
- {adding ? t('addingAll') : t('addSelectedToCart')}
213
- </button>
214
- </div>
215
-
216
- {/*
217
- Why the add was refused. Some items may already be in the cart:
218
- the loop above adds one at a time and stops at the first refusal.
219
- */}
220
- {addError && (
221
- <p role="alert" className="text-destructive mt-3 text-sm font-medium">
222
- {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
223
- </p>
224
- )}
225
- </section>
226
- );
227
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import { NoPhotoTile } from '@/ui/shared/no-photo-tile';
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 { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
12
+ import { cn } from '@/core/lib/utils';
13
+ import { IconPlus, IconBag } from '@/ui/shared/icons';
14
+
15
+ interface FrequentlyBoughtTogetherProps {
16
+ items: ProductRecommendation[];
17
+ currentProduct: Product;
18
+ className?: string;
19
+ }
20
+
21
+ // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are
22
+ // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency,
23
+ // so there is nothing to convert them to. Converting only `currentProduct`
24
+ // (which is a full Product and does carry FX fields) would add a euro amount to
25
+ // two dollar amounts and print the result as one total. One currency wins, and
26
+ // it has to be the one the cart actually charges.
27
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
28
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
29
+ const base = parseFloat(item.basePrice);
30
+ return sale != null && sale < base ? sale : base;
31
+ }
32
+
33
+ function ProductThumb({
34
+ name,
35
+ imageUrl,
36
+ price,
37
+ currency,
38
+ checked,
39
+ onToggle,
40
+ disabled,
41
+ }: {
42
+ name: string;
43
+ imageUrl: string | null;
44
+ price: number;
45
+ currency: string;
46
+ checked: boolean;
47
+ onToggle?: () => void;
48
+ disabled?: boolean;
49
+ }) {
50
+ return (
51
+ <label
52
+ className={cn(
53
+ 'flex w-40 cursor-pointer flex-col gap-2 rounded-xl border bg-background p-3 transition-colors',
54
+ checked ? 'border-primary' : 'border-border',
55
+ disabled && 'cursor-default'
56
+ )}
57
+ >
58
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
59
+ <span className="relative block aspect-square overflow-hidden rounded-lg bg-secondary">
60
+ {imageUrl ? (
61
+ <Image src={imageUrl} alt={name} fill sizes="160px" className="object-cover" />
62
+ ) : (
63
+ <NoPhotoTile name={name} />
64
+ )}
65
+ {onToggle && (
66
+ <input
67
+ type="checkbox"
68
+ checked={checked}
69
+ onChange={onToggle}
70
+ disabled={disabled}
71
+ className="absolute end-2 top-2 h-[18px] w-[18px] accent-primary"
72
+ aria-label={name}
73
+ />
74
+ )}
75
+ </span>
76
+ <span className="line-clamp-2 text-xs font-semibold leading-snug text-foreground">
77
+ {name}
78
+ </span>
79
+ <span className="text-sm font-semibold text-foreground">
80
+ {formatPrice(price, { currency }) as string}
81
+ </span>
82
+ </label>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * "Frequently bought together" bundle on the product page: current product +
88
+ * selectable cross-sells + combined total + add-all button; items come from
89
+ * useProductPage().recommendations.crossSells, the feature gate from
90
+ * useStoreInfo().upsell.
91
+ */
92
+ export function FrequentlyBoughtTogether({
93
+ items,
94
+ currentProduct,
95
+ className,
96
+ }: FrequentlyBoughtTogetherProps) {
97
+ // Hooks must be called unconditionally and in the same order on every
98
+ // render — keep all of them above any early `return null` branch.
99
+ const { storeInfo } = useStoreInfo();
100
+ const { refreshCart } = useCart();
101
+ const t = useTranslations('productDetail');
102
+ const currency = useCurrency();
103
+
104
+ // ⛔ KITs are dropped, not priced. A recommendation is a `ProductRecommendation`
105
+ // — an UNRESOLVED list shape, and the recommendations endpoint does not resolve
106
+ // kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every
107
+ // kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one
108
+ // "Total" beside a button that adds them all, so a wrong figure here is a
109
+ // number the shopper commits to. Adding a kit by its own `productId` would in
110
+ // fact be correct — it is the displayed price that cannot be trusted — so a
111
+ // kit still sells fine from its own product page, where the API resolves it.
112
+ // Only show up to 3 cross-sells.
113
+ const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3);
114
+
115
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
116
+ const [adding, setAdding] = useState(false);
117
+ const [addError, setAddError] = useState<AddToCartError | null>(null);
118
+
119
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
120
+ if (crossSells.length === 0) return null;
121
+
122
+ const currentPrice = getEffectivePrice(currentProduct);
123
+ const currentImage = currentProduct.images?.[0];
124
+ const currentImageUrl = currentImage
125
+ ? typeof currentImage === 'string'
126
+ ? currentImage
127
+ : currentImage.url
128
+ : null;
129
+
130
+ const totalPrice = crossSells
131
+ .filter((item) => selected.has(item.id))
132
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
133
+
134
+ const toggleItem = (id: string) => {
135
+ setSelected((prev) => {
136
+ const next = new Set(prev);
137
+ if (next.has(id)) {
138
+ next.delete(id);
139
+ } else {
140
+ next.add(id);
141
+ }
142
+ return next;
143
+ });
144
+ };
145
+
146
+ async function handleAddAll() {
147
+ if (adding || selected.size === 0) return;
148
+ setAddError(null);
149
+ try {
150
+ setAdding(true);
151
+ const { getClient } = await import('@/core/lib/brainerce');
152
+ const client = getClient();
153
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
154
+ for (const item of selectedItems) {
155
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
156
+ }
157
+ await refreshCart();
158
+ } catch (err) {
159
+ // The loop is not transactional: if the cart hits its 50-line cap
160
+ // part-way through, the earlier items ARE in the cart and the rest are
161
+ // not. Saying so beats a silent stop the shopper reads as a dead button.
162
+ setAddError(toAddToCartError(err));
163
+ console.error('Failed to add items to cart:', err);
164
+ } finally {
165
+ setAdding(false);
166
+ }
167
+ }
168
+
169
+ return (
170
+ <section className={cn('rounded-2xl border bg-secondary/50 p-6 sm:p-8', className)}>
171
+ <h2 className="mb-6 text-2xl">{t('frequentlyBoughtTogether')}</h2>
172
+
173
+ <div className="flex flex-wrap items-center gap-3">
174
+ {/* Current product (always included, no checkbox) */}
175
+ <ProductThumb
176
+ name={currentProduct.name}
177
+ imageUrl={currentImageUrl}
178
+ price={currentPrice}
179
+ currency={currency}
180
+ checked={true}
181
+ disabled
182
+ />
183
+
184
+ {crossSells.map((item) => {
185
+ const img = item.images?.[0];
186
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
187
+ return (
188
+ <div key={item.id} className="flex items-center gap-3">
189
+ <span aria-hidden="true" className="text-muted-foreground">
190
+ <IconPlus size={20} />
191
+ </span>
192
+ <ProductThumb
193
+ name={item.name}
194
+ imageUrl={imgUrl}
195
+ price={getEffectivePrice(item)}
196
+ currency={currency}
197
+ checked={selected.has(item.id)}
198
+ onToggle={() => toggleItem(item.id)}
199
+ />
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+
205
+ {/* Total + Add button */}
206
+ <div className="mt-6 flex flex-wrap items-center gap-4 border-t pt-5">
207
+ <span className="text-lg font-semibold text-foreground">
208
+ {t('totalPrice').replace('{price}', formatPrice(totalPrice, { currency }) as string)}
209
+ </span>
210
+ <button
211
+ type="button"
212
+ onClick={handleAddAll}
213
+ disabled={adding || selected.size === 0}
214
+ className="btn-primary btn-md"
215
+ >
216
+ <IconBag size={18} />
217
+ {adding ? t('addingAll') : t('addSelectedToCart')}
218
+ </button>
219
+ </div>
220
+
221
+ {/*
222
+ Why the add was refused. Some items may already be in the cart:
223
+ the loop above adds one at a time and stops at the first refusal.
224
+ */}
225
+ {addError && (
226
+ <p role="alert" className="text-destructive mt-3 text-sm font-medium">
227
+ {addError === 'CART_FULL' ? t('cartFull') : t('addToCartFailed')}
228
+ </p>
229
+ )}
230
+ </section>
231
+ );
232
+ }
@@ -1,69 +1,90 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { useCurrency } from '@/core/lib/use-currency';
5
- import { cn } from '@/core/lib/utils';
6
-
7
- interface PriceDisplayProps {
8
- price: string | number;
9
- salePrice?: string | number | null;
10
- currency?: string;
11
- className?: string;
12
- size?: 'sm' | 'md' | 'lg';
13
- }
14
-
15
- const CURRENT: Record<NonNullable<PriceDisplayProps['size']>, string> = {
16
- sm: 'text-sm font-semibold',
17
- md: 'text-base font-semibold',
18
- lg: 'text-2xl font-semibold',
19
- };
20
- const STRUCK: Record<NonNullable<PriceDisplayProps['size']>, string> = {
21
- sm: 'text-xs',
22
- md: 'text-sm',
23
- lg: 'text-base',
24
- };
25
-
26
- /**
27
- * Price renderer used by cards, product page and recommendations; computes
28
- * sale state + discount % from props (currency via useCurrency()).
29
- * Never hardcode currency symbols — formatPrice handles them.
30
- */
31
- export function PriceDisplay({
32
- price,
33
- salePrice,
34
- currency,
35
- className,
36
- size = 'md',
37
- }: PriceDisplayProps) {
38
- const currencyCode = useCurrency(currency);
39
-
40
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
41
- const sale =
42
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
43
- const isOnSale = sale !== null && sale < basePrice;
44
-
45
- const discountPercent =
46
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
47
-
48
- return (
49
- <span data-size={size} className={cn('inline-flex flex-wrap items-baseline gap-2', className)}>
50
- {isOnSale ? (
51
- <>
52
- <span className={cn(CURRENT[size], 'text-foreground')}>
53
- {formatPrice(sale!, { currency: currencyCode }) as string}
54
- </span>
55
- <s className={cn(STRUCK[size], 'text-muted-foreground')}>
56
- {formatPrice(basePrice, { currency: currencyCode }) as string}
57
- </s>
58
- {discountPercent > 0 && (
59
- <span className="badge-destructive self-center">-{discountPercent}%</span>
60
- )}
61
- </>
62
- ) : (
63
- <span className={cn(CURRENT[size], 'text-foreground')}>
64
- {formatPrice(basePrice, { currency: currencyCode }) as string}
65
- </span>
66
- )}
67
- </span>
68
- );
69
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useCurrency } from '@/core/lib/use-currency';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { useStoreInfo } from '@/core/providers/store-provider';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface PriceDisplayProps {
10
+ price: string | number;
11
+ salePrice?: string | number | null;
12
+ currency?: string;
13
+ className?: string;
14
+ size?: 'sm' | 'md' | 'lg';
15
+ }
16
+
17
+ const CURRENT: Record<NonNullable<PriceDisplayProps['size']>, string> = {
18
+ sm: 'text-sm font-semibold',
19
+ md: 'text-base font-semibold',
20
+ lg: 'text-2xl font-semibold',
21
+ };
22
+ const STRUCK: Record<NonNullable<PriceDisplayProps['size']>, string> = {
23
+ sm: 'text-xs',
24
+ md: 'text-sm',
25
+ lg: 'text-base',
26
+ };
27
+
28
+ /**
29
+ * Price renderer used by cards, product page and recommendations; computes
30
+ * sale state + discount % from props (currency via useCurrency()).
31
+ * Never hardcode currency symbols — formatPrice handles them.
32
+ */
33
+ export function PriceDisplay({
34
+ price,
35
+ salePrice,
36
+ currency,
37
+ className,
38
+ size = 'md',
39
+ }: PriceDisplayProps) {
40
+ const currencyCode = useCurrency(currency);
41
+ const { storeInfo } = useStoreInfo();
42
+ const t = useTranslations('common');
43
+
44
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
45
+ const sale =
46
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
47
+ const isOnSale = sale !== null && sale < basePrice;
48
+
49
+ const discountPercent =
50
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
51
+
52
+ // FX overlay disclosure (see core/lib/display-price.ts): `currency` is the
53
+ // region-converted DISPLAY currency when it differs from the store's own
54
+ // base currency. The shopper is still charged in the base currency at
55
+ // checkout, so every converted price needs a visible "estimated" cue.
56
+ const isConverted = !!currency && !!storeInfo?.currency && currency !== storeInfo.currency;
57
+
58
+ return (
59
+ <span data-size={size} className={cn('inline-flex flex-wrap items-baseline gap-2', className)}>
60
+ {isOnSale ? (
61
+ <>
62
+ <span className={cn(CURRENT[size], 'text-foreground')}>
63
+ {formatPrice(sale!, { currency: currencyCode }) as string}
64
+ </span>
65
+ <s className={cn(STRUCK[size], 'text-muted-foreground')}>
66
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
67
+ </s>
68
+ {discountPercent > 0 && (
69
+ <span className="badge-destructive self-center">-{discountPercent}%</span>
70
+ )}
71
+ </>
72
+ ) : (
73
+ <span className={cn(CURRENT[size], 'text-foreground')}>
74
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
75
+ </span>
76
+ )}
77
+ {isConverted && (
78
+ <span
79
+ className="text-muted-foreground cursor-help self-center text-xs font-normal underline decoration-dotted"
80
+ title={t('estimatedPriceTooltip', {
81
+ currency: currencyCode,
82
+ baseCurrency: storeInfo!.currency,
83
+ })}
84
+ >
85
+ {t('estimatedPrice')}
86
+ </span>
87
+ )}
88
+ </span>
89
+ );
90
+ }