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 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.29.13",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.29.13",
3
+ "version": "1.31.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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
+ }