create-brainerce-store 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +0 -0
  2. package/dist/index.js +31 -9
  3. package/messages/en.json +366 -362
  4. package/messages/he.json +366 -362
  5. package/package.json +8 -8
  6. package/templates/nextjs/base/next.config.ts +31 -31
  7. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
  8. package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
  9. package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
  10. package/templates/nextjs/base/src/app/account/page.tsx +122 -122
  11. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
  12. package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
  13. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
  14. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
  15. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
  16. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
  17. package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
  18. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
  19. package/templates/nextjs/base/src/app/cart/page.tsx +204 -204
  20. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
  21. package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
  22. package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
  23. package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
  24. package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
  25. package/templates/nextjs/base/src/app/login/page.tsx +59 -59
  26. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
  27. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +17 -0
  28. package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -0
  29. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
  30. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
  31. package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
  32. package/templates/nextjs/base/src/app/products/page.tsx +431 -431
  33. package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
  34. package/templates/nextjs/base/src/app/register/page.tsx +65 -65
  35. package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
  36. package/templates/nextjs/base/src/app/robots.ts +14 -14
  37. package/templates/nextjs/base/src/app/sitemap.ts +25 -25
  38. package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
  39. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
  40. package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
  41. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  42. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
  43. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
  44. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  45. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  46. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  47. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
  48. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
  49. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +49 -3
  50. package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
  51. package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
  52. package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
  53. package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
  54. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  55. package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
  56. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
  57. package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
  58. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  59. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
  60. package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
  61. package/templates/nextjs/base/src/lib/auth.ts +149 -149
  62. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
  63. package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
  64. package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
  65. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
  66. package/templates/nextjs/base/src/lib/translations.ts +0 -11
  67. package/templates/nextjs/base/src/middleware.ts +0 -25
