create-brainerce-store 1.14.3 → 1.14.5
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 +47 -4
- package/messages/en.json +37 -4
- package/messages/he.json +37 -4
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/account/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +122 -112
- package/templates/nextjs/base/src/app/cart/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +101 -3
- package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -1
- package/templates/nextjs/base/src/app/login/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -1
- package/templates/nextjs/base/src/app/products/layout.tsx +18 -0
- package/templates/nextjs/base/src/app/products/page.tsx +1 -0
- package/templates/nextjs/base/src/app/register/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/register/page.tsx +1 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +1 -1
- package/templates/nextjs/base/src/components/account/address-book.tsx +432 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -184
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +359 -305
- package/templates/nextjs/base/src/components/products/product-card.tsx +26 -4
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +40 -7
- package/templates/nextjs/base/src/lib/auth.ts +1 -0
|
@@ -0,0 +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
|
+
}
|