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 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.14.5",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.14.5",
3
+ "version": "1.15.1",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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 [lastSubmittedAddress, setLastSubmittedAddress] = useState<SetShippingAddressDto | null>(
54
- null
55
- );
56
- const [showSavePrompt, setShowSavePrompt] = useState(false);
57
- const [savingAddress, setSavingAddress] = useState(false);
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 from customer profile when logged in
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.shippingAddress) setPrefillAddress(data.shippingAddress);
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
- setStep('payment');
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 entirely
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('payment');
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
- const response = await client.setShippingAddress(checkout.id, address);
168
- setCheckout(response.checkout);
169
- setShippingRates(response.rates);
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
- // Offer to save address to profile if logged in and no prefill (new address)
181
- if (isLoggedIn && !prefillAddress) {
182
- setLastSubmittedAddress(address);
183
- setShowSavePrompt(true);
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
- ? [{ key: 'payment', label: t('stepPayment') }]
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">{t('shippingAddress')}</h2>
460
- {pickupLocations.length > 0 && (
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
- : undefined
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 (!formData.line1.trim()) {
65
- newErrors.line1 = t('addressRequired');
66
- }
67
- if (!formData.city.trim()) {
68
- newErrors.city = t('cityRequired');
69
- }
70
- if (!formData.postalCode.trim()) {
71
- newErrors.postalCode = t('postalCodeRequired');
72
- }
73
- if (!formData.country.trim()) {
74
- newErrors.country = t('countryRequired');
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
- {/* Country + Region row */}
164
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
165
- <div>
166
- <label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
167
- {t('country')} <span className="text-destructive">*</span>
168
- </label>
169
- {hasCountryOptions ? (
170
- <select
171
- id="country"
172
- value={formData.country}
173
- onChange={(e) => updateField('country', e.target.value)}
174
- className={cn(selectClass, errors.country ? 'border-destructive' : 'border-border')}
175
- >
176
- <option value="">{t('selectCountry')}</option>
177
- {destinations.countries.map((c) => (
178
- <option key={c.code} value={c.code}>
179
- {c.name}
180
- </option>
181
- ))}
182
- </select>
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="country"
267
+ id="line1"
186
268
  type="text"
187
- value={formData.country}
188
- onChange={(e) => updateField('country', e.target.value)}
189
- className={cn(inputClass, errors.country ? 'border-destructive' : 'border-border')}
190
- placeholder={t('countryPlaceholder')}
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
- {errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
194
- </div>
274
+ {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
275
+ </div>
195
276
 
196
- <div>
197
- <label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
198
- {t('stateRegion')}
199
- </label>
200
- {hasRegionOptions ? (
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="region"
283
+ id="line2"
217
284
  type="text"
218
- value={formData.region || ''}
219
- onChange={(e) => updateField('region', e.target.value)}
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
- {/* Address line 1 */}
227
- <div>
228
- <label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
229
- {t('address')} <span className="text-destructive">*</span>
230
- </label>
231
- <input
232
- id="line1"
233
- type="text"
234
- value={formData.line1}
235
- onChange={(e) => updateField('line1', e.target.value)}
236
- className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
237
- placeholder={t('streetAddress')}
238
- />
239
- {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
240
- </div>
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
- {/* Address line 2 */}
243
- <div>
244
- <label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
245
- {t('apartmentSuite')}
246
- </label>
247
- <input
248
- id="line2"
249
- type="text"
250
- value={formData.line2 || ''}
251
- onChange={(e) => updateField('line2', e.target.value)}
252
- className={cn(inputClass, 'border-border')}
253
- placeholder={t('aptPlaceholder')}
254
- />
255
- </div>
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
- {/* City + Postal code row */}
258
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
259
- <div>
260
- <label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
261
- {t('city')} <span className="text-destructive">*</span>
262
- </label>
263
- <input
264
- id="city"
265
- type="text"
266
- value={formData.city}
267
- onChange={(e) => updateField('city', e.target.value)}
268
- className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
269
- />
270
- {errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
271
- </div>
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
- values: Array<{
19
- value: string;
20
- variants: ProductVariant[];
21
- }>;
22
- }
23
-
24
- export function VariantSelector({
25
- product,
26
- selectedVariant,
27
- onVariantChange,
28
- className,
29
- }: VariantSelectorProps) {
30
- const { storeInfo } = useStoreInfo();
31
- const currency = storeInfo?.currency || 'USD';
32
- const variants = useMemo(() => product.variants || [], [product.variants]);
33
-
34
- // Build attribute groups from product attribute options or variant data
35
- const attributeGroups = useMemo<AttributeGroup[]>(() => {
36
- const groups = new Map<string, Map<string, ProductVariant[]>>();
37
-
38
- for (const variant of variants) {
39
- const options = getVariantOptions(variant);
40
- for (const { name, value } of options) {
41
- if (!groups.has(name)) {
42
- groups.set(name, new Map());
43
- }
44
- const valuesMap = groups.get(name)!;
45
- if (!valuesMap.has(value)) {
46
- valuesMap.set(value, []);
47
- }
48
- valuesMap.get(value)!.push(variant);
49
- }
50
- }
51
-
52
- return Array.from(groups.entries()).map(([name, valuesMap]) => ({
53
- name,
54
- values: Array.from(valuesMap.entries()).map(([value, variantList]) => ({
55
- value,
56
- variants: variantList,
57
- })),
58
- }));
59
- }, [variants]);
60
-
61
- // Get currently selected attribute values
62
- const selectedOptions = useMemo(() => {
63
- if (!selectedVariant) return new Map<string, string>();
64
- const opts = getVariantOptions(selectedVariant);
65
- return new Map(opts.map(({ name, value }) => [name, value]));
66
- }, [selectedVariant]);
67
-
68
- // Find the variant that matches all selected attributes
69
- function findMatchingVariant(
70
- attributeName: string,
71
- newValue: string
72
- ): ProductVariant | undefined {
73
- const nextSelection = new Map(selectedOptions);
74
- nextSelection.set(attributeName, newValue);
75
-
76
- return variants.find((v) => {
77
- const opts = getVariantOptions(v);
78
- return Array.from(nextSelection.entries()).every(([name, value]) =>
79
- opts.some((o) => o.name === name && o.value === value)
80
- );
81
- });
82
- }
83
-
84
- if (attributeGroups.length === 0) return null;
85
-
86
- return (
87
- <div className={cn('space-y-4', className)}>
88
- {attributeGroups.map((group) => (
89
- <div key={group.name}>
90
- <label className="text-foreground mb-2 block text-sm font-medium">
91
- {group.name}
92
- {selectedOptions.get(group.name) && (
93
- <span className="text-muted-foreground ms-1 font-normal">
94
- : {selectedOptions.get(group.name)}
95
- </span>
96
- )}
97
- </label>
98
- <div className="flex flex-wrap gap-2">
99
- {group.values.map(({ value, variants: matchingVariants }) => {
100
- const isSelected = selectedOptions.get(group.name) === value;
101
- const matchedVariant = findMatchingVariant(group.name, value);
102
- const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
103
-
104
- return (
105
- <button
106
- key={value}
107
- type="button"
108
- disabled={!isAvailable}
109
- onClick={() => {
110
- const variant = matchedVariant || matchingVariants[0];
111
- if (variant) onVariantChange(variant);
112
- }}
113
- className={cn(
114
- 'rounded border px-4 py-2 text-sm transition-colors',
115
- isSelected
116
- ? 'border-primary bg-primary text-primary-foreground'
117
- : isAvailable
118
- ? 'border-border bg-background text-foreground hover:border-primary'
119
- : 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
120
- )}
121
- >
122
- {value}
123
- </button>
124
- );
125
- })}
126
- </div>
127
- </div>
128
- ))}
129
-
130
- {/* Variant-specific info */}
131
- {selectedVariant && (
132
- <div className="text-muted-foreground flex items-center gap-3 pt-1 text-sm">
133
- {selectedVariant.price && (
134
- <span>
135
- {
136
- formatPrice(selectedVariant.salePrice || selectedVariant.price, {
137
- currency,
138
- }) as string
139
- }
140
- </span>
141
- )}
142
- <span>{getStockStatus(selectedVariant.inventory)}</span>
143
- </div>
144
- )}
145
- </div>
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
+ }