create-brainerce-store 1.15.2 → 1.17.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 +1 -1
- package/messages/en.json +17 -3
- package/messages/he.json +17 -3
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/cart/page.tsx +64 -2
- package/templates/nextjs/base/src/app/checkout/page.tsx +74 -1
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +10 -0
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +111 -0
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -0
- package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -0
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +83 -0
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -0
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.16.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/messages/en.json
CHANGED
|
@@ -88,7 +88,11 @@
|
|
|
88
88
|
"digitalProduct": "Digital Product",
|
|
89
89
|
"instantDownload": "Instant Download",
|
|
90
90
|
"downloadAfterPurchase": "Available for download after purchase",
|
|
91
|
-
"filesIncluded": "{count} files included"
|
|
91
|
+
"filesIncluded": "{count} files included",
|
|
92
|
+
"frequentlyBoughtTogether": "Frequently Bought Together",
|
|
93
|
+
"addSelectedToCart": "Add Selected to Cart",
|
|
94
|
+
"totalPrice": "Total: {price}",
|
|
95
|
+
"addingAll": "Adding..."
|
|
92
96
|
},
|
|
93
97
|
"cart": {
|
|
94
98
|
"pageTitle": "Shopping Cart",
|
|
@@ -98,7 +102,16 @@
|
|
|
98
102
|
"proceedToCheckout": "Proceed to Checkout",
|
|
99
103
|
"orderSummary": "Order Summary",
|
|
100
104
|
"taxAtCheckout": "Calculated at checkout",
|
|
101
|
-
"youMightAlsoNeed": "You Might Also Need"
|
|
105
|
+
"youMightAlsoNeed": "You Might Also Need",
|
|
106
|
+
"freeShippingQualified": "You've got free shipping!",
|
|
107
|
+
"freeShippingRemaining": "You're {amount} away from free shipping!",
|
|
108
|
+
"upgradeFor": "Upgrade to {name} for only +{amount}",
|
|
109
|
+
"upgrade": "Upgrade",
|
|
110
|
+
"upgrading": "Upgrading...",
|
|
111
|
+
"dismissUpgrade": "Dismiss",
|
|
112
|
+
"bundleOffers": "Bundle & Save",
|
|
113
|
+
"addBundleItem": "Add & Save",
|
|
114
|
+
"addingBundle": "Adding..."
|
|
102
115
|
},
|
|
103
116
|
"checkout": {
|
|
104
117
|
"pageTitle": "Checkout",
|
|
@@ -155,7 +168,8 @@
|
|
|
155
168
|
"failedToSetDeliveryMethod": "Failed to set delivery method",
|
|
156
169
|
"failedToSelectPickup": "Failed to select pickup location",
|
|
157
170
|
"failedToLoadPaymentSdk": "Failed to load payment SDK",
|
|
158
|
-
"paymentTimedOut": "Payment session timed out. Please try again."
|
|
171
|
+
"paymentTimedOut": "Payment session timed out. Please try again.",
|
|
172
|
+
"addToYourOrder": "Add to your order"
|
|
159
173
|
},
|
|
160
174
|
"checkoutForm": {
|
|
161
175
|
"email": "Email",
|
package/messages/he.json
CHANGED
|
@@ -88,7 +88,11 @@
|
|
|
88
88
|
"digitalProduct": "מוצר דיגיטלי",
|
|
89
89
|
"instantDownload": "הורדה מיידית",
|
|
90
90
|
"downloadAfterPurchase": "זמין להורדה לאחר רכישה",
|
|
91
|
-
"filesIncluded": "{count} קבצים כלולים"
|
|
91
|
+
"filesIncluded": "{count} קבצים כלולים",
|
|
92
|
+
"frequentlyBoughtTogether": "לקוחות קנו גם",
|
|
93
|
+
"addSelectedToCart": "הוסף הנבחרים לעגלה",
|
|
94
|
+
"totalPrice": "סה\"כ: {price}",
|
|
95
|
+
"addingAll": "...מוסיף"
|
|
92
96
|
},
|
|
93
97
|
"cart": {
|
|
94
98
|
"pageTitle": "עגלת קניות",
|
|
@@ -98,7 +102,16 @@
|
|
|
98
102
|
"proceedToCheckout": "המשך לתשלום",
|
|
99
103
|
"orderSummary": "סיכום הזמנה",
|
|
100
104
|
"taxAtCheckout": "יחושב בקופה",
|
|
101
|
-
"youMightAlsoNeed": "אולי גם תצטרכו"
|
|
105
|
+
"youMightAlsoNeed": "אולי גם תצטרכו",
|
|
106
|
+
"freeShippingQualified": "!יש לך משלוח חינם",
|
|
107
|
+
"freeShippingRemaining": "חסרים לך {amount} למשלוח חינם!",
|
|
108
|
+
"upgradeFor": "שדרגו ל-{name} בתוספת של {amount} בלבד",
|
|
109
|
+
"upgrade": "שדרג",
|
|
110
|
+
"upgrading": "...משדרג",
|
|
111
|
+
"dismissUpgrade": "סגור",
|
|
112
|
+
"bundleOffers": "חבילה וחיסכון",
|
|
113
|
+
"addBundleItem": "הוסף וחסוך",
|
|
114
|
+
"addingBundle": "...מוסיף"
|
|
102
115
|
},
|
|
103
116
|
"checkout": {
|
|
104
117
|
"pageTitle": "תשלום",
|
|
@@ -155,7 +168,8 @@
|
|
|
155
168
|
"failedToSetDeliveryMethod": "שגיאה בהגדרת שיטת המשלוח",
|
|
156
169
|
"failedToSelectPickup": "שגיאה בבחירת נקודת האיסוף",
|
|
157
170
|
"failedToLoadPaymentSdk": "שגיאה בטעינת מערכת התשלום",
|
|
158
|
-
"paymentTimedOut": "פג תוקף הפעלת התשלום. אנא נסו שנית."
|
|
171
|
+
"paymentTimedOut": "פג תוקף הפעלת התשלום. אנא נסו שנית.",
|
|
172
|
+
"addToYourOrder": "הוסיפו להזמנה"
|
|
159
173
|
},
|
|
160
174
|
"checkoutForm": {
|
|
161
175
|
"email": "אימייל",
|
package/package.json
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
CartRecommendationsResponse,
|
|
7
|
+
CartUpgradesResponse,
|
|
8
|
+
CartBundlesResponse,
|
|
9
|
+
} from 'brainerce';
|
|
6
10
|
import { getClient } from '@/lib/brainerce';
|
|
7
11
|
import { useCart } from '@/providers/store-provider';
|
|
12
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
13
|
import { CartItem } from '@/components/cart/cart-item';
|
|
14
|
+
import { CartUpgradeBanner } from '@/components/cart/cart-upgrade-banner';
|
|
15
|
+
import { CartBundleOfferCard } from '@/components/cart/cart-bundle-offer';
|
|
9
16
|
import { CartSummary } from '@/components/cart/cart-summary';
|
|
10
17
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
11
18
|
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
19
|
+
import { FreeShippingBar } from '@/components/cart/free-shipping-bar';
|
|
12
20
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
13
21
|
import { CartRecommendationSection } from '@/components/products/recommendation-section';
|
|
14
22
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -16,9 +24,12 @@ import { useTranslations } from '@/lib/translations';
|
|
|
16
24
|
|
|
17
25
|
export default function CartPage() {
|
|
18
26
|
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
27
|
+
const { storeInfo } = useStoreInfo();
|
|
19
28
|
const t = useTranslations('cart');
|
|
20
29
|
const tc = useTranslations('common');
|
|
21
30
|
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
31
|
+
const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
|
|
32
|
+
const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
|
|
22
33
|
|
|
23
34
|
// Load cross-sell recommendations when cart changes
|
|
24
35
|
useEffect(() => {
|
|
@@ -33,6 +44,36 @@ export default function CartPage() {
|
|
|
33
44
|
.catch(() => {});
|
|
34
45
|
}, [cart?.id, cart?.items.length]);
|
|
35
46
|
|
|
47
|
+
// Load upgrade suggestions when cart changes
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (
|
|
50
|
+
!cart?.id ||
|
|
51
|
+
cart.items.length === 0 ||
|
|
52
|
+
storeInfo?.upsell?.cartUpgradeBannerEnabled === false
|
|
53
|
+
) {
|
|
54
|
+
setUpgrades(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const client = getClient();
|
|
58
|
+
client
|
|
59
|
+
.getCartUpgrades(cart.id)
|
|
60
|
+
.then(setUpgrades)
|
|
61
|
+
.catch(() => {});
|
|
62
|
+
}, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartUpgradeBannerEnabled]);
|
|
63
|
+
|
|
64
|
+
// Load bundle offers when cart changes
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartBundleEnabled === false) {
|
|
67
|
+
setBundles(null);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const client = getClient();
|
|
71
|
+
client
|
|
72
|
+
.getCartBundles(cart.id)
|
|
73
|
+
.then(setBundles)
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
}, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartBundleEnabled]);
|
|
76
|
+
|
|
36
77
|
if (cartLoading) {
|
|
37
78
|
return (
|
|
38
79
|
<div className="flex min-h-[60vh] items-center justify-center">
|
|
@@ -92,10 +133,30 @@ export default function CartPage() {
|
|
|
92
133
|
{/* Cart items */}
|
|
93
134
|
<div>
|
|
94
135
|
{cart.items.map((item) => (
|
|
95
|
-
<
|
|
136
|
+
<div key={item.id}>
|
|
137
|
+
<CartItem item={item} onUpdate={refreshCart} />
|
|
138
|
+
{upgrades?.upgrades?.[item.productId] && (
|
|
139
|
+
<CartUpgradeBanner
|
|
140
|
+
suggestion={upgrades.upgrades[item.productId]}
|
|
141
|
+
cartItem={item}
|
|
142
|
+
onUpgrade={refreshCart}
|
|
143
|
+
className="mb-2 ms-24"
|
|
144
|
+
/>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
96
147
|
))}
|
|
97
148
|
</div>
|
|
98
149
|
|
|
150
|
+
{/* Bundle offers */}
|
|
151
|
+
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
152
|
+
<div className="mt-6 space-y-3">
|
|
153
|
+
<h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
|
|
154
|
+
{bundles.bundles.map((offer) => (
|
|
155
|
+
<CartBundleOfferCard key={offer.id} offer={offer} cartId={cart.id} onAdd={refreshCart} />
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
99
160
|
{/* Coupon input */}
|
|
100
161
|
<div className="border-border mt-6 border-t pt-4">
|
|
101
162
|
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
@@ -105,6 +166,7 @@ export default function CartPage() {
|
|
|
105
166
|
{/* Summary sidebar */}
|
|
106
167
|
<div className="lg:col-span-1">
|
|
107
168
|
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
169
|
+
<FreeShippingBar className="mb-4" />
|
|
108
170
|
<CartSummary />
|
|
109
171
|
|
|
110
172
|
<Link
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
SetShippingAddressDto,
|
|
11
11
|
ShippingDestinations,
|
|
12
12
|
PickupLocation,
|
|
13
|
+
CheckoutBumpsResponse,
|
|
13
14
|
} from 'brainerce';
|
|
14
15
|
import { formatPrice } from 'brainerce';
|
|
15
16
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -20,6 +21,7 @@ import { PaymentStep } from '@/components/checkout/payment-step';
|
|
|
20
21
|
import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
|
|
21
22
|
import { PickupStep } from '@/components/checkout/pickup-step';
|
|
22
23
|
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
24
|
+
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
23
25
|
import { CouponInput } from '@/components/cart/coupon-input';
|
|
24
26
|
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
25
27
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -56,6 +58,9 @@ function CheckoutContent() {
|
|
|
56
58
|
phone?: string;
|
|
57
59
|
} | null>(null);
|
|
58
60
|
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
61
|
+
const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
|
|
62
|
+
const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
|
|
63
|
+
const [bumpLoading, setBumpLoading] = useState<string | null>(null);
|
|
59
64
|
|
|
60
65
|
// Check for returning from canceled payment
|
|
61
66
|
const canceled = searchParams.get('canceled') === 'true';
|
|
@@ -158,9 +163,59 @@ function CheckoutContent() {
|
|
|
158
163
|
};
|
|
159
164
|
|
|
160
165
|
initCheckout();
|
|
161
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
166
|
}, [cart?.id, existingCheckoutId]);
|
|
163
167
|
|
|
168
|
+
// Load order bumps when checkout is available
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
|
|
171
|
+
setOrderBumps(null);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const client = getClient();
|
|
175
|
+
client
|
|
176
|
+
.getCheckoutBumps(checkout.id)
|
|
177
|
+
.then((data) => {
|
|
178
|
+
setOrderBumps(data);
|
|
179
|
+
// Detect already-added bumps from cart
|
|
180
|
+
if (cart?.items) {
|
|
181
|
+
const existingBumpIds = new Set<string>();
|
|
182
|
+
for (const item of cart.items) {
|
|
183
|
+
const meta = item.metadata as Record<string, unknown> | undefined;
|
|
184
|
+
if (meta?.isOrderBump && meta?.orderBumpId) {
|
|
185
|
+
existingBumpIds.add(meta.orderBumpId as string);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
setAddedBumpIds(existingBumpIds);
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.catch(() => {});
|
|
192
|
+
}, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
|
|
193
|
+
|
|
194
|
+
// Handle bump toggle
|
|
195
|
+
async function handleBumpToggle(bumpId: string, add: boolean) {
|
|
196
|
+
if (!cart?.id || bumpLoading) return;
|
|
197
|
+
try {
|
|
198
|
+
setBumpLoading(bumpId);
|
|
199
|
+
const client = getClient();
|
|
200
|
+
if (add) {
|
|
201
|
+
await client.addOrderBump(cart.id, bumpId);
|
|
202
|
+
setAddedBumpIds((prev) => new Set([...prev, bumpId]));
|
|
203
|
+
} else {
|
|
204
|
+
await client.removeOrderBump(cart.id, bumpId);
|
|
205
|
+
setAddedBumpIds((prev) => {
|
|
206
|
+
const next = new Set(prev);
|
|
207
|
+
next.delete(bumpId);
|
|
208
|
+
return next;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
await refreshCart();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error('Failed to toggle order bump:', err);
|
|
214
|
+
} finally {
|
|
215
|
+
setBumpLoading(null);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
164
219
|
// Handle shipping address submission
|
|
165
220
|
async function handleAddressSubmit(
|
|
166
221
|
address: SetShippingAddressDto,
|
|
@@ -663,6 +718,24 @@ function CheckoutContent() {
|
|
|
663
718
|
)
|
|
664
719
|
)}
|
|
665
720
|
|
|
721
|
+
{/* Order bumps */}
|
|
722
|
+
{orderBumps?.bumps && orderBumps.bumps.length > 0 && (
|
|
723
|
+
<div className="border-border space-y-2 border-t pt-4">
|
|
724
|
+
<p className="text-foreground text-xs font-semibold uppercase tracking-wide">
|
|
725
|
+
{t('addToYourOrder')}
|
|
726
|
+
</p>
|
|
727
|
+
{orderBumps.bumps.map((bump) => (
|
|
728
|
+
<OrderBumpCard
|
|
729
|
+
key={bump.id}
|
|
730
|
+
bump={bump}
|
|
731
|
+
isAdded={addedBumpIds.has(bump.id)}
|
|
732
|
+
onToggle={handleBumpToggle}
|
|
733
|
+
loading={bumpLoading === bump.id}
|
|
734
|
+
/>
|
|
735
|
+
))}
|
|
736
|
+
</div>
|
|
737
|
+
)}
|
|
738
|
+
|
|
666
739
|
{/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
|
|
667
740
|
{cart &&
|
|
668
741
|
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
@@ -16,6 +16,7 @@ import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
|
16
16
|
import { VariantSelector } from '@/components/products/variant-selector';
|
|
17
17
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
18
18
|
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
19
|
+
import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
|
|
19
20
|
import { useTranslations } from '@/lib/translations';
|
|
20
21
|
import { cn } from '@/lib/utils';
|
|
21
22
|
|
|
@@ -454,6 +455,15 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
454
455
|
</div>
|
|
455
456
|
</div>
|
|
456
457
|
|
|
458
|
+
{/* Frequently Bought Together (cross-sells) */}
|
|
459
|
+
{recommendations?.crossSells && recommendations.crossSells.length > 0 && (
|
|
460
|
+
<FrequentlyBoughtTogether
|
|
461
|
+
items={recommendations.crossSells}
|
|
462
|
+
currentProduct={product}
|
|
463
|
+
className="mt-12"
|
|
464
|
+
/>
|
|
465
|
+
)}
|
|
466
|
+
|
|
457
467
|
{/* Upsells */}
|
|
458
468
|
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
459
469
|
<RecommendationSection
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
|
+
import { useTranslations } from '@/lib/translations';
|
|
9
|
+
import { cn } from '@/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CartBundleOfferCardProps {
|
|
12
|
+
offer: CartBundleOfferType;
|
|
13
|
+
cartId: string;
|
|
14
|
+
onAdd: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
19
|
+
const { storeInfo } = useStoreInfo();
|
|
20
|
+
const t = useTranslations('cart');
|
|
21
|
+
const currency = storeInfo?.currency || 'USD';
|
|
22
|
+
const [adding, setAdding] = useState(false);
|
|
23
|
+
|
|
24
|
+
const product = offer.bundleProduct;
|
|
25
|
+
const firstImage = product.images?.[0];
|
|
26
|
+
const imageUrl = firstImage
|
|
27
|
+
? typeof firstImage === 'string'
|
|
28
|
+
? firstImage
|
|
29
|
+
: firstImage.url
|
|
30
|
+
: null;
|
|
31
|
+
const originalPrice = parseFloat(offer.originalPrice);
|
|
32
|
+
const discountedPrice = parseFloat(offer.discountedPrice);
|
|
33
|
+
const discountLabel =
|
|
34
|
+
offer.discountType === 'PERCENTAGE'
|
|
35
|
+
? `${offer.discountValue}%`
|
|
36
|
+
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
37
|
+
|
|
38
|
+
async function handleAdd() {
|
|
39
|
+
if (adding) return;
|
|
40
|
+
try {
|
|
41
|
+
setAdding(true);
|
|
42
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
43
|
+
const client = getClient();
|
|
44
|
+
await client.addBundleToCart(cartId, offer.id);
|
|
45
|
+
onAdd();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('Failed to add bundle item:', err);
|
|
48
|
+
} finally {
|
|
49
|
+
setAdding(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
className={cn(
|
|
56
|
+
'bg-background border-border flex items-center gap-4 rounded-lg border p-4',
|
|
57
|
+
className
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{/* Product image */}
|
|
61
|
+
<div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
|
|
62
|
+
{imageUrl ? (
|
|
63
|
+
<Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
|
|
64
|
+
) : (
|
|
65
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
|
|
66
|
+
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
67
|
+
<path
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
strokeWidth={1.5}
|
|
71
|
+
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"
|
|
72
|
+
/>
|
|
73
|
+
</svg>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Details */}
|
|
79
|
+
<div className="min-w-0 flex-1">
|
|
80
|
+
<p className="text-foreground text-sm font-medium">{offer.name}</p>
|
|
81
|
+
{offer.description && (
|
|
82
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
|
|
83
|
+
)}
|
|
84
|
+
<div className="mt-1 flex items-center gap-2">
|
|
85
|
+
<span className="text-muted-foreground text-sm line-through">
|
|
86
|
+
{formatPrice(originalPrice, { currency }) as string}
|
|
87
|
+
</span>
|
|
88
|
+
<span className="text-foreground text-sm font-semibold">
|
|
89
|
+
{formatPrice(discountedPrice, { currency }) as string}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
|
|
92
|
+
-{discountLabel}
|
|
93
|
+
</span>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Add button */}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={handleAdd}
|
|
101
|
+
disabled={adding}
|
|
102
|
+
className={cn(
|
|
103
|
+
'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
|
|
104
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/lib/brainerce';
|
|
8
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
9
|
+
import { useTranslations } from '@/lib/translations';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface CartUpgradeBannerProps {
|
|
13
|
+
suggestion: CartUpgradeSuggestion;
|
|
14
|
+
cartItem: CartItemType;
|
|
15
|
+
onUpgrade: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CartUpgradeBanner({
|
|
20
|
+
suggestion,
|
|
21
|
+
cartItem,
|
|
22
|
+
onUpgrade,
|
|
23
|
+
className,
|
|
24
|
+
}: CartUpgradeBannerProps) {
|
|
25
|
+
const { storeInfo } = useStoreInfo();
|
|
26
|
+
const t = useTranslations('cart');
|
|
27
|
+
const currency = storeInfo?.currency || 'USD';
|
|
28
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
29
|
+
const [dismissed, setDismissed] = useState(false);
|
|
30
|
+
|
|
31
|
+
const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
try {
|
|
35
|
+
if (sessionStorage.getItem(storageKey)) {
|
|
36
|
+
setDismissed(true);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
}, [storageKey]);
|
|
42
|
+
|
|
43
|
+
if (dismissed) return null;
|
|
44
|
+
|
|
45
|
+
const target = suggestion.targetProduct;
|
|
46
|
+
const firstImage = target.images?.[0];
|
|
47
|
+
const imageUrl = firstImage
|
|
48
|
+
? typeof firstImage === 'string'
|
|
49
|
+
? firstImage
|
|
50
|
+
: firstImage.url
|
|
51
|
+
: null;
|
|
52
|
+
const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
|
|
53
|
+
|
|
54
|
+
function handleDismiss() {
|
|
55
|
+
try {
|
|
56
|
+
sessionStorage.setItem(storageKey, '1');
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
setDismissed(true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleUpgrade() {
|
|
64
|
+
if (upgrading) return;
|
|
65
|
+
try {
|
|
66
|
+
setUpgrading(true);
|
|
67
|
+
const client = getClient();
|
|
68
|
+
await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
|
|
69
|
+
await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
|
|
70
|
+
onUpgrade();
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error('Failed to upgrade cart item:', err);
|
|
73
|
+
} finally {
|
|
74
|
+
setUpgrading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
|
|
82
|
+
className
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{/* Dismiss button */}
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={handleDismiss}
|
|
89
|
+
className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
|
|
90
|
+
aria-label={t('dismissUpgrade')}
|
|
91
|
+
>
|
|
92
|
+
<svg
|
|
93
|
+
className="h-4 w-4"
|
|
94
|
+
fill="none"
|
|
95
|
+
viewBox="0 0 24 24"
|
|
96
|
+
stroke="currentColor"
|
|
97
|
+
strokeWidth={2}
|
|
98
|
+
>
|
|
99
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
100
|
+
</svg>
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
{/* Product image */}
|
|
104
|
+
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
105
|
+
{imageUrl ? (
|
|
106
|
+
<Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
|
|
107
|
+
) : (
|
|
108
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
|
|
109
|
+
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
110
|
+
<path
|
|
111
|
+
strokeLinecap="round"
|
|
112
|
+
strokeLinejoin="round"
|
|
113
|
+
strokeWidth={1.5}
|
|
114
|
+
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"
|
|
115
|
+
/>
|
|
116
|
+
</svg>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Text */}
|
|
122
|
+
<div className="min-w-0 flex-1">
|
|
123
|
+
<p className="text-foreground text-sm font-medium">
|
|
124
|
+
{t('upgradeFor', { name: target.name, amount: formattedDelta })}
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Upgrade button */}
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={handleUpgrade}
|
|
132
|
+
disabled={upgrading}
|
|
133
|
+
className={cn(
|
|
134
|
+
'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
|
|
135
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{upgrading ? t('upgrading') : t('upgrade')}
|
|
139
|
+
</button>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface FreeShippingBarProps {
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FreeShippingBar({ className }: FreeShippingBarProps) {
|
|
13
|
+
const t = useTranslations('cart');
|
|
14
|
+
const { storeInfo } = useStoreInfo();
|
|
15
|
+
const { totals } = useCart();
|
|
16
|
+
|
|
17
|
+
const upsell = storeInfo?.upsell;
|
|
18
|
+
const threshold = upsell?.freeShippingThreshold;
|
|
19
|
+
const enabled = upsell?.freeShippingBarEnabled !== false;
|
|
20
|
+
|
|
21
|
+
// Don't render if disabled or no threshold configured
|
|
22
|
+
if (!enabled || !threshold || threshold <= 0) return null;
|
|
23
|
+
|
|
24
|
+
const subtotal = totals.subtotal;
|
|
25
|
+
const remaining = Math.max(0, threshold - subtotal);
|
|
26
|
+
const progress = Math.min(100, (subtotal / threshold) * 100);
|
|
27
|
+
const qualified = remaining <= 0;
|
|
28
|
+
const currency = storeInfo?.currency || 'USD';
|
|
29
|
+
|
|
30
|
+
// Don't show if already qualified
|
|
31
|
+
if (qualified) {
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
|
|
34
|
+
<div className="flex items-center gap-2 text-sm font-medium text-green-700">
|
|
35
|
+
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
36
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
37
|
+
</svg>
|
|
38
|
+
{t('freeShippingQualified')}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
|
|
46
|
+
<p className="mb-2 text-sm text-amber-800">
|
|
47
|
+
{t('freeShippingRemaining', {
|
|
48
|
+
amount: formatPrice(remaining, { currency }) as string,
|
|
49
|
+
})}
|
|
50
|
+
</p>
|
|
51
|
+
<div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
|
|
52
|
+
<div
|
|
53
|
+
className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
|
|
54
|
+
style={{ width: `${progress}%` }}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import type { OrderBump } from 'brainerce';
|
|
5
|
+
import { formatPrice } from 'brainerce';
|
|
6
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
+
import { useTranslations } from '@/lib/translations';
|
|
8
|
+
import { cn } from '@/lib/utils';
|
|
9
|
+
|
|
10
|
+
interface OrderBumpCardProps {
|
|
11
|
+
bump: OrderBump;
|
|
12
|
+
isAdded: boolean;
|
|
13
|
+
onToggle: (bumpId: string, add: boolean) => void;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: OrderBumpCardProps) {
|
|
19
|
+
const { storeInfo } = useStoreInfo();
|
|
20
|
+
const t = useTranslations('checkout');
|
|
21
|
+
const currency = storeInfo?.currency || 'USD';
|
|
22
|
+
|
|
23
|
+
const product = bump.bumpProduct;
|
|
24
|
+
const firstImage = product.images?.[0];
|
|
25
|
+
const imageUrl = firstImage
|
|
26
|
+
? typeof firstImage === 'string'
|
|
27
|
+
? firstImage
|
|
28
|
+
: firstImage.url
|
|
29
|
+
: null;
|
|
30
|
+
const originalPrice = parseFloat(bump.originalPrice);
|
|
31
|
+
const hasDiscount = bump.discountedPrice != null;
|
|
32
|
+
const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<label
|
|
36
|
+
className={cn(
|
|
37
|
+
'border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
|
38
|
+
isAdded && 'border-primary bg-primary/5',
|
|
39
|
+
loading && 'pointer-events-none opacity-60',
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
<input
|
|
44
|
+
type="checkbox"
|
|
45
|
+
checked={isAdded}
|
|
46
|
+
onChange={() => onToggle(bump.id, !isAdded)}
|
|
47
|
+
disabled={loading}
|
|
48
|
+
className="mt-1 h-4 w-4 shrink-0 rounded"
|
|
49
|
+
/>
|
|
50
|
+
|
|
51
|
+
{/* Image */}
|
|
52
|
+
{imageUrl && (
|
|
53
|
+
<div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
|
|
54
|
+
<Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{/* Content */}
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<p className="text-foreground text-sm font-medium">{bump.title}</p>
|
|
61
|
+
{bump.description && (
|
|
62
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
|
|
63
|
+
)}
|
|
64
|
+
<div className="mt-1 flex items-center gap-2">
|
|
65
|
+
{hasDiscount ? (
|
|
66
|
+
<>
|
|
67
|
+
<span className="text-muted-foreground text-xs line-through">
|
|
68
|
+
{formatPrice(originalPrice, { currency }) as string}
|
|
69
|
+
</span>
|
|
70
|
+
<span className="text-foreground text-sm font-semibold">
|
|
71
|
+
{formatPrice(discountedPrice!, { currency }) as string}
|
|
72
|
+
</span>
|
|
73
|
+
</>
|
|
74
|
+
) : (
|
|
75
|
+
<span className="text-foreground text-sm font-semibold">
|
|
76
|
+
{formatPrice(originalPrice, { currency }) as string}
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</label>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +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
|
+
}
|