create-brainerce-store 1.4.1 → 1.5.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.
Files changed (37) hide show
  1. package/dist/index.js +1 -1
  2. package/messages/en.json +9 -1
  3. package/messages/he.json +9 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/account/page.tsx +8 -4
  6. package/templates/nextjs/base/src/app/auth/callback/page.tsx +90 -90
  7. package/templates/nextjs/base/src/app/cart/page.tsx +110 -110
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +614 -614
  9. package/templates/nextjs/base/src/app/login/page.tsx +58 -58
  10. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +193 -193
  11. package/templates/nextjs/base/src/app/page.tsx +98 -98
  12. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +435 -435
  13. package/templates/nextjs/base/src/app/products/page.tsx +246 -246
  14. package/templates/nextjs/base/src/app/register/page.tsx +68 -68
  15. package/templates/nextjs/base/src/app/verify-email/page.tsx +293 -293
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +198 -198
  17. package/templates/nextjs/base/src/components/account/profile-section.tsx +189 -40
  18. package/templates/nextjs/base/src/components/auth/login-form.tsx +94 -94
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  20. package/templates/nextjs/base/src/components/auth/register-form.tsx +184 -184
  21. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  22. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +70 -70
  23. package/templates/nextjs/base/src/components/cart/coupon-input.tsx +134 -134
  24. package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +103 -103
  25. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +305 -305
  26. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +64 -64
  27. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +350 -344
  28. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  29. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  30. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  31. package/templates/nextjs/base/src/components/layout/footer.tsx +38 -38
  32. package/templates/nextjs/base/src/components/layout/header.tsx +332 -332
  33. package/templates/nextjs/base/src/components/products/product-card.tsx +96 -96
  34. package/templates/nextjs/base/src/components/products/product-grid.tsx +35 -35
  35. package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +32 -32
  36. package/templates/nextjs/base/src/lib/translations.ts +11 -11
  37. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +5 -1
