create-brainerce-store 1.29.13 → 1.31.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.
- package/dist/index.js +1 -1
- package/messages/en.json +29 -1
- package/messages/he.json +29 -1
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/contact/page.tsx +211 -0
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +125 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +367 -350
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +58 -0
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +79 -0
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +66 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +47 -41
- package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.31.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
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"account": "Account",
|
|
44
44
|
"login": "Login",
|
|
45
45
|
"logout": "Logout",
|
|
46
|
+
"contact": "Contact",
|
|
46
47
|
"searchPlaceholder": "Search products...",
|
|
47
48
|
"categories": "Categories",
|
|
48
49
|
"search": "Search",
|
|
@@ -369,7 +370,18 @@
|
|
|
369
370
|
"region": "State / Region",
|
|
370
371
|
"postalCode": "Postal Code",
|
|
371
372
|
"country": "Country",
|
|
372
|
-
"isDefault": "Set as my default address"
|
|
373
|
+
"isDefault": "Set as my default address",
|
|
374
|
+
"customizations": "Customizations",
|
|
375
|
+
"shippingAddress": "Shipping address",
|
|
376
|
+
"tracking": "Tracking",
|
|
377
|
+
"trackOrder": "Track order",
|
|
378
|
+
"shippedOn": "Shipped on {date}",
|
|
379
|
+
"deliveredOn": "Delivered on {date}",
|
|
380
|
+
"paymentMethod": "Payment",
|
|
381
|
+
"paid": "Paid",
|
|
382
|
+
"refundedStatus": "Refunded",
|
|
383
|
+
"partiallyRefundedStatus": "Partially refunded",
|
|
384
|
+
"statusTimeline": "Timeline"
|
|
373
385
|
},
|
|
374
386
|
"checkoutAddress": {
|
|
375
387
|
"saveToProfile": "Save this address to my profile?",
|
|
@@ -402,5 +414,21 @@
|
|
|
402
414
|
"coupon": {
|
|
403
415
|
"placeholder": "Coupon code",
|
|
404
416
|
"invalidCode": "Invalid coupon code"
|
|
417
|
+
},
|
|
418
|
+
"contact": {
|
|
419
|
+
"title": "Contact Us",
|
|
420
|
+
"subtitle": "Have a question or feedback? Send us a message and we'll get back to you.",
|
|
421
|
+
"name": "Your name",
|
|
422
|
+
"email": "Email",
|
|
423
|
+
"phone": "Phone",
|
|
424
|
+
"subject": "Subject",
|
|
425
|
+
"message": "Message",
|
|
426
|
+
"optional": "optional",
|
|
427
|
+
"send": "Send message",
|
|
428
|
+
"sending": "Sending...",
|
|
429
|
+
"sendAnother": "Send another message",
|
|
430
|
+
"thanksTitle": "Thank you!",
|
|
431
|
+
"thanksBody": "Your message has been received. We'll reply by email as soon as possible.",
|
|
432
|
+
"genericError": "Something went wrong. Please try again."
|
|
405
433
|
}
|
|
406
434
|
}
|
package/messages/he.json
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"account": "חשבון",
|
|
44
44
|
"login": "התחברות",
|
|
45
45
|
"logout": "התנתקות",
|
|
46
|
+
"contact": "צור קשר",
|
|
46
47
|
"searchPlaceholder": "חיפוש מוצרים...",
|
|
47
48
|
"categories": "קטגוריות",
|
|
48
49
|
"search": "חיפוש",
|
|
@@ -369,7 +370,18 @@
|
|
|
369
370
|
"region": "מדינה / אזור",
|
|
370
371
|
"postalCode": "מיקוד",
|
|
371
372
|
"country": "מדינה",
|
|
372
|
-
"isDefault": "הגדר ככתובת ברירת המחדל שלי"
|
|
373
|
+
"isDefault": "הגדר ככתובת ברירת המחדל שלי",
|
|
374
|
+
"customizations": "התאמות אישיות",
|
|
375
|
+
"shippingAddress": "כתובת משלוח",
|
|
376
|
+
"tracking": "מעקב",
|
|
377
|
+
"trackOrder": "מעקב הזמנה",
|
|
378
|
+
"shippedOn": "נשלח בתאריך {date}",
|
|
379
|
+
"deliveredOn": "נמסר בתאריך {date}",
|
|
380
|
+
"paymentMethod": "תשלום",
|
|
381
|
+
"paid": "שולם",
|
|
382
|
+
"refundedStatus": "הוחזר",
|
|
383
|
+
"partiallyRefundedStatus": "הוחזר חלקית",
|
|
384
|
+
"statusTimeline": "ציר זמן"
|
|
373
385
|
},
|
|
374
386
|
"checkoutAddress": {
|
|
375
387
|
"saveToProfile": "לשמור כתובת זו בפרופיל שלי?",
|
|
@@ -402,5 +414,21 @@
|
|
|
402
414
|
"coupon": {
|
|
403
415
|
"placeholder": "קוד קופון",
|
|
404
416
|
"invalidCode": "קוד קופון לא תקין"
|
|
417
|
+
},
|
|
418
|
+
"contact": {
|
|
419
|
+
"title": "צור קשר",
|
|
420
|
+
"subtitle": "יש לך שאלה או משוב? שלח לנו הודעה ונחזור אליך בהקדם.",
|
|
421
|
+
"name": "שם מלא",
|
|
422
|
+
"email": "אימייל",
|
|
423
|
+
"phone": "טלפון",
|
|
424
|
+
"subject": "נושא",
|
|
425
|
+
"message": "הודעה",
|
|
426
|
+
"optional": "לא חובה",
|
|
427
|
+
"send": "שליחה",
|
|
428
|
+
"sending": "שולח...",
|
|
429
|
+
"sendAnother": "שליחת הודעה נוספת",
|
|
430
|
+
"thanksTitle": "תודה!",
|
|
431
|
+
"thanksBody": "ההודעה התקבלה. נחזור אליך באימייל בהקדם האפשרי.",
|
|
432
|
+
"genericError": "משהו השתבש. אנא נסה שוב."
|
|
405
433
|
}
|
|
406
434
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { getClient } from '@/lib/brainerce';
|
|
5
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
6
|
+
import { useTranslations } from '@/lib/translations';
|
|
7
|
+
|
|
8
|
+
export default function ContactPage() {
|
|
9
|
+
const t = useTranslations('contact');
|
|
10
|
+
const [form, setForm] = useState({
|
|
11
|
+
name: '',
|
|
12
|
+
email: '',
|
|
13
|
+
phone: '',
|
|
14
|
+
subject: '',
|
|
15
|
+
message: '',
|
|
16
|
+
honeypot: '',
|
|
17
|
+
});
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
const [sent, setSent] = useState(false);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
|
|
22
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
if (loading) return;
|
|
25
|
+
|
|
26
|
+
// Honeypot — silently drop anything that auto-filled this hidden field.
|
|
27
|
+
if (form.honeypot.trim().length > 0) {
|
|
28
|
+
setSent(true);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
setLoading(true);
|
|
34
|
+
setError(null);
|
|
35
|
+
const client = getClient();
|
|
36
|
+
await client.createInquiry({
|
|
37
|
+
name: form.name.trim(),
|
|
38
|
+
email: form.email.trim(),
|
|
39
|
+
subject: form.subject.trim(),
|
|
40
|
+
message: form.message.trim(),
|
|
41
|
+
phone: form.phone.trim() || undefined,
|
|
42
|
+
});
|
|
43
|
+
setSent(true);
|
|
44
|
+
setForm({ name: '', email: '', phone: '', subject: '', message: '', honeypot: '' });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const message = err instanceof Error ? err.message : t('genericError');
|
|
47
|
+
setError(message);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const inputClass =
|
|
54
|
+
'border-border 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';
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
|
|
58
|
+
<div className="mb-8 text-center">
|
|
59
|
+
<h1 className="text-foreground text-3xl font-bold">{t('title')}</h1>
|
|
60
|
+
<p className="text-muted-foreground mt-2 text-sm">{t('subtitle')}</p>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{error && (
|
|
64
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
65
|
+
{error}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{sent ? (
|
|
70
|
+
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-6 text-center text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
71
|
+
<p className="font-medium">{t('thanksTitle')}</p>
|
|
72
|
+
<p className="mt-1">{t('thanksBody')}</p>
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => setSent(false)}
|
|
76
|
+
className="text-primary mt-4 text-sm font-medium hover:underline"
|
|
77
|
+
>
|
|
78
|
+
{t('sendAnother')}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
) : (
|
|
82
|
+
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
|
83
|
+
{/* Honeypot — kept off-screen + aria-hidden; real users never touch it. */}
|
|
84
|
+
<div
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
className="pointer-events-none absolute -start-[10000px] h-0 w-0 overflow-hidden"
|
|
87
|
+
>
|
|
88
|
+
<label htmlFor="contact-honeypot">Leave this field empty</label>
|
|
89
|
+
<input
|
|
90
|
+
id="contact-honeypot"
|
|
91
|
+
type="text"
|
|
92
|
+
tabIndex={-1}
|
|
93
|
+
autoComplete="off"
|
|
94
|
+
value={form.honeypot}
|
|
95
|
+
onChange={(e) => setForm({ ...form, honeypot: e.target.value })}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
100
|
+
<div>
|
|
101
|
+
<label
|
|
102
|
+
htmlFor="contact-name"
|
|
103
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
104
|
+
>
|
|
105
|
+
{t('name')}
|
|
106
|
+
</label>
|
|
107
|
+
<input
|
|
108
|
+
id="contact-name"
|
|
109
|
+
type="text"
|
|
110
|
+
required
|
|
111
|
+
maxLength={120}
|
|
112
|
+
autoComplete="name"
|
|
113
|
+
value={form.name}
|
|
114
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
115
|
+
className={inputClass}
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
<div>
|
|
119
|
+
<label
|
|
120
|
+
htmlFor="contact-email"
|
|
121
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
122
|
+
>
|
|
123
|
+
{t('email')}
|
|
124
|
+
</label>
|
|
125
|
+
<input
|
|
126
|
+
id="contact-email"
|
|
127
|
+
type="email"
|
|
128
|
+
required
|
|
129
|
+
autoComplete="email"
|
|
130
|
+
value={form.email}
|
|
131
|
+
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
132
|
+
className={inputClass}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div>
|
|
138
|
+
<label
|
|
139
|
+
htmlFor="contact-phone"
|
|
140
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
141
|
+
>
|
|
142
|
+
{t('phone')} <span className="text-muted-foreground">({t('optional')})</span>
|
|
143
|
+
</label>
|
|
144
|
+
<input
|
|
145
|
+
id="contact-phone"
|
|
146
|
+
type="tel"
|
|
147
|
+
autoComplete="tel"
|
|
148
|
+
value={form.phone}
|
|
149
|
+
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
|
150
|
+
className={inputClass}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div>
|
|
155
|
+
<label
|
|
156
|
+
htmlFor="contact-subject"
|
|
157
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
158
|
+
>
|
|
159
|
+
{t('subject')}
|
|
160
|
+
</label>
|
|
161
|
+
<input
|
|
162
|
+
id="contact-subject"
|
|
163
|
+
type="text"
|
|
164
|
+
required
|
|
165
|
+
maxLength={200}
|
|
166
|
+
value={form.subject}
|
|
167
|
+
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
|
168
|
+
className={inputClass}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<label
|
|
174
|
+
htmlFor="contact-message"
|
|
175
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
176
|
+
>
|
|
177
|
+
{t('message')}
|
|
178
|
+
</label>
|
|
179
|
+
<textarea
|
|
180
|
+
id="contact-message"
|
|
181
|
+
required
|
|
182
|
+
maxLength={10000}
|
|
183
|
+
rows={6}
|
|
184
|
+
value={form.message}
|
|
185
|
+
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
|
186
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2"
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<button
|
|
191
|
+
type="submit"
|
|
192
|
+
disabled={loading}
|
|
193
|
+
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
194
|
+
>
|
|
195
|
+
{loading ? (
|
|
196
|
+
<>
|
|
197
|
+
<LoadingSpinner
|
|
198
|
+
size="sm"
|
|
199
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
200
|
+
/>
|
|
201
|
+
{t('sending')}
|
|
202
|
+
</>
|
|
203
|
+
) : (
|
|
204
|
+
t('send')
|
|
205
|
+
)}
|
|
206
|
+
</button>
|
|
207
|
+
</form>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Image from 'next/image';
|
|
4
|
+
import type { OrderItem } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/lib/translations';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
type Customizations = NonNullable<OrderItem['customizations']>;
|
|
9
|
+
type CustomizationEntry = Customizations[string];
|
|
10
|
+
|
|
11
|
+
interface OrderCustomizationsProps {
|
|
12
|
+
customizations: OrderItem['customizations'];
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function OrderCustomizations({ customizations, className }: OrderCustomizationsProps) {
|
|
17
|
+
const t = useTranslations('account');
|
|
18
|
+
if (!customizations) return null;
|
|
19
|
+
const entries = Object.entries(customizations) as Array<[string, CustomizationEntry]>;
|
|
20
|
+
if (entries.length === 0) return null;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<dl className={cn('bg-background/60 mt-2 space-y-1 rounded border-s-2 p-2 text-xs', className)}>
|
|
24
|
+
{entries.map(([key, entry]) => (
|
|
25
|
+
<div key={key} className="flex items-start gap-2">
|
|
26
|
+
<dt className="text-muted-foreground min-w-24 font-medium">{entry.label}:</dt>
|
|
27
|
+
<dd className="text-foreground flex-1 break-words">
|
|
28
|
+
<CustomizationValue entry={entry} fallback={t('productFallback')} />
|
|
29
|
+
</dd>
|
|
30
|
+
</div>
|
|
31
|
+
))}
|
|
32
|
+
</dl>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CustomizationValue({ entry, fallback }: { entry: CustomizationEntry; fallback: string }) {
|
|
37
|
+
const { type, value } = entry;
|
|
38
|
+
|
|
39
|
+
switch (type) {
|
|
40
|
+
case 'BOOLEAN':
|
|
41
|
+
return <span>{value === 'yes' || value === 'true' ? '✓' : '✗'}</span>;
|
|
42
|
+
|
|
43
|
+
case 'MULTI_SELECT':
|
|
44
|
+
return <span>{Array.isArray(value) ? value.join(', ') : String(value)}</span>;
|
|
45
|
+
|
|
46
|
+
case 'IMAGE': {
|
|
47
|
+
const url = typeof value === 'string' ? value : Array.isArray(value) ? value[0] : '';
|
|
48
|
+
if (!url) return <span className="text-muted-foreground">—</span>;
|
|
49
|
+
return (
|
|
50
|
+
<a href={url} target="_blank" rel="noopener noreferrer" className="inline-block">
|
|
51
|
+
<span className="relative inline-block h-12 w-12 overflow-hidden rounded border">
|
|
52
|
+
<Image src={url} alt={fallback} fill sizes="48px" className="object-cover" />
|
|
53
|
+
</span>
|
|
54
|
+
</a>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'GALLERY': {
|
|
59
|
+
const urls = Array.isArray(value) ? value : value ? [value] : [];
|
|
60
|
+
if (urls.length === 0) return <span className="text-muted-foreground">—</span>;
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex flex-wrap gap-1.5">
|
|
63
|
+
{urls.map((url, i) => (
|
|
64
|
+
<a
|
|
65
|
+
key={`${url}-${i}`}
|
|
66
|
+
href={url}
|
|
67
|
+
target="_blank"
|
|
68
|
+
rel="noopener noreferrer"
|
|
69
|
+
className="inline-block"
|
|
70
|
+
>
|
|
71
|
+
<span className="relative inline-block h-10 w-10 overflow-hidden rounded border">
|
|
72
|
+
<Image src={url} alt={fallback} fill sizes="40px" className="object-cover" />
|
|
73
|
+
</span>
|
|
74
|
+
</a>
|
|
75
|
+
))}
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case 'COLOR': {
|
|
81
|
+
const hex = typeof value === 'string' ? value : '';
|
|
82
|
+
return (
|
|
83
|
+
<span className="inline-flex items-center gap-1.5">
|
|
84
|
+
<span
|
|
85
|
+
className="inline-block h-4 w-4 rounded border"
|
|
86
|
+
style={{ backgroundColor: hex || 'transparent' }}
|
|
87
|
+
/>
|
|
88
|
+
<span className="font-mono">{hex || '—'}</span>
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'DATE': {
|
|
94
|
+
const s = typeof value === 'string' ? value : '';
|
|
95
|
+
if (!s) return <span>—</span>;
|
|
96
|
+
const d = new Date(s);
|
|
97
|
+
return <span>{isNaN(d.getTime()) ? s : d.toLocaleDateString()}</span>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
case 'DATETIME': {
|
|
101
|
+
const s = typeof value === 'string' ? value : '';
|
|
102
|
+
if (!s) return <span>—</span>;
|
|
103
|
+
const d = new Date(s);
|
|
104
|
+
return <span>{isNaN(d.getTime()) ? s : d.toLocaleString()}</span>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
case 'URL': {
|
|
108
|
+
const href = typeof value === 'string' ? value : '';
|
|
109
|
+
if (!href) return <span>—</span>;
|
|
110
|
+
return (
|
|
111
|
+
<a
|
|
112
|
+
href={href}
|
|
113
|
+
target="_blank"
|
|
114
|
+
rel="noopener noreferrer"
|
|
115
|
+
className="text-primary break-all underline"
|
|
116
|
+
>
|
|
117
|
+
{href}
|
|
118
|
+
</a>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
default:
|
|
123
|
+
return <span>{Array.isArray(value) ? value.join(', ') : String(value ?? '')}</span>;
|
|
124
|
+
}
|
|
125
|
+
}
|