create-brainerce-store 1.43.0 → 1.43.2
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.
- package/dist/index.js +11 -8
- package/messages/en.json +1 -0
- package/messages/he.json +1 -0
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +68 -69
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +98 -93
- package/templates/nextjs/base/src/app/checkout/page.tsx +1004 -982
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +118 -118
- package/templates/nextjs/base/src/components/account/order-history.tsx +368 -368
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -111
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +152 -152
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +141 -141
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +62 -62
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +109 -109
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -64
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +203 -203
- package/templates/nextjs/base/src/components/products/product-card.tsx +46 -1
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +125 -129
- package/templates/nextjs/base/src/components/shared/price-display.tsx +61 -61
- package/templates/nextjs/base/src/lib/resolve-currency.ts +1 -6
- package/templates/nextjs/base/src/lib/use-currency.ts +1 -6
|
@@ -1,64 +1,74 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { TaxBreakdown } from 'brainerce';
|
|
4
|
-
import { formatPrice } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/lib/translations';
|
|
6
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
7
|
-
import { cn } from '@/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface TaxDisplayProps {
|
|
10
|
-
/** Whether shipping address has been set */
|
|
11
|
-
addressSet: boolean;
|
|
12
|
-
/** Tax amount string from checkout (only available after address is set) */
|
|
13
|
-
taxAmount?: string;
|
|
14
|
-
/** Detailed tax breakdown (optional) */
|
|
15
|
-
taxBreakdown?: TaxBreakdown | null;
|
|
16
|
-
className?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
|
|
20
|
-
const t = useTranslations('checkout');
|
|
21
|
-
const tc = useTranslations('common');
|
|
22
|
-
const currency = useCurrency();
|
|
23
|
-
|
|
24
|
-
// Before address is set
|
|
25
|
-
if (!addressSet) {
|
|
26
|
-
return (
|
|
27
|
-
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
28
|
-
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
29
|
-
<span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
|
|
30
|
-
</div>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { TaxBreakdown } from 'brainerce';
|
|
4
|
+
import { formatPrice } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface TaxDisplayProps {
|
|
10
|
+
/** Whether shipping address has been set */
|
|
11
|
+
addressSet: boolean;
|
|
12
|
+
/** Tax amount string from checkout (only available after address is set) */
|
|
13
|
+
taxAmount?: string;
|
|
14
|
+
/** Detailed tax breakdown (optional) */
|
|
15
|
+
taxBreakdown?: TaxBreakdown | null;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
|
|
20
|
+
const t = useTranslations('checkout');
|
|
21
|
+
const tc = useTranslations('common');
|
|
22
|
+
const currency = useCurrency();
|
|
23
|
+
|
|
24
|
+
// Before address is set
|
|
25
|
+
if (!addressSet) {
|
|
26
|
+
return (
|
|
27
|
+
<div className={cn('flex items-center justify-between text-sm', className)}>
|
|
28
|
+
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
29
|
+
<span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
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;
|
|
44
|
+
|
|
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
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
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>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Image from 'next/image';
|
|
5
|
-
import type { Product, ProductRecommendation } from 'brainerce';
|
|
6
|
-
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
8
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
9
|
-
import { useTranslations } from '@/lib/translations';
|
|
10
|
-
import { cn } from '@/lib/utils';
|
|
11
|
-
|
|
12
|
-
interface FrequentlyBoughtTogetherProps {
|
|
13
|
-
items: ProductRecommendation[];
|
|
14
|
-
currentProduct: Product;
|
|
15
|
-
className?: string;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
|
|
19
|
-
const sale = item.salePrice ? parseFloat(item.salePrice) : null;
|
|
20
|
-
const base = parseFloat(item.basePrice);
|
|
21
|
-
return sale != null && sale < base ? sale : base;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function ProductThumb({
|
|
25
|
-
name,
|
|
26
|
-
imageUrl,
|
|
27
|
-
price,
|
|
28
|
-
currency,
|
|
29
|
-
checked,
|
|
30
|
-
onToggle,
|
|
31
|
-
disabled,
|
|
32
|
-
}: {
|
|
33
|
-
name: string;
|
|
34
|
-
imageUrl: string | null;
|
|
35
|
-
price: number;
|
|
36
|
-
currency: string;
|
|
37
|
-
checked: boolean;
|
|
38
|
-
onToggle?: () => void;
|
|
39
|
-
disabled?: boolean;
|
|
40
|
-
}) {
|
|
41
|
-
return (
|
|
42
|
-
<label
|
|
43
|
-
className={cn(
|
|
44
|
-
'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
|
|
45
|
-
checked ? 'ring-primary ring-2' : 'opacity-60',
|
|
46
|
-
disabled && 'pointer-events-none'
|
|
47
|
-
)}
|
|
48
|
-
>
|
|
49
|
-
{onToggle && (
|
|
50
|
-
<input
|
|
51
|
-
type="checkbox"
|
|
52
|
-
checked={checked}
|
|
53
|
-
onChange={onToggle}
|
|
54
|
-
className="absolute start-2 top-2 h-4 w-4 rounded"
|
|
55
|
-
/>
|
|
56
|
-
)}
|
|
57
|
-
<div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
|
|
58
|
-
{imageUrl ? (
|
|
59
|
-
<Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
|
|
60
|
-
) : (
|
|
61
|
-
<div className="flex h-full w-full items-center justify-center">
|
|
62
|
-
<svg
|
|
63
|
-
className="text-muted-foreground h-8 w-8"
|
|
64
|
-
fill="none"
|
|
65
|
-
viewBox="0 0 24 24"
|
|
66
|
-
stroke="currentColor"
|
|
67
|
-
>
|
|
68
|
-
<path
|
|
69
|
-
strokeLinecap="round"
|
|
70
|
-
strokeLinejoin="round"
|
|
71
|
-
strokeWidth={1.5}
|
|
72
|
-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
73
|
-
/>
|
|
74
|
-
</svg>
|
|
75
|
-
</div>
|
|
76
|
-
)}
|
|
77
|
-
</div>
|
|
78
|
-
<span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
|
|
79
|
-
<span className="text-muted-foreground mt-1 text-xs">
|
|
80
|
-
{formatPrice(price, { currency }) as string}
|
|
81
|
-
</span>
|
|
82
|
-
</label>
|
|
83
|
-
);
|
|
84
|
-
}
|
|
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('@/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
|
-
<div className={cn('border-border rounded-lg border p-6', className)}>
|
|
151
|
-
<h2 className="text-foreground mb-4 text-xl font-semibold">
|
|
152
|
-
{t('frequentlyBoughtTogether')}
|
|
153
|
-
</h2>
|
|
154
|
-
|
|
155
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
156
|
-
{/* Current product (always included, no checkbox) */}
|
|
157
|
-
<ProductThumb
|
|
158
|
-
name={currentProduct.name}
|
|
159
|
-
imageUrl={currentImageUrl}
|
|
160
|
-
price={currentPrice}
|
|
161
|
-
currency={currency}
|
|
162
|
-
checked={true}
|
|
163
|
-
disabled
|
|
164
|
-
/>
|
|
165
|
-
|
|
166
|
-
{crossSells.map((item) => {
|
|
167
|
-
const img = item.images?.[0];
|
|
168
|
-
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
169
|
-
return (
|
|
170
|
-
<div key={item.id} className="flex items-center gap-3">
|
|
171
|
-
<span className="text-muted-foreground text-lg font-light">+</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-4 flex flex-wrap items-center gap-4">
|
|
187
|
-
<span className="text-foreground text-lg font-semibold">
|
|
188
|
-
{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
|
|
189
|
-
</span>
|
|
190
|
-
<button
|
|
191
|
-
onClick={handleAddAll}
|
|
192
|
-
disabled={adding || selected.size === 0}
|
|
193
|
-
className={cn(
|
|
194
|
-
'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
|
|
195
|
-
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
196
|
-
)}
|
|
197
|
-
>
|
|
198
|
-
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
199
|
-
</button>
|
|
200
|
-
</div>
|
|
201
|
-
</div>
|
|
202
|
-
);
|
|
203
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { Product, ProductRecommendation } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
8
|
+
import { useCurrency } from '@/lib/use-currency';
|
|
9
|
+
import { useTranslations } from '@/lib/translations';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface FrequentlyBoughtTogetherProps {
|
|
13
|
+
items: ProductRecommendation[];
|
|
14
|
+
currentProduct: Product;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
|
|
19
|
+
const sale = item.salePrice ? parseFloat(item.salePrice) : null;
|
|
20
|
+
const base = parseFloat(item.basePrice);
|
|
21
|
+
return sale != null && sale < base ? sale : base;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ProductThumb({
|
|
25
|
+
name,
|
|
26
|
+
imageUrl,
|
|
27
|
+
price,
|
|
28
|
+
currency,
|
|
29
|
+
checked,
|
|
30
|
+
onToggle,
|
|
31
|
+
disabled,
|
|
32
|
+
}: {
|
|
33
|
+
name: string;
|
|
34
|
+
imageUrl: string | null;
|
|
35
|
+
price: number;
|
|
36
|
+
currency: string;
|
|
37
|
+
checked: boolean;
|
|
38
|
+
onToggle?: () => void;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<label
|
|
43
|
+
className={cn(
|
|
44
|
+
'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
|
|
45
|
+
checked ? 'ring-primary ring-2' : 'opacity-60',
|
|
46
|
+
disabled && 'pointer-events-none'
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{onToggle && (
|
|
50
|
+
<input
|
|
51
|
+
type="checkbox"
|
|
52
|
+
checked={checked}
|
|
53
|
+
onChange={onToggle}
|
|
54
|
+
className="absolute start-2 top-2 h-4 w-4 rounded"
|
|
55
|
+
/>
|
|
56
|
+
)}
|
|
57
|
+
<div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
|
|
58
|
+
{imageUrl ? (
|
|
59
|
+
<Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
|
|
60
|
+
) : (
|
|
61
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
62
|
+
<svg
|
|
63
|
+
className="text-muted-foreground h-8 w-8"
|
|
64
|
+
fill="none"
|
|
65
|
+
viewBox="0 0 24 24"
|
|
66
|
+
stroke="currentColor"
|
|
67
|
+
>
|
|
68
|
+
<path
|
|
69
|
+
strokeLinecap="round"
|
|
70
|
+
strokeLinejoin="round"
|
|
71
|
+
strokeWidth={1.5}
|
|
72
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
73
|
+
/>
|
|
74
|
+
</svg>
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
<span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
|
|
79
|
+
<span className="text-muted-foreground mt-1 text-xs">
|
|
80
|
+
{formatPrice(price, { currency }) as string}
|
|
81
|
+
</span>
|
|
82
|
+
</label>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
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('@/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
|
+
<div className={cn('border-border rounded-lg border p-6', className)}>
|
|
151
|
+
<h2 className="text-foreground mb-4 text-xl font-semibold">
|
|
152
|
+
{t('frequentlyBoughtTogether')}
|
|
153
|
+
</h2>
|
|
154
|
+
|
|
155
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
156
|
+
{/* Current product (always included, no checkbox) */}
|
|
157
|
+
<ProductThumb
|
|
158
|
+
name={currentProduct.name}
|
|
159
|
+
imageUrl={currentImageUrl}
|
|
160
|
+
price={currentPrice}
|
|
161
|
+
currency={currency}
|
|
162
|
+
checked={true}
|
|
163
|
+
disabled
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{crossSells.map((item) => {
|
|
167
|
+
const img = item.images?.[0];
|
|
168
|
+
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
169
|
+
return (
|
|
170
|
+
<div key={item.id} className="flex items-center gap-3">
|
|
171
|
+
<span className="text-muted-foreground text-lg font-light">+</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-4 flex flex-wrap items-center gap-4">
|
|
187
|
+
<span className="text-foreground text-lg font-semibold">
|
|
188
|
+
{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
|
|
189
|
+
</span>
|
|
190
|
+
<button
|
|
191
|
+
onClick={handleAddAll}
|
|
192
|
+
disabled={adding || selected.size === 0}
|
|
193
|
+
className={cn(
|
|
194
|
+
'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
|
|
195
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
199
|
+
</button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -18,6 +18,41 @@ interface ProductCardProps {
|
|
|
18
18
|
className?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Pick the price + currency to render for a given product (PRD §23 FX overlay).
|
|
23
|
+
*
|
|
24
|
+
* When the storefront passed `regionId` to `getProducts({ regionId })` AND the
|
|
25
|
+
* region currency differs from the store currency, the backend attaches
|
|
26
|
+
* additive `displayPrice` / `displayCurrency` fields. Otherwise we fall back
|
|
27
|
+
* to the canonical store-currency `basePrice` / `salePrice`. Either way the
|
|
28
|
+
* cart still charges in the store currency — this only affects display.
|
|
29
|
+
*/
|
|
30
|
+
function pickDisplayPrice(
|
|
31
|
+
product: Product,
|
|
32
|
+
fallbackCurrency: string
|
|
33
|
+
): { price: number | undefined; salePrice: number | null; currency: string } {
|
|
34
|
+
if (product.displayPrice != null && product.displayCurrency) {
|
|
35
|
+
const sale =
|
|
36
|
+
product.displaySalePrice != null ? parseFloat(product.displaySalePrice) : null;
|
|
37
|
+
return {
|
|
38
|
+
// `displayPrice` is the base price converted to the region currency —
|
|
39
|
+
// it goes into the PriceDisplay `price` (base) slot, NOT the sale slot.
|
|
40
|
+
price: parseFloat(product.displayPrice),
|
|
41
|
+
salePrice: sale != null && !Number.isNaN(sale) ? sale : null,
|
|
42
|
+
currency: product.displayCurrency,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Same-currency fallback. `getProductPriceInfo.price` is the EFFECTIVE
|
|
46
|
+
// charged amount (= salePrice when on sale, base otherwise); `originalPrice`
|
|
47
|
+
// is the base. Map them to PriceDisplay's (price = base, salePrice = sale).
|
|
48
|
+
const { price: effective, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
49
|
+
return {
|
|
50
|
+
price: originalPrice,
|
|
51
|
+
salePrice: isOnSale ? effective : null,
|
|
52
|
+
currency: fallbackCurrency,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
function VariantPriceRange({ product }: { product: Product }) {
|
|
22
57
|
const currency = useCurrency();
|
|
23
58
|
|
|
@@ -51,6 +86,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
51
86
|
const tProd = useTranslations('products');
|
|
52
87
|
const router = useRouter();
|
|
53
88
|
const { refreshCart } = useCart();
|
|
89
|
+
const fallbackCurrency = useCurrency();
|
|
90
|
+
// FX overlay (PRD §23): prefer the region-converted display values when the
|
|
91
|
+
// storefront passed regionId to getProducts. Otherwise fall back to the
|
|
92
|
+
// canonical store-currency basePrice / salePrice.
|
|
93
|
+
const display = pickDisplayPrice(product, fallbackCurrency);
|
|
54
94
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
55
95
|
const mainImage = product.images?.[0];
|
|
56
96
|
const imageUrl = mainImage?.url || null;
|
|
@@ -215,7 +255,12 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
215
255
|
{isVariable ? (
|
|
216
256
|
<VariantPriceRange product={product} />
|
|
217
257
|
) : (
|
|
218
|
-
<PriceDisplay
|
|
258
|
+
<PriceDisplay
|
|
259
|
+
price={display.price ?? originalPrice}
|
|
260
|
+
salePrice={display.salePrice ?? (isOnSale ? price : undefined)}
|
|
261
|
+
currency={display.currency}
|
|
262
|
+
size="sm"
|
|
263
|
+
/>
|
|
219
264
|
)}
|
|
220
265
|
|
|
221
266
|
{/* Stock */}
|