create-brainerce-store 1.14.5 → 1.15.1
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 +5 -1
- package/messages/he.json +5 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +72 -73
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +200 -144
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +277 -147
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.15.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
|
@@ -116,6 +116,8 @@
|
|
|
116
116
|
"pickUpInStore": "Pick up in store",
|
|
117
117
|
"pickUpInStoreDesc": "Collect from a pickup location",
|
|
118
118
|
"shippingAddress": "Shipping Address",
|
|
119
|
+
"contactInfo": "Contact Information",
|
|
120
|
+
"stepContactInfo": "Contact",
|
|
119
121
|
"changeMethod": "Change method",
|
|
120
122
|
"editAddress": "Edit address",
|
|
121
123
|
"shippingMethod": "Shipping Method",
|
|
@@ -168,6 +170,7 @@
|
|
|
168
170
|
"phone": "Phone",
|
|
169
171
|
"phonePlaceholder": "+1234567890 (optional)",
|
|
170
172
|
"continueToShipping": "Continue to Shipping",
|
|
173
|
+
"continueToPayment": "Continue to Payment",
|
|
171
174
|
"countryPlaceholder": "e.g. US, IL, GB",
|
|
172
175
|
"emailRequired": "Email is required",
|
|
173
176
|
"emailInvalid": "Please enter a valid email",
|
|
@@ -180,7 +183,8 @@
|
|
|
180
183
|
"privacyAcceptPrefix": "I have read and agree to the",
|
|
181
184
|
"privacyPolicyLink": "Privacy Policy",
|
|
182
185
|
"privacyRequired": "You must accept the privacy policy to continue",
|
|
183
|
-
"acceptsMarketing": "Send me news, promotions, and updates by email"
|
|
186
|
+
"acceptsMarketing": "Send me news, promotions, and updates by email",
|
|
187
|
+
"saveDetailsForNextTime": "Save my details for faster checkout next time"
|
|
184
188
|
},
|
|
185
189
|
"auth": {
|
|
186
190
|
"loginPageTitle": "Sign In",
|
package/messages/he.json
CHANGED
|
@@ -116,6 +116,8 @@
|
|
|
116
116
|
"pickUpInStore": "איסוף מהחנות",
|
|
117
117
|
"pickUpInStoreDesc": "איסוף מנקודת איסוף",
|
|
118
118
|
"shippingAddress": "כתובת למשלוח",
|
|
119
|
+
"contactInfo": "פרטי קשר",
|
|
120
|
+
"stepContactInfo": "פרטי קשר",
|
|
119
121
|
"changeMethod": "שנה שיטה",
|
|
120
122
|
"editAddress": "ערוך כתובת",
|
|
121
123
|
"shippingMethod": "שיטת משלוח",
|
|
@@ -168,6 +170,7 @@
|
|
|
168
170
|
"phone": "טלפון",
|
|
169
171
|
"phonePlaceholder": "+972501234567 (אופציונלי)",
|
|
170
172
|
"continueToShipping": "המשך למשלוח",
|
|
173
|
+
"continueToPayment": "המשך לתשלום",
|
|
171
174
|
"countryPlaceholder": "לדוגמה: IL, US, GB",
|
|
172
175
|
"emailRequired": "אימייל הוא שדה חובה",
|
|
173
176
|
"emailInvalid": "כתובת אימייל לא תקינה",
|
|
@@ -180,7 +183,8 @@
|
|
|
180
183
|
"privacyAcceptPrefix": "קראתי ואני מסכים/ה ל",
|
|
181
184
|
"privacyPolicyLink": "מדיניות הפרטיות",
|
|
182
185
|
"privacyRequired": "יש לאשר את מדיניות הפרטיות כדי להמשיך",
|
|
183
|
-
"acceptsMarketing": "שלחו לי חדשות, מבצעים ועדכונים במייל"
|
|
186
|
+
"acceptsMarketing": "שלחו לי חדשות, מבצעים ועדכונים במייל",
|
|
187
|
+
"saveDetailsForNextTime": "שמרו את הפרטים שלי לתשלום מהיר בפעם הבאה"
|
|
184
188
|
},
|
|
185
189
|
"auth": {
|
|
186
190
|
"loginPageTitle": "התחברות",
|
package/package.json
CHANGED
|
@@ -36,7 +36,6 @@ function CheckoutContent() {
|
|
|
36
36
|
const currency = storeInfo?.currency || 'USD';
|
|
37
37
|
const t = useTranslations('checkout');
|
|
38
38
|
const tc = useTranslations('common');
|
|
39
|
-
const tAddr = useTranslations('checkoutAddress');
|
|
40
39
|
|
|
41
40
|
const [step, setStep] = useState<CheckoutStep>('address');
|
|
42
41
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
@@ -50,23 +49,29 @@ function CheckoutContent() {
|
|
|
50
49
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
51
50
|
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
52
51
|
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
53
|
-
const [
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
const [prefillCustomer, setPrefillCustomer] = useState<{
|
|
53
|
+
email: string;
|
|
54
|
+
firstName?: string;
|
|
55
|
+
lastName?: string;
|
|
56
|
+
phone?: string;
|
|
57
|
+
} | null>(null);
|
|
58
|
+
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
58
59
|
|
|
59
60
|
// Check for returning from canceled payment
|
|
60
61
|
const canceled = searchParams.get('canceled') === 'true';
|
|
61
62
|
const existingCheckoutId = searchParams.get('checkout_id');
|
|
62
63
|
|
|
63
|
-
// Pre-fill address
|
|
64
|
+
// Pre-fill address and customer data from profile when logged in
|
|
64
65
|
useEffect(() => {
|
|
65
66
|
if (!isLoggedIn) return;
|
|
66
67
|
getClient()
|
|
67
68
|
.getCheckoutPrefillData()
|
|
68
69
|
.then((data) => {
|
|
69
|
-
if (data.
|
|
70
|
+
if (data.customer) setPrefillCustomer(data.customer);
|
|
71
|
+
if (data.shippingAddress) {
|
|
72
|
+
setPrefillAddress(data.shippingAddress);
|
|
73
|
+
setHasSavedAddress(true);
|
|
74
|
+
}
|
|
70
75
|
})
|
|
71
76
|
.catch(() => {});
|
|
72
77
|
}, [isLoggedIn]);
|
|
@@ -98,7 +103,8 @@ function CheckoutContent() {
|
|
|
98
103
|
);
|
|
99
104
|
setIsAllDigital(allDigital);
|
|
100
105
|
if (allDigital) {
|
|
101
|
-
|
|
106
|
+
// Digital products: show contact info step if email not set, else payment
|
|
107
|
+
setStep(existing.email ? 'payment' : 'address');
|
|
102
108
|
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
103
109
|
setDeliveryType('pickup');
|
|
104
110
|
setStep('payment');
|
|
@@ -120,13 +126,13 @@ function CheckoutContent() {
|
|
|
120
126
|
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
121
127
|
setCheckout(newCheckout);
|
|
122
128
|
|
|
123
|
-
// If all items are downloadable, skip shipping
|
|
129
|
+
// If all items are downloadable, skip shipping — show contact info step
|
|
124
130
|
const allDigital = newCheckout.lineItems.every(
|
|
125
131
|
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
126
132
|
);
|
|
127
133
|
setIsAllDigital(allDigital);
|
|
128
134
|
if (allDigital) {
|
|
129
|
-
setStep('
|
|
135
|
+
setStep('address');
|
|
130
136
|
return;
|
|
131
137
|
}
|
|
132
138
|
} else {
|
|
@@ -155,7 +161,7 @@ function CheckoutContent() {
|
|
|
155
161
|
// Handle shipping address submission
|
|
156
162
|
async function handleAddressSubmit(
|
|
157
163
|
address: SetShippingAddressDto,
|
|
158
|
-
consent: { acceptsMarketing: boolean }
|
|
164
|
+
consent: { acceptsMarketing: boolean; saveDetails: boolean }
|
|
159
165
|
) {
|
|
160
166
|
if (!checkout) return;
|
|
161
167
|
|
|
@@ -164,9 +170,23 @@ function CheckoutContent() {
|
|
|
164
170
|
setError(null);
|
|
165
171
|
const client = getClient();
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
173
|
+
if (isAllDigital) {
|
|
174
|
+
// Digital products: set customer info only, skip shipping
|
|
175
|
+
const updated = await client.setCheckoutCustomer(checkout.id, {
|
|
176
|
+
email: address.email,
|
|
177
|
+
firstName: address.firstName,
|
|
178
|
+
lastName: address.lastName,
|
|
179
|
+
phone: address.phone,
|
|
180
|
+
acceptsMarketing: consent.acceptsMarketing,
|
|
181
|
+
});
|
|
182
|
+
setCheckout(updated);
|
|
183
|
+
setStep('payment');
|
|
184
|
+
} else {
|
|
185
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
186
|
+
setCheckout(response.checkout);
|
|
187
|
+
setShippingRates(response.rates);
|
|
188
|
+
setStep('shipping');
|
|
189
|
+
}
|
|
170
190
|
|
|
171
191
|
// Update marketing preference for logged-in users
|
|
172
192
|
if (isLoggedIn) {
|
|
@@ -177,13 +197,25 @@ function CheckoutContent() {
|
|
|
177
197
|
}
|
|
178
198
|
}
|
|
179
199
|
|
|
180
|
-
//
|
|
181
|
-
if (isLoggedIn && !
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
// Save address to profile if checkbox was checked and no existing saved address
|
|
201
|
+
if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
|
|
202
|
+
try {
|
|
203
|
+
await client.addMyAddress({
|
|
204
|
+
firstName: address.firstName,
|
|
205
|
+
lastName: address.lastName,
|
|
206
|
+
line1: address.line1,
|
|
207
|
+
line2: address.line2,
|
|
208
|
+
city: address.city,
|
|
209
|
+
region: address.region,
|
|
210
|
+
postalCode: address.postalCode,
|
|
211
|
+
country: address.country,
|
|
212
|
+
phone: address.phone,
|
|
213
|
+
isDefault: true,
|
|
214
|
+
});
|
|
215
|
+
} catch {
|
|
216
|
+
// non-critical
|
|
217
|
+
}
|
|
184
218
|
}
|
|
185
|
-
|
|
186
|
-
setStep('shipping');
|
|
187
219
|
} catch (err) {
|
|
188
220
|
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
189
221
|
setError(message);
|
|
@@ -192,30 +224,6 @@ function CheckoutContent() {
|
|
|
192
224
|
}
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
async function handleSaveAddressToProfile() {
|
|
196
|
-
if (!lastSubmittedAddress) return;
|
|
197
|
-
setSavingAddress(true);
|
|
198
|
-
try {
|
|
199
|
-
await getClient().addMyAddress({
|
|
200
|
-
firstName: lastSubmittedAddress.firstName,
|
|
201
|
-
lastName: lastSubmittedAddress.lastName,
|
|
202
|
-
line1: lastSubmittedAddress.line1,
|
|
203
|
-
line2: lastSubmittedAddress.line2,
|
|
204
|
-
city: lastSubmittedAddress.city,
|
|
205
|
-
region: lastSubmittedAddress.region,
|
|
206
|
-
postalCode: lastSubmittedAddress.postalCode,
|
|
207
|
-
country: lastSubmittedAddress.country,
|
|
208
|
-
phone: lastSubmittedAddress.phone,
|
|
209
|
-
isDefault: true,
|
|
210
|
-
});
|
|
211
|
-
} catch {
|
|
212
|
-
// ignore
|
|
213
|
-
} finally {
|
|
214
|
-
setSavingAddress(false);
|
|
215
|
-
setShowSavePrompt(false);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
227
|
// Handle shipping method selection
|
|
220
228
|
async function handleShippingSelect(rateId: string) {
|
|
221
229
|
if (!checkout) return;
|
|
@@ -345,7 +353,10 @@ function CheckoutContent() {
|
|
|
345
353
|
}
|
|
346
354
|
|
|
347
355
|
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
348
|
-
? [
|
|
356
|
+
? [
|
|
357
|
+
{ key: 'address', label: t('stepContactInfo') },
|
|
358
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
359
|
+
]
|
|
349
360
|
: pickupLocations.length > 0
|
|
350
361
|
? deliveryType === 'pickup'
|
|
351
362
|
? [
|
|
@@ -456,8 +467,10 @@ function CheckoutContent() {
|
|
|
456
467
|
{step === 'address' && (
|
|
457
468
|
<div>
|
|
458
469
|
<div className="mb-4 flex items-center justify-between">
|
|
459
|
-
<h2 className="text-foreground text-lg font-semibold">
|
|
460
|
-
|
|
470
|
+
<h2 className="text-foreground text-lg font-semibold">
|
|
471
|
+
{isAllDigital ? t('contactInfo') : t('shippingAddress')}
|
|
472
|
+
</h2>
|
|
473
|
+
{!isAllDigital && pickupLocations.length > 0 && (
|
|
461
474
|
<button
|
|
462
475
|
type="button"
|
|
463
476
|
onClick={() => setStep('method')}
|
|
@@ -467,33 +480,12 @@ function CheckoutContent() {
|
|
|
467
480
|
</button>
|
|
468
481
|
)}
|
|
469
482
|
</div>
|
|
470
|
-
{/* Save-to-profile prompt */}
|
|
471
|
-
{showSavePrompt && (
|
|
472
|
-
<div className="border-border bg-muted/50 mb-4 flex items-center justify-between gap-3 rounded-lg border px-4 py-3 text-sm">
|
|
473
|
-
<span className="text-foreground">{tAddr('saveToProfile')}</span>
|
|
474
|
-
<div className="flex gap-2">
|
|
475
|
-
<button
|
|
476
|
-
type="button"
|
|
477
|
-
onClick={handleSaveAddressToProfile}
|
|
478
|
-
disabled={savingAddress}
|
|
479
|
-
className="bg-primary text-primary-foreground rounded px-3 py-1 text-xs font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
480
|
-
>
|
|
481
|
-
{savingAddress ? '...' : tAddr('saveYes')}
|
|
482
|
-
</button>
|
|
483
|
-
<button
|
|
484
|
-
type="button"
|
|
485
|
-
onClick={() => setShowSavePrompt(false)}
|
|
486
|
-
className="text-muted-foreground hover:text-foreground rounded px-3 py-1 text-xs transition-colors"
|
|
487
|
-
>
|
|
488
|
-
{tAddr('saveNo')}
|
|
489
|
-
</button>
|
|
490
|
-
</div>
|
|
491
|
-
</div>
|
|
492
|
-
)}
|
|
493
483
|
<CheckoutForm
|
|
494
484
|
onSubmit={handleAddressSubmit}
|
|
495
485
|
loading={loading}
|
|
496
|
-
destinations={destinations}
|
|
486
|
+
destinations={isAllDigital ? null : destinations}
|
|
487
|
+
showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
|
|
488
|
+
emailOnly={isAllDigital}
|
|
497
489
|
initialValues={
|
|
498
490
|
checkout?.shippingAddress
|
|
499
491
|
? {
|
|
@@ -521,7 +513,14 @@ function CheckoutContent() {
|
|
|
521
513
|
country: prefillAddress.country,
|
|
522
514
|
phone: prefillAddress.phone || '',
|
|
523
515
|
}
|
|
524
|
-
:
|
|
516
|
+
: prefillCustomer
|
|
517
|
+
? {
|
|
518
|
+
email: prefillCustomer.email,
|
|
519
|
+
firstName: prefillCustomer.firstName || '',
|
|
520
|
+
lastName: prefillCustomer.lastName || '',
|
|
521
|
+
phone: prefillCustomer.phone || '',
|
|
522
|
+
}
|
|
523
|
+
: undefined
|
|
525
524
|
}
|
|
526
525
|
/>
|
|
527
526
|
</div>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react';
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
4
|
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
5
5
|
import { useTranslations } from '@/lib/translations';
|
|
6
6
|
import { cn } from '@/lib/utils';
|
|
7
7
|
|
|
8
8
|
interface CheckoutConsent {
|
|
9
9
|
acceptsMarketing: boolean;
|
|
10
|
+
saveDetails: boolean;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface CheckoutFormProps {
|
|
@@ -15,6 +16,8 @@ interface CheckoutFormProps {
|
|
|
15
16
|
initialValues?: Partial<SetShippingAddressDto>;
|
|
16
17
|
destinations?: ShippingDestinations | null;
|
|
17
18
|
className?: string;
|
|
19
|
+
showSaveDetails?: boolean;
|
|
20
|
+
emailOnly?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export function CheckoutForm({
|
|
@@ -23,6 +26,8 @@ export function CheckoutForm({
|
|
|
23
26
|
initialValues,
|
|
24
27
|
destinations,
|
|
25
28
|
className,
|
|
29
|
+
showSaveDetails = false,
|
|
30
|
+
emailOnly = false,
|
|
26
31
|
}: CheckoutFormProps) {
|
|
27
32
|
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
28
33
|
email: initialValues?.email || '',
|
|
@@ -39,8 +44,28 @@ export function CheckoutForm({
|
|
|
39
44
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
40
45
|
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
41
46
|
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
47
|
+
const [saveDetails, setSaveDetails] = useState(true);
|
|
42
48
|
const t = useTranslations('checkoutForm');
|
|
43
49
|
const tc = useTranslations('common');
|
|
50
|
+
const hasAppliedPrefill = useRef(!!initialValues);
|
|
51
|
+
|
|
52
|
+
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!initialValues || hasAppliedPrefill.current) return;
|
|
55
|
+
hasAppliedPrefill.current = true;
|
|
56
|
+
setFormData((prev) => ({
|
|
57
|
+
email: initialValues.email || prev.email,
|
|
58
|
+
firstName: initialValues.firstName || prev.firstName,
|
|
59
|
+
lastName: initialValues.lastName || prev.lastName,
|
|
60
|
+
line1: initialValues.line1 || prev.line1,
|
|
61
|
+
line2: initialValues.line2 || prev.line2 || '',
|
|
62
|
+
city: initialValues.city || prev.city,
|
|
63
|
+
region: initialValues.region || prev.region || '',
|
|
64
|
+
postalCode: initialValues.postalCode || prev.postalCode,
|
|
65
|
+
country: initialValues.country || prev.country,
|
|
66
|
+
phone: initialValues.phone || prev.phone || '',
|
|
67
|
+
}));
|
|
68
|
+
}, [initialValues]);
|
|
44
69
|
|
|
45
70
|
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
46
71
|
const countryRegions = destinations?.regions[formData.country];
|
|
@@ -61,17 +86,19 @@ export function CheckoutForm({
|
|
|
61
86
|
if (!formData.lastName.trim()) {
|
|
62
87
|
newErrors.lastName = t('lastNameRequired');
|
|
63
88
|
}
|
|
64
|
-
if (!
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
if (!emailOnly) {
|
|
90
|
+
if (!formData.line1.trim()) {
|
|
91
|
+
newErrors.line1 = t('addressRequired');
|
|
92
|
+
}
|
|
93
|
+
if (!formData.city.trim()) {
|
|
94
|
+
newErrors.city = t('cityRequired');
|
|
95
|
+
}
|
|
96
|
+
if (!formData.postalCode.trim()) {
|
|
97
|
+
newErrors.postalCode = t('postalCodeRequired');
|
|
98
|
+
}
|
|
99
|
+
if (!formData.country.trim()) {
|
|
100
|
+
newErrors.country = t('countryRequired');
|
|
101
|
+
}
|
|
75
102
|
}
|
|
76
103
|
if (!privacyAccepted) {
|
|
77
104
|
newErrors.privacy = t('privacyRequired');
|
|
@@ -84,7 +111,7 @@ export function CheckoutForm({
|
|
|
84
111
|
function handleSubmit(e: React.FormEvent) {
|
|
85
112
|
e.preventDefault();
|
|
86
113
|
if (validate()) {
|
|
87
|
-
onSubmit(formData, { acceptsMarketing });
|
|
114
|
+
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
88
115
|
}
|
|
89
116
|
}
|
|
90
117
|
|
|
@@ -160,147 +187,163 @@ export function CheckoutForm({
|
|
|
160
187
|
</div>
|
|
161
188
|
</div>
|
|
162
189
|
|
|
163
|
-
{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
{!emailOnly && (
|
|
191
|
+
<>
|
|
192
|
+
{/* Country + Region row */}
|
|
193
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
194
|
+
<div>
|
|
195
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
196
|
+
{t('country')} <span className="text-destructive">*</span>
|
|
197
|
+
</label>
|
|
198
|
+
{hasCountryOptions ? (
|
|
199
|
+
<select
|
|
200
|
+
id="country"
|
|
201
|
+
value={formData.country}
|
|
202
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
203
|
+
className={cn(
|
|
204
|
+
selectClass,
|
|
205
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
<option value="">{t('selectCountry')}</option>
|
|
209
|
+
{destinations.countries.map((c) => (
|
|
210
|
+
<option key={c.code} value={c.code}>
|
|
211
|
+
{c.name}
|
|
212
|
+
</option>
|
|
213
|
+
))}
|
|
214
|
+
</select>
|
|
215
|
+
) : (
|
|
216
|
+
<input
|
|
217
|
+
id="country"
|
|
218
|
+
type="text"
|
|
219
|
+
value={formData.country}
|
|
220
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
221
|
+
className={cn(
|
|
222
|
+
inputClass,
|
|
223
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
224
|
+
)}
|
|
225
|
+
placeholder={t('countryPlaceholder')}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div>
|
|
232
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
233
|
+
{t('stateRegion')}
|
|
234
|
+
</label>
|
|
235
|
+
{hasRegionOptions ? (
|
|
236
|
+
<select
|
|
237
|
+
id="region"
|
|
238
|
+
value={formData.region || ''}
|
|
239
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
240
|
+
className={cn(selectClass, 'border-border')}
|
|
241
|
+
>
|
|
242
|
+
<option value="">{t('selectRegion')}</option>
|
|
243
|
+
{countryRegions.map((r) => (
|
|
244
|
+
<option key={r.code} value={r.code}>
|
|
245
|
+
{r.name}
|
|
246
|
+
</option>
|
|
247
|
+
))}
|
|
248
|
+
</select>
|
|
249
|
+
) : (
|
|
250
|
+
<input
|
|
251
|
+
id="region"
|
|
252
|
+
type="text"
|
|
253
|
+
value={formData.region || ''}
|
|
254
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
255
|
+
className={cn(inputClass, 'border-border')}
|
|
256
|
+
/>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Address line 1 */}
|
|
262
|
+
<div>
|
|
263
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
264
|
+
{t('address')} <span className="text-destructive">*</span>
|
|
265
|
+
</label>
|
|
184
266
|
<input
|
|
185
|
-
id="
|
|
267
|
+
id="line1"
|
|
186
268
|
type="text"
|
|
187
|
-
value={formData.
|
|
188
|
-
onChange={(e) => updateField('
|
|
189
|
-
className={cn(inputClass, errors.
|
|
190
|
-
placeholder={t('
|
|
269
|
+
value={formData.line1}
|
|
270
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
271
|
+
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
272
|
+
placeholder={t('streetAddress')}
|
|
191
273
|
/>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
</div>
|
|
274
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
275
|
+
</div>
|
|
195
276
|
|
|
196
|
-
|
|
197
|
-
<
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<select
|
|
202
|
-
id="region"
|
|
203
|
-
value={formData.region || ''}
|
|
204
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
205
|
-
className={cn(selectClass, 'border-border')}
|
|
206
|
-
>
|
|
207
|
-
<option value="">{t('selectRegion')}</option>
|
|
208
|
-
{countryRegions.map((r) => (
|
|
209
|
-
<option key={r.code} value={r.code}>
|
|
210
|
-
{r.name}
|
|
211
|
-
</option>
|
|
212
|
-
))}
|
|
213
|
-
</select>
|
|
214
|
-
) : (
|
|
277
|
+
{/* Address line 2 */}
|
|
278
|
+
<div>
|
|
279
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
280
|
+
{t('apartmentSuite')}
|
|
281
|
+
</label>
|
|
215
282
|
<input
|
|
216
|
-
id="
|
|
283
|
+
id="line2"
|
|
217
284
|
type="text"
|
|
218
|
-
value={formData.
|
|
219
|
-
onChange={(e) => updateField('
|
|
285
|
+
value={formData.line2 || ''}
|
|
286
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
220
287
|
className={cn(inputClass, 'border-border')}
|
|
288
|
+
placeholder={t('aptPlaceholder')}
|
|
221
289
|
/>
|
|
222
|
-
|
|
223
|
-
</div>
|
|
224
|
-
</div>
|
|
290
|
+
</div>
|
|
225
291
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
292
|
+
{/* City + Postal code row */}
|
|
293
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
294
|
+
<div>
|
|
295
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
296
|
+
{t('city')} <span className="text-destructive">*</span>
|
|
297
|
+
</label>
|
|
298
|
+
<input
|
|
299
|
+
id="city"
|
|
300
|
+
type="text"
|
|
301
|
+
value={formData.city}
|
|
302
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
303
|
+
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
304
|
+
/>
|
|
305
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
306
|
+
</div>
|
|
241
307
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
308
|
+
<div>
|
|
309
|
+
<label
|
|
310
|
+
htmlFor="postalCode"
|
|
311
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
312
|
+
>
|
|
313
|
+
{t('postalCode')} <span className="text-destructive">*</span>
|
|
314
|
+
</label>
|
|
315
|
+
<input
|
|
316
|
+
id="postalCode"
|
|
317
|
+
type="text"
|
|
318
|
+
value={formData.postalCode}
|
|
319
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
320
|
+
className={cn(
|
|
321
|
+
inputClass,
|
|
322
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
323
|
+
)}
|
|
324
|
+
/>
|
|
325
|
+
{errors.postalCode && (
|
|
326
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
256
330
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
<div>
|
|
274
|
-
<label htmlFor="postalCode" className="text-foreground mb-1 block text-sm font-medium">
|
|
275
|
-
{t('postalCode')} <span className="text-destructive">*</span>
|
|
276
|
-
</label>
|
|
277
|
-
<input
|
|
278
|
-
id="postalCode"
|
|
279
|
-
type="text"
|
|
280
|
-
value={formData.postalCode}
|
|
281
|
-
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
282
|
-
className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
|
|
283
|
-
/>
|
|
284
|
-
{errors.postalCode && (
|
|
285
|
-
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
286
|
-
)}
|
|
287
|
-
</div>
|
|
288
|
-
</div>
|
|
289
|
-
|
|
290
|
-
{/* Phone */}
|
|
291
|
-
<div>
|
|
292
|
-
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
293
|
-
{t('phone')}
|
|
294
|
-
</label>
|
|
295
|
-
<input
|
|
296
|
-
id="phone"
|
|
297
|
-
type="tel"
|
|
298
|
-
value={formData.phone || ''}
|
|
299
|
-
onChange={(e) => updateField('phone', e.target.value)}
|
|
300
|
-
className={cn(inputClass, 'border-border')}
|
|
301
|
-
placeholder={t('phonePlaceholder')}
|
|
302
|
-
/>
|
|
303
|
-
</div>
|
|
331
|
+
{/* Phone */}
|
|
332
|
+
<div>
|
|
333
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
334
|
+
{t('phone')}
|
|
335
|
+
</label>
|
|
336
|
+
<input
|
|
337
|
+
id="phone"
|
|
338
|
+
type="tel"
|
|
339
|
+
value={formData.phone || ''}
|
|
340
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
341
|
+
className={cn(inputClass, 'border-border')}
|
|
342
|
+
placeholder={t('phonePlaceholder')}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</>
|
|
346
|
+
)}
|
|
304
347
|
|
|
305
348
|
{/* Privacy Policy (required) */}
|
|
306
349
|
<div>
|
|
@@ -347,12 +390,25 @@ export function CheckoutForm({
|
|
|
347
390
|
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
348
391
|
</label>
|
|
349
392
|
|
|
393
|
+
{/* Save details for next time (logged-in users only) */}
|
|
394
|
+
{showSaveDetails && (
|
|
395
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
396
|
+
<input
|
|
397
|
+
type="checkbox"
|
|
398
|
+
checked={saveDetails}
|
|
399
|
+
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
400
|
+
className="accent-primary mt-0.5"
|
|
401
|
+
/>
|
|
402
|
+
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
403
|
+
</label>
|
|
404
|
+
)}
|
|
405
|
+
|
|
350
406
|
<button
|
|
351
407
|
type="submit"
|
|
352
408
|
disabled={loading}
|
|
353
409
|
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
354
410
|
>
|
|
355
|
-
{loading ? tc('saving') : t('continueToShipping')}
|
|
411
|
+
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
356
412
|
</button>
|
|
357
413
|
</form>
|
|
358
414
|
);
|
|
@@ -1,147 +1,277 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useMemo } from 'react';
|
|
4
|
-
import type { Product, ProductVariant } from 'brainerce';
|
|
5
|
-
import { getVariantOptions, getStockStatus, formatPrice } from 'brainerce';
|
|
6
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
-
import { cn } from '@/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface VariantSelectorProps {
|
|
10
|
-
product: Product;
|
|
11
|
-
selectedVariant: ProductVariant | null;
|
|
12
|
-
onVariantChange: (variant: ProductVariant) => void;
|
|
13
|
-
className?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface AttributeGroup {
|
|
17
|
-
name: string;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Product, ProductVariant } from 'brainerce';
|
|
5
|
+
import { getVariantOptions, getProductSwatches, getStockStatus, formatPrice } from 'brainerce';
|
|
6
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface VariantSelectorProps {
|
|
10
|
+
product: Product;
|
|
11
|
+
selectedVariant: ProductVariant | null;
|
|
12
|
+
onVariantChange: (variant: ProductVariant) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AttributeGroup {
|
|
17
|
+
name: string;
|
|
18
|
+
displayType: string;
|
|
19
|
+
values: Array<{
|
|
20
|
+
value: string;
|
|
21
|
+
swatchColor?: string | null;
|
|
22
|
+
swatchColor2?: string | null;
|
|
23
|
+
swatchImageUrl?: string | null;
|
|
24
|
+
variants: ProductVariant[];
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function VariantSelector({
|
|
29
|
+
product,
|
|
30
|
+
selectedVariant,
|
|
31
|
+
onVariantChange,
|
|
32
|
+
className,
|
|
33
|
+
}: VariantSelectorProps) {
|
|
34
|
+
const { storeInfo } = useStoreInfo();
|
|
35
|
+
const currency = storeInfo?.currency || 'USD';
|
|
36
|
+
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
37
|
+
|
|
38
|
+
// Get swatch metadata from product attribute options
|
|
39
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
40
|
+
const swatchMap = useMemo(() => {
|
|
41
|
+
const map = new Map<
|
|
42
|
+
string,
|
|
43
|
+
{
|
|
44
|
+
displayType: string;
|
|
45
|
+
options: Map<
|
|
46
|
+
string,
|
|
47
|
+
{
|
|
48
|
+
swatchColor?: string | null;
|
|
49
|
+
swatchColor2?: string | null;
|
|
50
|
+
swatchImageUrl?: string | null;
|
|
51
|
+
}
|
|
52
|
+
>;
|
|
53
|
+
}
|
|
54
|
+
>();
|
|
55
|
+
for (const attr of swatchData) {
|
|
56
|
+
const optMap = new Map<
|
|
57
|
+
string,
|
|
58
|
+
{
|
|
59
|
+
swatchColor?: string | null;
|
|
60
|
+
swatchColor2?: string | null;
|
|
61
|
+
swatchImageUrl?: string | null;
|
|
62
|
+
}
|
|
63
|
+
>();
|
|
64
|
+
for (const opt of attr.options) {
|
|
65
|
+
optMap.set(opt.name, {
|
|
66
|
+
swatchColor: opt.swatchColor,
|
|
67
|
+
swatchColor2: opt.swatchColor2,
|
|
68
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}, [swatchData]);
|
|
75
|
+
|
|
76
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
77
|
+
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
78
|
+
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
79
|
+
|
|
80
|
+
for (const variant of variants) {
|
|
81
|
+
const options = getVariantOptions(variant);
|
|
82
|
+
for (const { name, value } of options) {
|
|
83
|
+
if (!groups.has(name)) {
|
|
84
|
+
groups.set(name, new Map());
|
|
85
|
+
}
|
|
86
|
+
const valuesMap = groups.get(name)!;
|
|
87
|
+
if (!valuesMap.has(value)) {
|
|
88
|
+
valuesMap.set(value, []);
|
|
89
|
+
}
|
|
90
|
+
valuesMap.get(value)!.push(variant);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
95
|
+
const attrSwatch = swatchMap.get(name);
|
|
96
|
+
return {
|
|
97
|
+
name,
|
|
98
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
99
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
100
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
101
|
+
return {
|
|
102
|
+
value,
|
|
103
|
+
swatchColor: optSwatch?.swatchColor,
|
|
104
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
105
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
106
|
+
variants: variantList,
|
|
107
|
+
};
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}, [variants, swatchMap]);
|
|
112
|
+
|
|
113
|
+
// Get currently selected attribute values
|
|
114
|
+
const selectedOptions = useMemo(() => {
|
|
115
|
+
if (!selectedVariant) return new Map<string, string>();
|
|
116
|
+
const opts = getVariantOptions(selectedVariant);
|
|
117
|
+
return new Map(opts.map(({ name, value }) => [name, value]));
|
|
118
|
+
}, [selectedVariant]);
|
|
119
|
+
|
|
120
|
+
// Find the variant that matches all selected attributes
|
|
121
|
+
function findMatchingVariant(
|
|
122
|
+
attributeName: string,
|
|
123
|
+
newValue: string
|
|
124
|
+
): ProductVariant | undefined {
|
|
125
|
+
const nextSelection = new Map(selectedOptions);
|
|
126
|
+
nextSelection.set(attributeName, newValue);
|
|
127
|
+
|
|
128
|
+
return variants.find((v) => {
|
|
129
|
+
const opts = getVariantOptions(v);
|
|
130
|
+
return Array.from(nextSelection.entries()).every(([name, value]) =>
|
|
131
|
+
opts.some((o) => o.name === name && o.value === value)
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (attributeGroups.length === 0) return null;
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className={cn('space-y-4', className)}>
|
|
140
|
+
{attributeGroups.map((group) => (
|
|
141
|
+
<div key={group.name}>
|
|
142
|
+
<label className="text-foreground mb-2 block text-sm font-medium">
|
|
143
|
+
{group.name}
|
|
144
|
+
{selectedOptions.get(group.name) && (
|
|
145
|
+
<span className="text-muted-foreground ms-1 font-normal">
|
|
146
|
+
: {selectedOptions.get(group.name)}
|
|
147
|
+
</span>
|
|
148
|
+
)}
|
|
149
|
+
</label>
|
|
150
|
+
<div className="flex flex-wrap gap-2">
|
|
151
|
+
{group.values.map(
|
|
152
|
+
({
|
|
153
|
+
value,
|
|
154
|
+
swatchColor,
|
|
155
|
+
swatchColor2,
|
|
156
|
+
swatchImageUrl,
|
|
157
|
+
variants: matchingVariants,
|
|
158
|
+
}) => {
|
|
159
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
160
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
161
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
162
|
+
|
|
163
|
+
// Color swatch rendering
|
|
164
|
+
if (group.displayType === 'COLOR_SWATCH' && swatchColor) {
|
|
165
|
+
return (
|
|
166
|
+
<button
|
|
167
|
+
key={value}
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={!isAvailable}
|
|
170
|
+
title={value}
|
|
171
|
+
onClick={() => {
|
|
172
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
173
|
+
if (variant) onVariantChange(variant);
|
|
174
|
+
}}
|
|
175
|
+
className={cn(
|
|
176
|
+
'h-9 w-9 rounded-full border-2 transition-all',
|
|
177
|
+
isSelected
|
|
178
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
179
|
+
: isAvailable
|
|
180
|
+
? 'border-border hover:border-primary'
|
|
181
|
+
: 'cursor-not-allowed opacity-40'
|
|
182
|
+
)}
|
|
183
|
+
style={{
|
|
184
|
+
background: swatchColor2
|
|
185
|
+
? `linear-gradient(135deg, ${swatchColor} 50%, ${swatchColor2} 50%)`
|
|
186
|
+
: swatchColor,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{!isAvailable && (
|
|
190
|
+
<span
|
|
191
|
+
className="bg-muted-foreground block h-full w-full rounded-full opacity-50"
|
|
192
|
+
style={{
|
|
193
|
+
backgroundImage:
|
|
194
|
+
'linear-gradient(135deg, transparent 45%, currentColor 45%, currentColor 55%, transparent 55%)',
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
</button>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Image swatch rendering
|
|
203
|
+
if (group.displayType === 'IMAGE_SWATCH' && swatchImageUrl) {
|
|
204
|
+
return (
|
|
205
|
+
<button
|
|
206
|
+
key={value}
|
|
207
|
+
type="button"
|
|
208
|
+
disabled={!isAvailable}
|
|
209
|
+
title={value}
|
|
210
|
+
onClick={() => {
|
|
211
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
212
|
+
if (variant) onVariantChange(variant);
|
|
213
|
+
}}
|
|
214
|
+
className={cn(
|
|
215
|
+
'h-10 w-10 overflow-hidden rounded-lg border-2 transition-all',
|
|
216
|
+
isSelected
|
|
217
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
218
|
+
: isAvailable
|
|
219
|
+
? 'border-border hover:border-primary'
|
|
220
|
+
: 'cursor-not-allowed opacity-40'
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<img
|
|
224
|
+
src={swatchImageUrl}
|
|
225
|
+
alt={value}
|
|
226
|
+
className="h-full w-full object-cover"
|
|
227
|
+
/>
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Default button rendering (BUTTON, DROPDOWN, or fallback)
|
|
233
|
+
return (
|
|
234
|
+
<button
|
|
235
|
+
key={value}
|
|
236
|
+
type="button"
|
|
237
|
+
disabled={!isAvailable}
|
|
238
|
+
onClick={() => {
|
|
239
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
240
|
+
if (variant) onVariantChange(variant);
|
|
241
|
+
}}
|
|
242
|
+
className={cn(
|
|
243
|
+
'rounded border px-4 py-2 text-sm transition-colors',
|
|
244
|
+
isSelected
|
|
245
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
246
|
+
: isAvailable
|
|
247
|
+
? 'border-border bg-background text-foreground hover:border-primary'
|
|
248
|
+
: 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
|
|
249
|
+
)}
|
|
250
|
+
>
|
|
251
|
+
{value}
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
|
|
260
|
+
{/* Variant-specific info */}
|
|
261
|
+
{selectedVariant && (
|
|
262
|
+
<div className="text-muted-foreground flex items-center gap-3 pt-1 text-sm">
|
|
263
|
+
{selectedVariant.price && (
|
|
264
|
+
<span>
|
|
265
|
+
{
|
|
266
|
+
formatPrice(selectedVariant.salePrice || selectedVariant.price, {
|
|
267
|
+
currency,
|
|
268
|
+
}) as string
|
|
269
|
+
}
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
<span>{getStockStatus(selectedVariant.inventory)}</span>
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|