@@ -1,305 +1,305 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
5
- import { useTranslations } from '@/lib/translations';
6
- import { cn } from '@/lib/utils';
7
-
8
- interface CheckoutFormProps {
9
- onSubmit: (address: SetShippingAddressDto) => void;
10
- loading?: boolean;
11
- initialValues?: Partial<SetShippingAddressDto>;
12
- destinations?: ShippingDestinations | null;
13
- className?: string;
14
- }
15
-
16
- export function CheckoutForm({
17
- onSubmit,
18
- loading = false,
19
- initialValues,
20
- destinations,
21
- className,
22
- }: CheckoutFormProps) {
23
- const [formData, setFormData] = useState<SetShippingAddressDto>({
24
- email: initialValues?.email || '',
25
- firstName: initialValues?.firstName || '',
26
- lastName: initialValues?.lastName || '',
27
- line1: initialValues?.line1 || '',
28
- line2: initialValues?.line2 || '',
29
- city: initialValues?.city || '',
30
- region: initialValues?.region || '',
31
- postalCode: initialValues?.postalCode || '',
32
- country: initialValues?.country || '',
33
- phone: initialValues?.phone || '',
34
- });
35
- const [errors, setErrors] = useState<Record<string, string>>({});
36
- const t = useTranslations('checkoutForm');
37
- const tc = useTranslations('common');
38
-
39
- const hasCountryOptions = destinations && destinations.countries.length > 0;
40
- const countryRegions = destinations?.regions[formData.country];
41
- const hasRegionOptions = countryRegions && countryRegions.length > 0;
42
-
43
- function validate(): boolean {
44
- const newErrors: Record<string, string> = {};
45
-
46
- if (!formData.email.trim()) {
47
- newErrors.email = t('emailRequired');
48
- } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
49
- newErrors.email = t('emailInvalid');
50
- }
51
-
52
- if (!formData.firstName.trim()) {
53
- newErrors.firstName = t('firstNameRequired');
54
- }
55
- if (!formData.lastName.trim()) {
56
- newErrors.lastName = t('lastNameRequired');
57
- }
58
- if (!formData.line1.trim()) {
59
- newErrors.line1 = t('addressRequired');
60
- }
61
- if (!formData.city.trim()) {
62
- newErrors.city = t('cityRequired');
63
- }
64
- if (!formData.postalCode.trim()) {
65
- newErrors.postalCode = t('postalCodeRequired');
66
- }
67
- if (!formData.country.trim()) {
68
- newErrors.country = t('countryRequired');
69
- }
70
-
71
- setErrors(newErrors);
72
- return Object.keys(newErrors).length === 0;
73
- }
74
-
75
- function handleSubmit(e: React.FormEvent) {
76
- e.preventDefault();
77
- if (validate()) {
78
- onSubmit(formData);
79
- }
80
- }
81
-
82
- function updateField(field: keyof SetShippingAddressDto, value: string) {
83
- setFormData((prev) => {
84
- const next = { ...prev, [field]: value };
85
- // Reset region when country changes
86
- if (field === 'country' && value !== prev.country) {
87
- next.region = '';
88
- }
89
- return next;
90
- });
91
- if (errors[field]) {
92
- setErrors((prev) => {
93
- const next = { ...prev };
94
- delete next[field];
95
- return next;
96
- });
97
- }
98
- }
99
-
100
- const inputClass =
101
- 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
102
- const selectClass =
103
- 'bg-background text-foreground focus:ring-primary/20 focus:border-primary h-10 w-full appearance-none rounded border px-3 text-sm focus:outline-none focus:ring-2';
104
-
105
- return (
106
- <form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
107
- {/* Email */}
108
- <div>
109
- <label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
110
- {t('email')} <span className="text-destructive">*</span>
111
- </label>
112
- <input
113
- id="email"
114
- type="email"
115
- value={formData.email}
116
- onChange={(e) => updateField('email', e.target.value)}
117
- className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
118
- placeholder="your@email.com"
119
- />
120
- {errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
121
- </div>
122
-
123
- {/* Name row */}
124
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
125
- <div>
126
- <label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
127
- {t('firstName')} <span className="text-destructive">*</span>
128
- </label>
129
- <input
130
- id="firstName"
131
- type="text"
132
- value={formData.firstName}
133
- onChange={(e) => updateField('firstName', e.target.value)}
134
- className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
135
- />
136
- {errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
137
- </div>
138
-
139
- <div>
140
- <label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
141
- {t('lastName')} <span className="text-destructive">*</span>
142
- </label>
143
- <input
144
- id="lastName"
145
- type="text"
146
- value={formData.lastName}
147
- onChange={(e) => updateField('lastName', e.target.value)}
148
- className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
149
- />
150
- {errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
151
- </div>
152
- </div>
153
-
154
- {/* Country + Region row */}
155
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
156
- <div>
157
- <label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
158
- {t('country')} <span className="text-destructive">*</span>
159
- </label>
160
- {hasCountryOptions ? (
161
- <select
162
- id="country"
163
- value={formData.country}
164
- onChange={(e) => updateField('country', e.target.value)}
165
- className={cn(selectClass, errors.country ? 'border-destructive' : 'border-border')}
166
- >
167
- <option value="">{t('selectCountry')}</option>
168
- {destinations.countries.map((c) => (
169
- <option key={c.code} value={c.code}>
170
- {c.name}
171
- </option>
172
- ))}
173
- </select>
174
- ) : (
175
- <input
176
- id="country"
177
- type="text"
178
- value={formData.country}
179
- onChange={(e) => updateField('country', e.target.value)}
180
- className={cn(inputClass, errors.country ? 'border-destructive' : 'border-border')}
181
- placeholder={t('countryPlaceholder')}
182
- />
183
- )}
184
- {errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
185
- </div>
186
-
187
- <div>
188
- <label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
189
- {t('stateRegion')}
190
- </label>
191
- {hasRegionOptions ? (
192
- <select
193
- id="region"
194
- value={formData.region || ''}
195
- onChange={(e) => updateField('region', e.target.value)}
196
- className={cn(selectClass, 'border-border')}
197
- >
198
- <option value="">{t('selectRegion')}</option>
199
- {countryRegions.map((r) => (
200
- <option key={r.code} value={r.code}>
201
- {r.name}
202
- </option>
203
- ))}
204
- </select>
205
- ) : (
206
- <input
207
- id="region"
208
- type="text"
209
- value={formData.region || ''}
210
- onChange={(e) => updateField('region', e.target.value)}
211
- className={cn(inputClass, 'border-border')}
212
- />
213
- )}
214
- </div>
215
- </div>
216
-
217
- {/* Address line 1 */}
218
- <div>
219
- <label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
220
- {t('address')} <span className="text-destructive">*</span>
221
- </label>
222
- <input
223
- id="line1"
224
- type="text"
225
- value={formData.line1}
226
- onChange={(e) => updateField('line1', e.target.value)}
227
- className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
228
- placeholder={t('streetAddress')}
229
- />
230
- {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
231
- </div>
232
-
233
- {/* Address line 2 */}
234
- <div>
235
- <label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
236
- {t('apartmentSuite')}
237
- </label>
238
- <input
239
- id="line2"
240
- type="text"
241
- value={formData.line2 || ''}
242
- onChange={(e) => updateField('line2', e.target.value)}
243
- className={cn(inputClass, 'border-border')}
244
- placeholder={t('aptPlaceholder')}
245
- />
246
- </div>
247
-
248
- {/* City + Postal code row */}
249
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
250
- <div>
251
- <label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
252
- {t('city')} <span className="text-destructive">*</span>
253
- </label>
254
- <input
255
- id="city"
256
- type="text"
257
- value={formData.city}
258
- onChange={(e) => updateField('city', e.target.value)}
259
- className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
260
- />
261
- {errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
262
- </div>
263
-
264
- <div>
265
- <label htmlFor="postalCode" className="text-foreground mb-1 block text-sm font-medium">
266
- {t('postalCode')} <span className="text-destructive">*</span>
267
- </label>
268
- <input
269
- id="postalCode"
270
- type="text"
271
- value={formData.postalCode}
272
- onChange={(e) => updateField('postalCode', e.target.value)}
273
- className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
274
- />
275
- {errors.postalCode && (
276
- <p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
277
- )}
278
- </div>
279
- </div>
280
-
281
- {/* Phone */}
282
- <div>
283
- <label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
284
- {t('phone')}
285
- </label>
286
- <input
287
- id="phone"
288
- type="tel"
289
- value={formData.phone || ''}
290
- onChange={(e) => updateField('phone', e.target.value)}
291
- className={cn(inputClass, 'border-border')}
292
- placeholder={t('phonePlaceholder')}
293
- />
294
- </div>
295
-
296
- <button
297
- type="submit"
298
- disabled={loading}
299
- 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"
300
- >
301
- {loading ? tc('saving') : t('continueToShipping')}
302
- </button>
303
- </form>
304
- );
305
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
5
+ import { useTranslations } from '@/lib/translations';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ interface CheckoutFormProps {
9
+ onSubmit: (address: SetShippingAddressDto) => void;
10
+ loading?: boolean;
11
+ initialValues?: Partial<SetShippingAddressDto>;
12
+ destinations?: ShippingDestinations | null;
13
+ className?: string;
14
+ }
15
+
16
+ export function CheckoutForm({
17
+ onSubmit,
18
+ loading = false,
19
+ initialValues,
20
+ destinations,
21
+ className,
22
+ }: CheckoutFormProps) {
23
+ const [formData, setFormData] = useState<SetShippingAddressDto>({
24
+ email: initialValues?.email || '',
25
+ firstName: initialValues?.firstName || '',
26
+ lastName: initialValues?.lastName || '',
27
+ line1: initialValues?.line1 || '',
28
+ line2: initialValues?.line2 || '',
29
+ city: initialValues?.city || '',
30
+ region: initialValues?.region || '',
31
+ postalCode: initialValues?.postalCode || '',
32
+ country: initialValues?.country || '',
33
+ phone: initialValues?.phone || '',
34
+ });
35
+ const [errors, setErrors] = useState<Record<string, string>>({});
36
+ const t = useTranslations('checkoutForm');
37
+ const tc = useTranslations('common');
38
+
39
+ const hasCountryOptions = destinations && destinations.countries.length > 0;
40
+ const countryRegions = destinations?.regions[formData.country];
41
+ const hasRegionOptions = countryRegions && countryRegions.length > 0;
42
+
43
+ function validate(): boolean {
44
+ const newErrors: Record<string, string> = {};
45
+
46
+ if (!formData.email.trim()) {
47
+ newErrors.email = t('emailRequired');
48
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
49
+ newErrors.email = t('emailInvalid');
50
+ }
51
+
52
+ if (!formData.firstName.trim()) {
53
+ newErrors.firstName = t('firstNameRequired');
54
+ }
55
+ if (!formData.lastName.trim()) {
56
+ newErrors.lastName = t('lastNameRequired');
57
+ }
58
+ if (!formData.line1.trim()) {
59
+ newErrors.line1 = t('addressRequired');
60
+ }
61
+ if (!formData.city.trim()) {
62
+ newErrors.city = t('cityRequired');
63
+ }
64
+ if (!formData.postalCode.trim()) {
65
+ newErrors.postalCode = t('postalCodeRequired');
66
+ }
67
+ if (!formData.country.trim()) {
68
+ newErrors.country = t('countryRequired');
69
+ }
70
+
71
+ setErrors(newErrors);
72
+ return Object.keys(newErrors).length === 0;
73
+ }
74
+
75
+ function handleSubmit(e: React.FormEvent) {
76
+ e.preventDefault();
77
+ if (validate()) {
78
+ onSubmit(formData);
79
+ }
80
+ }
81
+
82
+ function updateField(field: keyof SetShippingAddressDto, value: string) {
83
+ setFormData((prev) => {
84
+ const next = { ...prev, [field]: value };
85
+ // Reset region when country changes
86
+ if (field === 'country' && value !== prev.country) {
87
+ next.region = '';
88
+ }
89
+ return next;
90
+ });
91
+ if (errors[field]) {
92
+ setErrors((prev) => {
93
+ const next = { ...prev };
94
+ delete next[field];
95
+ return next;
96
+ });
97
+ }
98
+ }
99
+
100
+ const inputClass =
101
+ 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
102
+ const selectClass =
103
+ 'bg-background text-foreground focus:ring-primary/20 focus:border-primary h-10 w-full appearance-none rounded border px-3 text-sm focus:outline-none focus:ring-2';
104
+
105
+ return (
106
+ <form onSubmit={handleSubmit} className={cn('space-y-4', className)}>
107
+ {/* Email */}
108
+ <div>
109
+ <label htmlFor="email" className="text-foreground mb-1 block text-sm font-medium">
110
+ {t('email')} <span className="text-destructive">*</span>
111
+ </label>
112
+ <input
113
+ id="email"
114
+ type="email"
115
+ value={formData.email}
116
+ onChange={(e) => updateField('email', e.target.value)}
117
+ className={cn(inputClass, errors.email ? 'border-destructive' : 'border-border')}
118
+ placeholder="your@email.com"
119
+ />
120
+ {errors.email && <p className="text-destructive mt-1 text-xs">{errors.email}</p>}
121
+ </div>
122
+
123
+ {/* Name row */}
124
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
125
+ <div>
126
+ <label htmlFor="firstName" className="text-foreground mb-1 block text-sm font-medium">
127
+ {t('firstName')} <span className="text-destructive">*</span>
128
+ </label>
129
+ <input
130
+ id="firstName"
131
+ type="text"
132
+ value={formData.firstName}
133
+ onChange={(e) => updateField('firstName', e.target.value)}
134
+ className={cn(inputClass, errors.firstName ? 'border-destructive' : 'border-border')}
135
+ />
136
+ {errors.firstName && <p className="text-destructive mt-1 text-xs">{errors.firstName}</p>}
137
+ </div>
138
+
139
+ <div>
140
+ <label htmlFor="lastName" className="text-foreground mb-1 block text-sm font-medium">
141
+ {t('lastName')} <span className="text-destructive">*</span>
142
+ </label>
143
+ <input
144
+ id="lastName"
145
+ type="text"
146
+ value={formData.lastName}
147
+ onChange={(e) => updateField('lastName', e.target.value)}
148
+ className={cn(inputClass, errors.lastName ? 'border-destructive' : 'border-border')}
149
+ />
150
+ {errors.lastName && <p className="text-destructive mt-1 text-xs">{errors.lastName}</p>}
151
+ </div>
152
+ </div>
153
+
154
+ {/* Country + Region row */}
155
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
156
+ <div>
157
+ <label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
158
+ {t('country')} <span className="text-destructive">*</span>
159
+ </label>
160
+ {hasCountryOptions ? (
161
+ <select
162
+ id="country"
163
+ value={formData.country}
164
+ onChange={(e) => updateField('country', e.target.value)}
165
+ className={cn(selectClass, errors.country ? 'border-destructive' : 'border-border')}
166
+ >
167
+ <option value="">{t('selectCountry')}</option>
168
+ {destinations.countries.map((c) => (
169
+ <option key={c.code} value={c.code}>
170
+ {c.name}
171
+ </option>
172
+ ))}
173
+ </select>
174
+ ) : (
175
+ <input
176
+ id="country"
177
+ type="text"
178
+ value={formData.country}
179
+ onChange={(e) => updateField('country', e.target.value)}
180
+ className={cn(inputClass, errors.country ? 'border-destructive' : 'border-border')}
181
+ placeholder={t('countryPlaceholder')}
182
+ />
183
+ )}
184
+ {errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
185
+ </div>
186
+
187
+ <div>
188
+ <label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
189
+ {t('stateRegion')}
190
+ </label>
191
+ {hasRegionOptions ? (
192
+ <select
193
+ id="region"
194
+ value={formData.region || ''}
195
+ onChange={(e) => updateField('region', e.target.value)}
196
+ className={cn(selectClass, 'border-border')}
197
+ >
198
+ <option value="">{t('selectRegion')}</option>
199
+ {countryRegions.map((r) => (
200
+ <option key={r.code} value={r.code}>
201
+ {r.name}
202
+ </option>
203
+ ))}
204
+ </select>
205
+ ) : (
206
+ <input
207
+ id="region"
208
+ type="text"
209
+ value={formData.region || ''}
210
+ onChange={(e) => updateField('region', e.target.value)}
211
+ className={cn(inputClass, 'border-border')}
212
+ />
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ {/* Address line 1 */}
218
+ <div>
219
+ <label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
220
+ {t('address')} <span className="text-destructive">*</span>
221
+ </label>
222
+ <input
223
+ id="line1"
224
+ type="text"
225
+ value={formData.line1}
226
+ onChange={(e) => updateField('line1', e.target.value)}
227
+ className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
228
+ placeholder={t('streetAddress')}
229
+ />
230
+ {errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
231
+ </div>
232
+
233
+ {/* Address line 2 */}
234
+ <div>
235
+ <label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
236
+ {t('apartmentSuite')}
237
+ </label>
238
+ <input
239
+ id="line2"
240
+ type="text"
241
+ value={formData.line2 || ''}
242
+ onChange={(e) => updateField('line2', e.target.value)}
243
+ className={cn(inputClass, 'border-border')}
244
+ placeholder={t('aptPlaceholder')}
245
+ />
246
+ </div>
247
+
248
+ {/* City + Postal code row */}
249
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
250
+ <div>
251
+ <label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
252
+ {t('city')} <span className="text-destructive">*</span>
253
+ </label>
254
+ <input
255
+ id="city"
256
+ type="text"
257
+ value={formData.city}
258
+ onChange={(e) => updateField('city', e.target.value)}
259
+ className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
260
+ />
261
+ {errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
262
+ </div>
263
+
264
+ <div>
265
+ <label htmlFor="postalCode" className="text-foreground mb-1 block text-sm font-medium">
266
+ {t('postalCode')} <span className="text-destructive">*</span>
267
+ </label>
268
+ <input
269
+ id="postalCode"
270
+ type="text"
271
+ value={formData.postalCode}
272
+ onChange={(e) => updateField('postalCode', e.target.value)}
273
+ className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
274
+ />
275
+ {errors.postalCode && (
276
+ <p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
277
+ )}
278
+ </div>
279
+ </div>
280
+
281
+ {/* Phone */}
282
+ <div>
283
+ <label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
284
+ {t('phone')}
285
+ </label>
286
+ <input
287
+ id="phone"
288
+ type="tel"
289
+ value={formData.phone || ''}
290
+ onChange={(e) => updateField('phone', e.target.value)}
291
+ className={cn(inputClass, 'border-border')}
292
+ placeholder={t('phonePlaceholder')}
293
+ />
294
+ </div>
295
+
296
+ <button
297
+ type="submit"
298
+ disabled={loading}
299
+ 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"
300
+ >
301
+ {loading ? tc('saving') : t('continueToShipping')}
302
+ </button>
303
+ </form>
304
+ );
305
+ }