create-brainerce-store 1.17.0 → 1.19.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.
- package/dist/index.js +31 -9
- package/messages/en.json +366 -359
- package/messages/he.json +366 -359
- package/package.json +45 -45
- package/templates/nextjs/base/next.config.ts +31 -31
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
- package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
- package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/account/page.tsx +122 -122
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
- package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/cart/page.tsx +204 -199
- package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
- package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
- package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/login/page.tsx +59 -59
- package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +254 -254
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
- package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
- package/templates/nextjs/base/src/app/products/page.tsx +431 -431
- package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
- package/templates/nextjs/base/src/app/register/page.tsx +65 -65
- package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
- package/templates/nextjs/base/src/app/robots.ts +14 -14
- package/templates/nextjs/base/src/app/sitemap.ts +25 -25
- package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
- package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
- package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +519 -473
- package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
- package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
- package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
- package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
- package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
- package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
- package/templates/nextjs/base/src/lib/auth.ts +149 -149
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
- package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
- package/templates/nextjs/base/src/lib/translations.ts +0 -11
- package/templates/nextjs/base/src/middleware.ts +0 -25
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<% if (i18nEnabled) { %>
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { supportedLocales } from '@/i18n';
|
|
6
|
+
|
|
7
|
+
const LOCALE_NAMES: Record<string, string> = {
|
|
8
|
+
en: 'English',
|
|
9
|
+
he: 'עברית',
|
|
10
|
+
ar: 'العربية',
|
|
11
|
+
es: 'Español',
|
|
12
|
+
fr: 'Français',
|
|
13
|
+
de: 'Deutsch',
|
|
14
|
+
it: 'Italiano',
|
|
15
|
+
pt: 'Português',
|
|
16
|
+
nl: 'Nederlands',
|
|
17
|
+
ja: '日本語',
|
|
18
|
+
ko: '한국어',
|
|
19
|
+
zh: '中文',
|
|
20
|
+
ru: 'Русский',
|
|
21
|
+
tr: 'Türkçe',
|
|
22
|
+
pl: 'Polski',
|
|
23
|
+
sv: 'Svenska',
|
|
24
|
+
da: 'Dansk',
|
|
25
|
+
no: 'Norsk',
|
|
26
|
+
fi: 'Suomi',
|
|
27
|
+
cs: 'Čeština',
|
|
28
|
+
th: 'ไทย',
|
|
29
|
+
vi: 'Tiếng Việt',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function LanguageSwitcher() {
|
|
33
|
+
const pathname = usePathname();
|
|
34
|
+
const currentLocale = pathname.split('/')[1] || '<%= defaultLocale %>';
|
|
35
|
+
|
|
36
|
+
function switchLocale(newLocale: string) {
|
|
37
|
+
const segments = pathname.split('/');
|
|
38
|
+
if (supportedLocales.includes(segments[1] as (typeof supportedLocales)[number])) {
|
|
39
|
+
segments[1] = newLocale;
|
|
40
|
+
} else {
|
|
41
|
+
segments.splice(1, 0, newLocale);
|
|
42
|
+
}
|
|
43
|
+
window.location.href = segments.join('/');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (supportedLocales.length <= 1) return null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<select
|
|
50
|
+
value={currentLocale}
|
|
51
|
+
onChange={(e) => switchLocale(e.target.value)}
|
|
52
|
+
className="text-sm border rounded px-2 py-1 bg-transparent"
|
|
53
|
+
aria-label="Select language"
|
|
54
|
+
>
|
|
55
|
+
{supportedLocales.map((locale) => (
|
|
56
|
+
<option key={locale} value={locale}>
|
|
57
|
+
{LOCALE_NAMES[locale] || locale}
|
|
58
|
+
</option>
|
|
59
|
+
))}
|
|
60
|
+
</select>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
<% } %>
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import type { ProductDiscount } from 'brainerce';
|
|
2
|
-
import { cn } from '@/lib/utils';
|
|
3
|
-
|
|
4
|
-
interface DiscountBadgeProps {
|
|
5
|
-
discount?: ProductDiscount | null;
|
|
6
|
-
className?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
10
|
-
if (!discount) return null;
|
|
11
|
-
|
|
12
|
-
return (
|
|
13
|
-
<span
|
|
14
|
-
className={cn(
|
|
15
|
-
'bg-destructive text-destructive-foreground inline-flex items-center rounded px-2 py-1 text-xs font-bold',
|
|
16
|
-
className
|
|
17
|
-
)}
|
|
18
|
-
>
|
|
19
|
-
{discount.badgeText}
|
|
20
|
-
</span>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
1
|
+
import type { ProductDiscount } from 'brainerce';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
|
|
4
|
+
interface DiscountBadgeProps {
|
|
5
|
+
discount?: ProductDiscount | null;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
10
|
+
if (!discount) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<span
|
|
14
|
+
className={cn(
|
|
15
|
+
'bg-destructive text-destructive-foreground inline-flex items-center rounded px-2 py-1 text-xs font-bold',
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
>
|
|
19
|
+
{discount.badgeText}
|
|
20
|
+
</span>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -1,202 +1,202 @@
|
|
|
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 } from '@/providers/store-provider';
|
|
8
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
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
|
-
const { storeInfo } = useStoreInfo();
|
|
92
|
-
const { refreshCart } = useCart();
|
|
93
|
-
const t = useTranslations('productDetail');
|
|
94
|
-
|
|
95
|
-
// Only show up to 3 cross-sells
|
|
96
|
-
const crossSells = items.slice(0, 3);
|
|
97
|
-
|
|
98
|
-
const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
|
|
99
|
-
const [adding, setAdding] = useState(false);
|
|
100
|
-
|
|
101
|
-
if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
|
|
102
|
-
if (crossSells.length === 0) return null;
|
|
103
|
-
|
|
104
|
-
const currency = storeInfo.currency || 'USD';
|
|
105
|
-
|
|
106
|
-
const currentPrice = getEffectivePrice(currentProduct);
|
|
107
|
-
const currentImage = currentProduct.images?.[0];
|
|
108
|
-
const currentImageUrl = currentImage
|
|
109
|
-
? typeof currentImage === 'string'
|
|
110
|
-
? currentImage
|
|
111
|
-
: currentImage.url
|
|
112
|
-
: null;
|
|
113
|
-
|
|
114
|
-
const totalPrice = crossSells
|
|
115
|
-
.filter((item) => selected.has(item.id))
|
|
116
|
-
.reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
|
|
117
|
-
|
|
118
|
-
const toggleItem = (id: string) => {
|
|
119
|
-
setSelected((prev) => {
|
|
120
|
-
const next = new Set(prev);
|
|
121
|
-
if (next.has(id)) {
|
|
122
|
-
next.delete(id);
|
|
123
|
-
} else {
|
|
124
|
-
next.add(id);
|
|
125
|
-
}
|
|
126
|
-
return next;
|
|
127
|
-
});
|
|
128
|
-
};
|
|
129
|
-
|
|
130
|
-
async function handleAddAll() {
|
|
131
|
-
if (adding || selected.size === 0) return;
|
|
132
|
-
try {
|
|
133
|
-
setAdding(true);
|
|
134
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
135
|
-
const client = getClient();
|
|
136
|
-
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
137
|
-
for (const item of selectedItems) {
|
|
138
|
-
await client.smartAddToCart({ productId: item.id, quantity: 1 });
|
|
139
|
-
}
|
|
140
|
-
await refreshCart();
|
|
141
|
-
} catch (err) {
|
|
142
|
-
console.error('Failed to add items to cart:', err);
|
|
143
|
-
} finally {
|
|
144
|
-
setAdding(false);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return (
|
|
149
|
-
<div className={cn('border-border rounded-lg border p-6', className)}>
|
|
150
|
-
<h2 className="text-foreground mb-4 text-xl font-semibold">
|
|
151
|
-
{t('frequentlyBoughtTogether')}
|
|
152
|
-
</h2>
|
|
153
|
-
|
|
154
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
155
|
-
{/* Current product (always included, no checkbox) */}
|
|
156
|
-
<ProductThumb
|
|
157
|
-
name={currentProduct.name}
|
|
158
|
-
imageUrl={currentImageUrl}
|
|
159
|
-
price={currentPrice}
|
|
160
|
-
currency={currency}
|
|
161
|
-
checked={true}
|
|
162
|
-
disabled
|
|
163
|
-
/>
|
|
164
|
-
|
|
165
|
-
{crossSells.map((item) => {
|
|
166
|
-
const img = item.images?.[0];
|
|
167
|
-
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
168
|
-
return (
|
|
169
|
-
<div key={item.id} className="flex items-center gap-3">
|
|
170
|
-
<span className="text-muted-foreground text-lg font-light">+</span>
|
|
171
|
-
<ProductThumb
|
|
172
|
-
name={item.name}
|
|
173
|
-
imageUrl={imgUrl}
|
|
174
|
-
price={getEffectivePrice(item)}
|
|
175
|
-
currency={currency}
|
|
176
|
-
checked={selected.has(item.id)}
|
|
177
|
-
onToggle={() => toggleItem(item.id)}
|
|
178
|
-
/>
|
|
179
|
-
</div>
|
|
180
|
-
);
|
|
181
|
-
})}
|
|
182
|
-
</div>
|
|
183
|
-
|
|
184
|
-
{/* Total + Add button */}
|
|
185
|
-
<div className="mt-4 flex flex-wrap items-center gap-4">
|
|
186
|
-
<span className="text-foreground text-lg font-semibold">
|
|
187
|
-
{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
|
|
188
|
-
</span>
|
|
189
|
-
<button
|
|
190
|
-
onClick={handleAddAll}
|
|
191
|
-
disabled={adding || selected.size === 0}
|
|
192
|
-
className={cn(
|
|
193
|
-
'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
|
|
194
|
-
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
195
|
-
)}
|
|
196
|
-
>
|
|
197
|
-
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
198
|
-
</button>
|
|
199
|
-
</div>
|
|
200
|
-
</div>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
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 } from '@/providers/store-provider';
|
|
8
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
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
|
+
const { storeInfo } = useStoreInfo();
|
|
92
|
+
const { refreshCart } = useCart();
|
|
93
|
+
const t = useTranslations('productDetail');
|
|
94
|
+
|
|
95
|
+
// Only show up to 3 cross-sells
|
|
96
|
+
const crossSells = items.slice(0, 3);
|
|
97
|
+
|
|
98
|
+
const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
|
|
99
|
+
const [adding, setAdding] = useState(false);
|
|
100
|
+
|
|
101
|
+
if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
|
|
102
|
+
if (crossSells.length === 0) return null;
|
|
103
|
+
|
|
104
|
+
const currency = storeInfo.currency || 'USD';
|
|
105
|
+
|
|
106
|
+
const currentPrice = getEffectivePrice(currentProduct);
|
|
107
|
+
const currentImage = currentProduct.images?.[0];
|
|
108
|
+
const currentImageUrl = currentImage
|
|
109
|
+
? typeof currentImage === 'string'
|
|
110
|
+
? currentImage
|
|
111
|
+
: currentImage.url
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
const totalPrice = crossSells
|
|
115
|
+
.filter((item) => selected.has(item.id))
|
|
116
|
+
.reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
|
|
117
|
+
|
|
118
|
+
const toggleItem = (id: string) => {
|
|
119
|
+
setSelected((prev) => {
|
|
120
|
+
const next = new Set(prev);
|
|
121
|
+
if (next.has(id)) {
|
|
122
|
+
next.delete(id);
|
|
123
|
+
} else {
|
|
124
|
+
next.add(id);
|
|
125
|
+
}
|
|
126
|
+
return next;
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
async function handleAddAll() {
|
|
131
|
+
if (adding || selected.size === 0) return;
|
|
132
|
+
try {
|
|
133
|
+
setAdding(true);
|
|
134
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
135
|
+
const client = getClient();
|
|
136
|
+
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
137
|
+
for (const item of selectedItems) {
|
|
138
|
+
await client.smartAddToCart({ productId: item.id, quantity: 1 });
|
|
139
|
+
}
|
|
140
|
+
await refreshCart();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error('Failed to add items to cart:', err);
|
|
143
|
+
} finally {
|
|
144
|
+
setAdding(false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={cn('border-border rounded-lg border p-6', className)}>
|
|
150
|
+
<h2 className="text-foreground mb-4 text-xl font-semibold">
|
|
151
|
+
{t('frequentlyBoughtTogether')}
|
|
152
|
+
</h2>
|
|
153
|
+
|
|
154
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
155
|
+
{/* Current product (always included, no checkbox) */}
|
|
156
|
+
<ProductThumb
|
|
157
|
+
name={currentProduct.name}
|
|
158
|
+
imageUrl={currentImageUrl}
|
|
159
|
+
price={currentPrice}
|
|
160
|
+
currency={currency}
|
|
161
|
+
checked={true}
|
|
162
|
+
disabled
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
{crossSells.map((item) => {
|
|
166
|
+
const img = item.images?.[0];
|
|
167
|
+
const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
|
|
168
|
+
return (
|
|
169
|
+
<div key={item.id} className="flex items-center gap-3">
|
|
170
|
+
<span className="text-muted-foreground text-lg font-light">+</span>
|
|
171
|
+
<ProductThumb
|
|
172
|
+
name={item.name}
|
|
173
|
+
imageUrl={imgUrl}
|
|
174
|
+
price={getEffectivePrice(item)}
|
|
175
|
+
currency={currency}
|
|
176
|
+
checked={selected.has(item.id)}
|
|
177
|
+
onToggle={() => toggleItem(item.id)}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Total + Add button */}
|
|
185
|
+
<div className="mt-4 flex flex-wrap items-center gap-4">
|
|
186
|
+
<span className="text-foreground text-lg font-semibold">
|
|
187
|
+
{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
|
|
188
|
+
</span>
|
|
189
|
+
<button
|
|
190
|
+
onClick={handleAddAll}
|
|
191
|
+
disabled={adding || selected.size === 0}
|
|
192
|
+
className={cn(
|
|
193
|
+
'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
|
|
194
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
{adding ? t('addingAll') : t('addSelectedToCart')}
|
|
198
|
+
</button>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|