@@ -1,432 +1,432 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { CustomerAddress, CreateAddressDto } from 'brainerce';
5
- import { getClient } from '@/lib/brainerce';
6
- import { useTranslations } from '@/lib/translations';
7
- import { cn } from '@/lib/utils';
8
-
9
- interface AddressBookProps {
10
- addresses: CustomerAddress[];
11
- onUpdate: (addresses: CustomerAddress[]) => void;
12
- className?: string;
13
- }
14
-
15
- interface AddressFormState {
16
- label: string;
17
- firstName: string;
18
- lastName: string;
19
- line1: string;
20
- line2: string;
21
- city: string;
22
- region: string;
23
- postalCode: string;
24
- country: string;
25
- phone: string;
26
- isDefault: boolean;
27
- }
28
-
29
- const emptyForm: AddressFormState = {
30
- label: '',
31
- firstName: '',
32
- lastName: '',
33
- line1: '',
34
- line2: '',
35
- city: '',
36
- region: '',
37
- postalCode: '',
38
- country: '',
39
- phone: '',
40
- isDefault: false,
41
- };
42
-
43
- function addressToForm(a: CustomerAddress): AddressFormState {
44
- return {
45
- label: a.label || '',
46
- firstName: a.firstName,
47
- lastName: a.lastName,
48
- line1: a.line1,
49
- line2: a.line2 || '',
50
- city: a.city,
51
- region: a.region || '',
52
- postalCode: a.postalCode,
53
- country: a.country,
54
- phone: a.phone || '',
55
- isDefault: a.isDefault,
56
- };
57
- }
58
-
59
- interface AddressFormProps {
60
- initial: AddressFormState;
61
- onSave: (data: AddressFormState) => Promise<void>;
62
- onCancel: () => void;
63
- saving: boolean;
64
- t: (key: string) => string;
65
- tc: (key: string) => string;
66
- }
67
-
68
- function AddressForm({ initial, onSave, onCancel, saving, t, tc }: AddressFormProps) {
69
- const [form, setForm] = useState<AddressFormState>(initial);
70
- const set = (field: keyof AddressFormState, value: string | boolean) =>
71
- setForm((f) => ({ ...f, [field]: value }));
72
-
73
- const inputClass =
74
- 'border-border bg-background text-foreground focus:border-primary w-full rounded-md border px-3 py-1.5 text-sm outline-none';
75
-
76
- return (
77
- <form
78
- onSubmit={(e) => {
79
- e.preventDefault();
80
- onSave(form);
81
- }}
82
- className="space-y-3"
83
- >
84
- <div>
85
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
86
- {t('addressLabel')}
87
- </label>
88
- <input
89
- type="text"
90
- value={form.label}
91
- onChange={(e) => set('label', e.target.value)}
92
- className={inputClass}
93
- placeholder="Home, Work..."
94
- />
95
- </div>
96
- <div className="grid grid-cols-2 gap-3">
97
- <div>
98
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
99
- {t('firstName')}
100
- </label>
101
- <input
102
- type="text"
103
- value={form.firstName}
104
- onChange={(e) => set('firstName', e.target.value)}
105
- className={inputClass}
106
- required
107
- />
108
- </div>
109
- <div>
110
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
111
- {t('lastName')}
112
- </label>
113
- <input
114
- type="text"
115
- value={form.lastName}
116
- onChange={(e) => set('lastName', e.target.value)}
117
- className={inputClass}
118
- required
119
- />
120
- </div>
121
- </div>
122
- <div>
123
- <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('line1')}</label>
124
- <input
125
- type="text"
126
- value={form.line1}
127
- onChange={(e) => set('line1', e.target.value)}
128
- className={inputClass}
129
- required
130
- />
131
- </div>
132
- <div>
133
- <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('line2')}</label>
134
- <input
135
- type="text"
136
- value={form.line2}
137
- onChange={(e) => set('line2', e.target.value)}
138
- className={inputClass}
139
- />
140
- </div>
141
- <div className="grid grid-cols-2 gap-3">
142
- <div>
143
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
144
- {t('city')}
145
- </label>
146
- <input
147
- type="text"
148
- value={form.city}
149
- onChange={(e) => set('city', e.target.value)}
150
- className={inputClass}
151
- required
152
- />
153
- </div>
154
- <div>
155
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
156
- {t('postalCode')}
157
- </label>
158
- <input
159
- type="text"
160
- value={form.postalCode}
161
- onChange={(e) => set('postalCode', e.target.value)}
162
- className={inputClass}
163
- required
164
- />
165
- </div>
166
- </div>
167
- <div className="grid grid-cols-2 gap-3">
168
- <div>
169
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
170
- {t('region')}
171
- </label>
172
- <input
173
- type="text"
174
- value={form.region}
175
- onChange={(e) => set('region', e.target.value)}
176
- className={inputClass}
177
- />
178
- </div>
179
- <div>
180
- <label className="text-muted-foreground mb-1 block text-xs font-medium">
181
- {t('country')}
182
- </label>
183
- <input
184
- type="text"
185
- value={form.country}
186
- onChange={(e) => set('country', e.target.value)}
187
- className={inputClass}
188
- required
189
- />
190
- </div>
191
- </div>
192
- <div>
193
- <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('phone')}</label>
194
- <input
195
- type="tel"
196
- value={form.phone}
197
- onChange={(e) => set('phone', e.target.value)}
198
- className={inputClass}
199
- />
200
- </div>
201
- <label className="flex cursor-pointer items-center gap-2">
202
- <input
203
- type="checkbox"
204
- checked={form.isDefault}
205
- onChange={(e) => set('isDefault', e.target.checked)}
206
- className="accent-primary"
207
- />
208
- <span className="text-sm">{t('isDefault')}</span>
209
- </label>
210
- <div className="flex gap-2 pt-1">
211
- <button
212
- type="submit"
213
- disabled={saving}
214
- className="bg-primary text-primary-foreground rounded-md px-4 py-1.5 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
215
- >
216
- {saving ? '...' : tc('save')}
217
- </button>
218
- <button
219
- type="button"
220
- onClick={onCancel}
221
- disabled={saving}
222
- className="text-muted-foreground hover:text-foreground rounded-md px-4 py-1.5 text-sm transition-colors"
223
- >
224
- {tc('cancel')}
225
- </button>
226
- </div>
227
- </form>
228
- );
229
- }
230
-
231
- export function AddressBook({ addresses, onUpdate, className }: AddressBookProps) {
232
- const t = useTranslations('account');
233
- const tc = useTranslations('common');
234
- const [editingId, setEditingId] = useState<string | null>(null);
235
- const [adding, setAdding] = useState(false);
236
- const [saving, setSaving] = useState(false);
237
- const [deletingId, setDeletingId] = useState<string | null>(null);
238
-
239
- async function handleAdd(data: AddressFormState) {
240
- setSaving(true);
241
- try {
242
- const client = getClient();
243
- const dto: CreateAddressDto = {
244
- label: data.label || undefined,
245
- firstName: data.firstName,
246
- lastName: data.lastName,
247
- line1: data.line1,
248
- line2: data.line2 || undefined,
249
- city: data.city,
250
- region: data.region || undefined,
251
- postalCode: data.postalCode,
252
- country: data.country,
253
- phone: data.phone || undefined,
254
- isDefault: data.isDefault,
255
- };
256
- await client.addMyAddress(dto);
257
- const updated = await client.getMyAddresses();
258
- onUpdate(updated);
259
- setAdding(false);
260
- } catch {
261
- // ignore — could show error
262
- } finally {
263
- setSaving(false);
264
- }
265
- }
266
-
267
- async function handleEdit(id: string, data: AddressFormState) {
268
- setSaving(true);
269
- try {
270
- const client = getClient();
271
- await client.updateMyAddress(id, {
272
- label: data.label || undefined,
273
- firstName: data.firstName,
274
- lastName: data.lastName,
275
- line1: data.line1,
276
- line2: data.line2 || undefined,
277
- city: data.city,
278
- region: data.region || undefined,
279
- postalCode: data.postalCode,
280
- country: data.country,
281
- phone: data.phone || undefined,
282
- isDefault: data.isDefault,
283
- });
284
- const updated = await client.getMyAddresses();
285
- onUpdate(updated);
286
- setEditingId(null);
287
- } catch {
288
- // ignore
289
- } finally {
290
- setSaving(false);
291
- }
292
- }
293
-
294
- async function handleDelete(id: string) {
295
- setDeletingId(id);
296
- try {
297
- const client = getClient();
298
- await client.deleteMyAddress(id);
299
- onUpdate(addresses.filter((a) => a.id !== id));
300
- } catch {
301
- // ignore
302
- } finally {
303
- setDeletingId(null);
304
- }
305
- }
306
-
307
- async function handleSetDefault(id: string) {
308
- try {
309
- const client = getClient();
310
- await client.updateMyAddress(id, { isDefault: true });
311
- const updated = await client.getMyAddresses();
312
- onUpdate(updated);
313
- } catch {
314
- // ignore
315
- }
316
- }
317
-
318
- return (
319
- <div className={cn('border-border rounded-lg border p-6', className)}>
320
- <div className="mb-4 flex items-center justify-between">
321
- <h2 className="text-foreground text-lg font-semibold">{t('addressBook')}</h2>
322
- {!adding && (
323
- <button
324
- type="button"
325
- onClick={() => setAdding(true)}
326
- className="text-primary hover:text-primary/80 text-sm font-medium transition-colors"
327
- >
328
- + {t('addAddress')}
329
- </button>
330
- )}
331
- </div>
332
-
333
- {/* Add form */}
334
- {adding && (
335
- <div className="border-border mb-4 rounded-lg border p-4">
336
- <h3 className="text-foreground mb-3 text-sm font-medium">{t('addAddress')}</h3>
337
- <AddressForm
338
- initial={emptyForm}
339
- onSave={handleAdd}
340
- onCancel={() => setAdding(false)}
341
- saving={saving}
342
- t={t}
343
- tc={tc}
344
- />
345
- </div>
346
- )}
347
-
348
- {/* Address list */}
349
- {addresses.length === 0 && !adding ? (
350
- <p className="text-muted-foreground text-sm">{t('noAddresses')}</p>
351
- ) : (
352
- <div className="space-y-3">
353
- {addresses.map((address) => (
354
- <div
355
- key={address.id}
356
- className={cn(
357
- 'border-border rounded-lg border p-4',
358
- address.isDefault && 'border-primary/40 bg-primary/5'
359
- )}
360
- >
361
- {editingId === address.id ? (
362
- <>
363
- <h3 className="text-foreground mb-3 text-sm font-medium">{t('editAddress')}</h3>
364
- <AddressForm
365
- initial={addressToForm(address)}
366
- onSave={(data) => handleEdit(address.id, data)}
367
- onCancel={() => setEditingId(null)}
368
- saving={saving}
369
- t={t}
370
- tc={tc}
371
- />
372
- </>
373
- ) : (
374
- <div className="flex items-start justify-between gap-3">
375
- <div className="min-w-0 text-sm">
376
- {address.label && (
377
- <p className="text-foreground mb-1 font-medium">{address.label}</p>
378
- )}
379
- <p className="text-foreground">
380
- {address.firstName} {address.lastName}
381
- </p>
382
- <p className="text-muted-foreground">
383
- {address.line1}
384
- {address.line2 ? `, ${address.line2}` : ''}
385
- </p>
386
- <p className="text-muted-foreground">
387
- {address.city}
388
- {address.region ? `, ${address.region}` : ''} {address.postalCode}
389
- </p>
390
- <p className="text-muted-foreground">{address.country}</p>
391
- {address.phone && <p className="text-muted-foreground">{address.phone}</p>}
392
- {address.isDefault && (
393
- <span className="bg-primary/10 text-primary mt-2 inline-block rounded px-2 py-0.5 text-xs font-medium">
394
- {t('defaultAddress')}
395
- </span>
396
- )}
397
- </div>
398
- <div className="flex flex-shrink-0 flex-col items-end gap-1">
399
- <button
400
- type="button"
401
- onClick={() => setEditingId(address.id)}
402
- className="text-muted-foreground hover:text-foreground text-xs transition-colors"
403
- >
404
- {t('editAddress')}
405
- </button>
406
- {!address.isDefault && (
407
- <button
408
- type="button"
409
- onClick={() => handleSetDefault(address.id)}
410
- className="text-muted-foreground hover:text-foreground text-xs transition-colors"
411
- >
412
- {t('setDefault')}
413
- </button>
414
- )}
415
- <button
416
- type="button"
417
- onClick={() => handleDelete(address.id)}
418
- disabled={deletingId === address.id}
419
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-50"
420
- >
421
- {deletingId === address.id ? '...' : t('deleteAddress')}
422
- </button>
423
- </div>
424
- </div>
425
- )}
426
- </div>
427
- ))}
428
- </div>
429
- )}
430
- </div>
431
- );
432
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { CustomerAddress, CreateAddressDto } from 'brainerce';
5
+ import { getClient } from '@/lib/brainerce';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface AddressBookProps {
10
+ addresses: CustomerAddress[];
11
+ onUpdate: (addresses: CustomerAddress[]) => void;
12
+ className?: string;
13
+ }
14
+
15
+ interface AddressFormState {
16
+ label: string;
17
+ firstName: string;
18
+ lastName: string;
19
+ line1: string;
20
+ line2: string;
21
+ city: string;
22
+ region: string;
23
+ postalCode: string;
24
+ country: string;
25
+ phone: string;
26
+ isDefault: boolean;
27
+ }
28
+
29
+ const emptyForm: AddressFormState = {
30
+ label: '',
31
+ firstName: '',
32
+ lastName: '',
33
+ line1: '',
34
+ line2: '',
35
+ city: '',
36
+ region: '',
37
+ postalCode: '',
38
+ country: '',
39
+ phone: '',
40
+ isDefault: false,
41
+ };
42
+
43
+ function addressToForm(a: CustomerAddress): AddressFormState {
44
+ return {
45
+ label: a.label || '',
46
+ firstName: a.firstName,
47
+ lastName: a.lastName,
48
+ line1: a.line1,
49
+ line2: a.line2 || '',
50
+ city: a.city,
51
+ region: a.region || '',
52
+ postalCode: a.postalCode,
53
+ country: a.country,
54
+ phone: a.phone || '',
55
+ isDefault: a.isDefault,
56
+ };
57
+ }
58
+
59
+ interface AddressFormProps {
60
+ initial: AddressFormState;
61
+ onSave: (data: AddressFormState) => Promise<void>;
62
+ onCancel: () => void;
63
+ saving: boolean;
64
+ t: (key: string) => string;
65
+ tc: (key: string) => string;
66
+ }
67
+
68
+ function AddressForm({ initial, onSave, onCancel, saving, t, tc }: AddressFormProps) {
69
+ const [form, setForm] = useState<AddressFormState>(initial);
70
+ const set = (field: keyof AddressFormState, value: string | boolean) =>
71
+ setForm((f) => ({ ...f, [field]: value }));
72
+
73
+ const inputClass =
74
+ 'border-border bg-background text-foreground focus:border-primary w-full rounded-md border px-3 py-1.5 text-sm outline-none';
75
+
76
+ return (
77
+ <form
78
+ onSubmit={(e) => {
79
+ e.preventDefault();
80
+ onSave(form);
81
+ }}
82
+ className="space-y-3"
83
+ >
84
+ <div>
85
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
86
+ {t('addressLabel')}
87
+ </label>
88
+ <input
89
+ type="text"
90
+ value={form.label}
91
+ onChange={(e) => set('label', e.target.value)}
92
+ className={inputClass}
93
+ placeholder="Home, Work..."
94
+ />
95
+ </div>
96
+ <div className="grid grid-cols-2 gap-3">
97
+ <div>
98
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
99
+ {t('firstName')}
100
+ </label>
101
+ <input
102
+ type="text"
103
+ value={form.firstName}
104
+ onChange={(e) => set('firstName', e.target.value)}
105
+ className={inputClass}
106
+ required
107
+ />
108
+ </div>
109
+ <div>
110
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
111
+ {t('lastName')}
112
+ </label>
113
+ <input
114
+ type="text"
115
+ value={form.lastName}
116
+ onChange={(e) => set('lastName', e.target.value)}
117
+ className={inputClass}
118
+ required
119
+ />
120
+ </div>
121
+ </div>
122
+ <div>
123
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('line1')}</label>
124
+ <input
125
+ type="text"
126
+ value={form.line1}
127
+ onChange={(e) => set('line1', e.target.value)}
128
+ className={inputClass}
129
+ required
130
+ />
131
+ </div>
132
+ <div>
133
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('line2')}</label>
134
+ <input
135
+ type="text"
136
+ value={form.line2}
137
+ onChange={(e) => set('line2', e.target.value)}
138
+ className={inputClass}
139
+ />
140
+ </div>
141
+ <div className="grid grid-cols-2 gap-3">
142
+ <div>
143
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
144
+ {t('city')}
145
+ </label>
146
+ <input
147
+ type="text"
148
+ value={form.city}
149
+ onChange={(e) => set('city', e.target.value)}
150
+ className={inputClass}
151
+ required
152
+ />
153
+ </div>
154
+ <div>
155
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
156
+ {t('postalCode')}
157
+ </label>
158
+ <input
159
+ type="text"
160
+ value={form.postalCode}
161
+ onChange={(e) => set('postalCode', e.target.value)}
162
+ className={inputClass}
163
+ required
164
+ />
165
+ </div>
166
+ </div>
167
+ <div className="grid grid-cols-2 gap-3">
168
+ <div>
169
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
170
+ {t('region')}
171
+ </label>
172
+ <input
173
+ type="text"
174
+ value={form.region}
175
+ onChange={(e) => set('region', e.target.value)}
176
+ className={inputClass}
177
+ />
178
+ </div>
179
+ <div>
180
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">
181
+ {t('country')}
182
+ </label>
183
+ <input
184
+ type="text"
185
+ value={form.country}
186
+ onChange={(e) => set('country', e.target.value)}
187
+ className={inputClass}
188
+ required
189
+ />
190
+ </div>
191
+ </div>
192
+ <div>
193
+ <label className="text-muted-foreground mb-1 block text-xs font-medium">{t('phone')}</label>
194
+ <input
195
+ type="tel"
196
+ value={form.phone}
197
+ onChange={(e) => set('phone', e.target.value)}
198
+ className={inputClass}
199
+ />
200
+ </div>
201
+ <label className="flex cursor-pointer items-center gap-2">
202
+ <input
203
+ type="checkbox"
204
+ checked={form.isDefault}
205
+ onChange={(e) => set('isDefault', e.target.checked)}
206
+ className="accent-primary"
207
+ />
208
+ <span className="text-sm">{t('isDefault')}</span>
209
+ </label>
210
+ <div className="flex gap-2 pt-1">
211
+ <button
212
+ type="submit"
213
+ disabled={saving}
214
+ className="bg-primary text-primary-foreground rounded-md px-4 py-1.5 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
215
+ >
216
+ {saving ? '...' : tc('save')}
217
+ </button>
218
+ <button
219
+ type="button"
220
+ onClick={onCancel}
221
+ disabled={saving}
222
+ className="text-muted-foreground hover:text-foreground rounded-md px-4 py-1.5 text-sm transition-colors"
223
+ >
224
+ {tc('cancel')}
225
+ </button>
226
+ </div>
227
+ </form>
228
+ );
229
+ }
230
+
231
+ export function AddressBook({ addresses, onUpdate, className }: AddressBookProps) {
232
+ const t = useTranslations('account');
233
+ const tc = useTranslations('common');
234
+ const [editingId, setEditingId] = useState<string | null>(null);
235
+ const [adding, setAdding] = useState(false);
236
+ const [saving, setSaving] = useState(false);
237
+ const [deletingId, setDeletingId] = useState<string | null>(null);
238
+
239
+ async function handleAdd(data: AddressFormState) {
240
+ setSaving(true);
241
+ try {
242
+ const client = getClient();
243
+ const dto: CreateAddressDto = {
244
+ label: data.label || undefined,
245
+ firstName: data.firstName,
246
+ lastName: data.lastName,
247
+ line1: data.line1,
248
+ line2: data.line2 || undefined,
249
+ city: data.city,
250
+ region: data.region || undefined,
251
+ postalCode: data.postalCode,
252
+ country: data.country,
253
+ phone: data.phone || undefined,
254
+ isDefault: data.isDefault,
255
+ };
256
+ await client.addMyAddress(dto);
257
+ const updated = await client.getMyAddresses();
258
+ onUpdate(updated);
259
+ setAdding(false);
260
+ } catch {
261
+ // ignore — could show error
262
+ } finally {
263
+ setSaving(false);
264
+ }
265
+ }
266
+
267
+ async function handleEdit(id: string, data: AddressFormState) {
268
+ setSaving(true);
269
+ try {
270
+ const client = getClient();
271
+ await client.updateMyAddress(id, {
272
+ label: data.label || undefined,
273
+ firstName: data.firstName,
274
+ lastName: data.lastName,
275
+ line1: data.line1,
276
+ line2: data.line2 || undefined,
277
+ city: data.city,
278
+ region: data.region || undefined,
279
+ postalCode: data.postalCode,
280
+ country: data.country,
281
+ phone: data.phone || undefined,
282
+ isDefault: data.isDefault,
283
+ });
284
+ const updated = await client.getMyAddresses();
285
+ onUpdate(updated);
286
+ setEditingId(null);
287
+ } catch {
288
+ // ignore
289
+ } finally {
290
+ setSaving(false);
291
+ }
292
+ }
293
+
294
+ async function handleDelete(id: string) {
295
+ setDeletingId(id);
296
+ try {
297
+ const client = getClient();
298
+ await client.deleteMyAddress(id);
299
+ onUpdate(addresses.filter((a) => a.id !== id));
300
+ } catch {
301
+ // ignore
302
+ } finally {
303
+ setDeletingId(null);
304
+ }
305
+ }
306
+
307
+ async function handleSetDefault(id: string) {
308
+ try {
309
+ const client = getClient();
310
+ await client.updateMyAddress(id, { isDefault: true });
311
+ const updated = await client.getMyAddresses();
312
+ onUpdate(updated);
313
+ } catch {
314
+ // ignore
315
+ }
316
+ }
317
+
318
+ return (
319
+ <div className={cn('border-border rounded-lg border p-6', className)}>
320
+ <div className="mb-4 flex items-center justify-between">
321
+ <h2 className="text-foreground text-lg font-semibold">{t('addressBook')}</h2>
322
+ {!adding && (
323
+ <button
324
+ type="button"
325
+ onClick={() => setAdding(true)}
326
+ className="text-primary hover:text-primary/80 text-sm font-medium transition-colors"
327
+ >
328
+ + {t('addAddress')}
329
+ </button>
330
+ )}
331
+ </div>
332
+
333
+ {/* Add form */}
334
+ {adding && (
335
+ <div className="border-border mb-4 rounded-lg border p-4">
336
+ <h3 className="text-foreground mb-3 text-sm font-medium">{t('addAddress')}</h3>
337
+ <AddressForm
338
+ initial={emptyForm}
339
+ onSave={handleAdd}
340
+ onCancel={() => setAdding(false)}
341
+ saving={saving}
342
+ t={t}
343
+ tc={tc}
344
+ />
345
+ </div>
346
+ )}
347
+
348
+ {/* Address list */}
349
+ {addresses.length === 0 && !adding ? (
350
+ <p className="text-muted-foreground text-sm">{t('noAddresses')}</p>
351
+ ) : (
352
+ <div className="space-y-3">
353
+ {addresses.map((address) => (
354
+ <div
355
+ key={address.id}
356
+ className={cn(
357
+ 'border-border rounded-lg border p-4',
358
+ address.isDefault && 'border-primary/40 bg-primary/5'
359
+ )}
360
+ >
361
+ {editingId === address.id ? (
362
+ <>
363
+ <h3 className="text-foreground mb-3 text-sm font-medium">{t('editAddress')}</h3>
364
+ <AddressForm
365
+ initial={addressToForm(address)}
366
+ onSave={(data) => handleEdit(address.id, data)}
367
+ onCancel={() => setEditingId(null)}
368
+ saving={saving}
369
+ t={t}
370
+ tc={tc}
371
+ />
372
+ </>
373
+ ) : (
374
+ <div className="flex items-start justify-between gap-3">
375
+ <div className="min-w-0 text-sm">
376
+ {address.label && (
377
+ <p className="text-foreground mb-1 font-medium">{address.label}</p>
378
+ )}
379
+ <p className="text-foreground">
380
+ {address.firstName} {address.lastName}
381
+ </p>
382
+ <p className="text-muted-foreground">
383
+ {address.line1}
384
+ {address.line2 ? `, ${address.line2}` : ''}
385
+ </p>
386
+ <p className="text-muted-foreground">
387
+ {address.city}
388
+ {address.region ? `, ${address.region}` : ''} {address.postalCode}
389
+ </p>
390
+ <p className="text-muted-foreground">{address.country}</p>
391
+ {address.phone && <p className="text-muted-foreground">{address.phone}</p>}
392
+ {address.isDefault && (
393
+ <span className="bg-primary/10 text-primary mt-2 inline-block rounded px-2 py-0.5 text-xs font-medium">
394
+ {t('defaultAddress')}
395
+ </span>
396
+ )}
397
+ </div>
398
+ <div className="flex flex-shrink-0 flex-col items-end gap-1">
399
+ <button
400
+ type="button"
401
+ onClick={() => setEditingId(address.id)}
402
+ className="text-muted-foreground hover:text-foreground text-xs transition-colors"
403
+ >
404
+ {t('editAddress')}
405
+ </button>
406
+ {!address.isDefault && (
407
+ <button
408
+ type="button"
409
+ onClick={() => handleSetDefault(address.id)}
410
+ className="text-muted-foreground hover:text-foreground text-xs transition-colors"
411
+ >
412
+ {t('setDefault')}
413
+ </button>
414
+ )}
415
+ <button
416
+ type="button"
417
+ onClick={() => handleDelete(address.id)}
418
+ disabled={deletingId === address.id}
419
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-50"
420
+ >
421
+ {deletingId === address.id ? '...' : t('deleteAddress')}
422
+ </button>
423
+ </div>
424
+ </div>
425
+ )}
426
+ </div>
427
+ ))}
428
+ </div>
429
+ )}
430
+ </div>
431
+ );
432
+ }