create-brainerce-store 1.31.2 → 1.33.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.31.2",
34
+ version: "1.33.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"
@@ -153,7 +153,7 @@ var ALLOWED_PACKAGE_MANAGERS = [
153
153
  "bun"
154
154
  ];
155
155
  var BRAINERCE_RUNTIME_DEPS = Object.freeze({
156
- brainerce: "^1.20.1",
156
+ brainerce: "^1.22.0",
157
157
  "isomorphic-dompurify": "^3.8.0"
158
158
  });
159
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.31.2",
3
+ "version": "1.33.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"
@@ -1,75 +1,179 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ /**
4
+ * Contact form — fully driven by the merchant's dashboard configuration.
5
+ *
6
+ * The merchant configures the form in the Brainerce dashboard under
7
+ * Customers → Contact Forms → <form>
8
+ *
9
+ * From there they can:
10
+ * • edit the title, description, submit button label, success message
11
+ * • add/remove/reorder/hide any field (TEXT/TEXTAREA/EMAIL/PHONE/NUMBER/SELECT/
12
+ * MULTI_SELECT/CHECKBOX/URL/DATE)
13
+ * • toggle required, set placeholder/helpText/enumValues/validation
14
+ * • provide per-locale translations for every label/placeholder/helpText/successMessage
15
+ *
16
+ * Everything below is generic rendering logic — it adapts automatically to any
17
+ * form shape returned by the API, so **you should not hardcode field keys or
18
+ * labels here**. If you want a different visual layout, change the markup
19
+ * inside `DynamicField` (keep the behavior) and keep using `schema.fields` as
20
+ * the source of truth.
21
+ *
22
+ * API contract the page relies on:
23
+ * 1. `GET /stores/{storeId}/contact-forms/main?locale={locale}` → `ContactFormPublic`
24
+ * (see `brainerce` SDK type). Server pre-resolves translations for the
25
+ * requested locale and strips any field with `isVisible=false`.
26
+ * 2. `POST /stores/{storeId}/inquiries` with `{ formKey, fields, locale }` →
27
+ * `CreateInquiryResponse`. Unknown keys are stripped server-side.
28
+ *
29
+ * Rate limit: 3 submissions / 60s per IP. Honeypot field below blocks naive bots.
30
+ * If the merchant has more than one form (e.g. 'main' + 'newsletter'), you can
31
+ * list them via `client.contactForms.list()` and pick by `key`.
32
+ */
33
+
34
+ import { useEffect, useMemo, useState } from 'react';
35
+ import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
4
36
  import { getClient } from '@/lib/brainerce';
5
37
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
6
38
  import { useTranslations } from '@/lib/translations';
7
39
 
40
+ type FieldValue = string | string[] | boolean;
41
+
42
+ function defaultValueFor(field: ContactFormPublicField): FieldValue {
43
+ if (field.type === 'CHECKBOX') return false;
44
+ if (field.type === 'MULTI_SELECT') return [];
45
+ return field.defaultValue ?? '';
46
+ }
47
+
48
+ function isEmpty(v: FieldValue): boolean {
49
+ if (typeof v === 'string') return v.trim().length === 0;
50
+ if (Array.isArray(v)) return v.length === 0;
51
+ return v === false;
52
+ }
53
+
54
+ const inputClass =
55
+ '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';
56
+
57
+ const textareaClass =
58
+ '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';
59
+
8
60
  export default function ContactPage() {
9
61
  const t = useTranslations('contact');
10
- const [form, setForm] = useState({
11
- name: '',
12
- email: '',
13
- phone: '',
14
- subject: '',
15
- message: '',
16
- honeypot: '',
17
- });
62
+ const [schema, setSchema] = useState<ContactFormPublic | null>(null);
63
+ const [schemaError, setSchemaError] = useState<string | null>(null);
64
+ const [values, setValues] = useState<Record<string, FieldValue>>({});
65
+ const [honeypot, setHoneypot] = useState('');
18
66
  const [loading, setLoading] = useState(false);
19
67
  const [sent, setSent] = useState(false);
20
- const [error, setError] = useState<string | null>(null);
68
+ const [submitError, setSubmitError] = useState<string | null>(null);
69
+
70
+ const locale = useMemo(() => {
71
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
72
+ return undefined;
73
+ }, []);
74
+
75
+ useEffect(() => {
76
+ let cancelled = false;
77
+ (async () => {
78
+ try {
79
+ const client = getClient();
80
+ const form = await client.contactForms.get('main', locale);
81
+ if (cancelled) return;
82
+ setSchema(form);
83
+ const initial: Record<string, FieldValue> = {};
84
+ for (const field of form.fields) initial[field.key] = defaultValueFor(field);
85
+ setValues(initial);
86
+ } catch (err) {
87
+ if (cancelled) return;
88
+ setSchemaError(err instanceof Error ? err.message : t('genericError'));
89
+ }
90
+ })();
91
+ return () => {
92
+ cancelled = true;
93
+ };
94
+ }, [t, locale]);
95
+
96
+ function updateValue(key: string, value: FieldValue) {
97
+ setValues((prev) => ({ ...prev, [key]: value }));
98
+ }
21
99
 
22
100
  async function handleSubmit(e: React.FormEvent) {
23
101
  e.preventDefault();
24
- if (loading) return;
102
+ if (loading || !schema) return;
25
103
 
26
- // Honeypot silently drop anything that auto-filled this hidden field.
27
- if (form.honeypot.trim().length > 0) {
104
+ if (honeypot.trim().length > 0) {
28
105
  setSent(true);
29
106
  return;
30
107
  }
31
108
 
32
109
  try {
33
110
  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,
111
+ setSubmitError(null);
112
+
113
+ const payload: Record<string, unknown> = {};
114
+ for (const field of schema.fields) {
115
+ const raw = values[field.key];
116
+ if (isEmpty(raw)) continue;
117
+ payload[field.key] = typeof raw === 'string' ? raw.trim() : raw;
118
+ }
119
+
120
+ await getClient().createInquiry({
121
+ formKey: schema.key,
122
+ fields: payload,
123
+ locale,
42
124
  });
125
+
43
126
  setSent(true);
44
- setForm({ name: '', email: '', phone: '', subject: '', message: '', honeypot: '' });
127
+ const reset: Record<string, FieldValue> = {};
128
+ for (const field of schema.fields) reset[field.key] = defaultValueFor(field);
129
+ setValues(reset);
130
+ setHoneypot('');
45
131
  } catch (err) {
46
- const message = err instanceof Error ? err.message : t('genericError');
47
- setError(message);
132
+ setSubmitError(err instanceof Error ? err.message : t('genericError'));
48
133
  } finally {
49
134
  setLoading(false);
50
135
  }
51
136
  }
52
137
 
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';
138
+ if (schemaError) {
139
+ return (
140
+ <div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
141
+ <div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
142
+ {schemaError}
143
+ </div>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ if (!schema) {
149
+ return (
150
+ <div className="mx-auto flex max-w-2xl items-center justify-center px-4 py-24 sm:px-6 lg:px-8">
151
+ <LoadingSpinner />
152
+ </div>
153
+ );
154
+ }
55
155
 
56
156
  return (
57
157
  <div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
58
158
  <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>
159
+ <h1 className="text-foreground text-3xl font-bold">{schema.name || t('title')}</h1>
160
+ {schema.description ? (
161
+ <p className="text-muted-foreground mt-2 text-sm">{schema.description}</p>
162
+ ) : (
163
+ <p className="text-muted-foreground mt-2 text-sm">{t('subtitle')}</p>
164
+ )}
61
165
  </div>
62
166
 
63
- {error && (
167
+ {submitError && (
64
168
  <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
65
- {error}
169
+ {submitError}
66
170
  </div>
67
171
  )}
68
172
 
69
173
  {sent ? (
70
174
  <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
175
  <p className="font-medium">{t('thanksTitle')}</p>
72
- <p className="mt-1">{t('thanksBody')}</p>
176
+ <p className="mt-1">{schema.successMessage || t('thanksBody')}</p>
73
177
  <button
74
178
  type="button"
75
179
  onClick={() => setSent(false)}
@@ -80,7 +184,6 @@ export default function ContactPage() {
80
184
  </div>
81
185
  ) : (
82
186
  <form onSubmit={handleSubmit} className="space-y-4" noValidate>
83
- {/* Honeypot — kept off-screen + aria-hidden; real users never touch it. */}
84
187
  <div
85
188
  aria-hidden="true"
86
189
  className="pointer-events-none absolute -start-[10000px] h-0 w-0 overflow-hidden"
@@ -91,101 +194,20 @@ export default function ContactPage() {
91
194
  type="text"
92
195
  tabIndex={-1}
93
196
  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}
197
+ value={honeypot}
198
+ onChange={(e) => setHoneypot(e.target.value)}
151
199
  />
152
200
  </div>
153
201
 
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}
202
+ {schema.fields.map((field) => (
203
+ <DynamicField
204
+ key={field.key}
205
+ field={field}
206
+ value={values[field.key] ?? defaultValueFor(field)}
207
+ onChange={(v) => updateValue(field.key, v)}
208
+ t={t}
169
209
  />
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>
210
+ ))}
189
211
 
190
212
  <button
191
213
  type="submit"
@@ -201,7 +223,7 @@ export default function ContactPage() {
201
223
  {t('sending')}
202
224
  </>
203
225
  ) : (
204
- t('send')
226
+ schema.submitButton || t('send')
205
227
  )}
206
228
  </button>
207
229
  </form>
@@ -209,3 +231,231 @@ export default function ContactPage() {
209
231
  </div>
210
232
  );
211
233
  }
234
+
235
+ function DynamicField({
236
+ field,
237
+ value,
238
+ onChange,
239
+ t,
240
+ }: {
241
+ field: ContactFormPublicField;
242
+ value: FieldValue;
243
+ onChange: (v: FieldValue) => void;
244
+ t: (key: string, values?: Record<string, string>) => string;
245
+ }) {
246
+ const id = `contact-${field.key}`;
247
+ const label = (
248
+ <label htmlFor={id} className="text-foreground mb-1.5 block text-sm font-medium">
249
+ {field.label}
250
+ {!field.isRequired && <span className="text-muted-foreground ms-1">({t('optional')})</span>}
251
+ </label>
252
+ );
253
+ const help = field.helpText ? (
254
+ <p className="text-muted-foreground mt-1 text-xs">{field.helpText}</p>
255
+ ) : null;
256
+
257
+ const maxLength = field.validation?.maxLength;
258
+ const minLength = field.validation?.minLength;
259
+ const min = field.validation?.min;
260
+ const max = field.validation?.max;
261
+ const pattern = field.validation?.pattern;
262
+ const strVal = typeof value === 'string' ? value : '';
263
+
264
+ switch (field.type) {
265
+ case 'TEXTAREA':
266
+ return (
267
+ <div>
268
+ {label}
269
+ <textarea
270
+ id={id}
271
+ required={field.isRequired}
272
+ maxLength={maxLength}
273
+ minLength={minLength}
274
+ rows={6}
275
+ placeholder={field.placeholder}
276
+ value={strVal}
277
+ onChange={(e) => onChange(e.target.value)}
278
+ className={textareaClass}
279
+ />
280
+ {help}
281
+ </div>
282
+ );
283
+
284
+ case 'SELECT':
285
+ return (
286
+ <div>
287
+ {label}
288
+ <select
289
+ id={id}
290
+ required={field.isRequired}
291
+ value={strVal}
292
+ onChange={(e) => onChange(e.target.value)}
293
+ className={inputClass}
294
+ >
295
+ <option value="">—</option>
296
+ {field.enumValues?.map((opt) => (
297
+ <option key={opt.value} value={opt.value}>
298
+ {opt.label}
299
+ </option>
300
+ ))}
301
+ </select>
302
+ {help}
303
+ </div>
304
+ );
305
+
306
+ case 'MULTI_SELECT': {
307
+ const arr = Array.isArray(value) ? value : [];
308
+ return (
309
+ <div>
310
+ {label}
311
+ <div className="space-y-2">
312
+ {field.enumValues?.map((opt) => {
313
+ const checked = arr.includes(opt.value);
314
+ return (
315
+ <label key={opt.value} className="flex items-center gap-2 text-sm">
316
+ <input
317
+ type="checkbox"
318
+ checked={checked}
319
+ onChange={(e) => {
320
+ if (e.target.checked) onChange([...arr, opt.value]);
321
+ else onChange(arr.filter((v) => v !== opt.value));
322
+ }}
323
+ />
324
+ <span>{opt.label}</span>
325
+ </label>
326
+ );
327
+ })}
328
+ </div>
329
+ {help}
330
+ </div>
331
+ );
332
+ }
333
+
334
+ case 'CHECKBOX':
335
+ return (
336
+ <div>
337
+ <label htmlFor={id} className="flex items-start gap-2 text-sm">
338
+ <input
339
+ id={id}
340
+ type="checkbox"
341
+ required={field.isRequired}
342
+ checked={value === true}
343
+ onChange={(e) => onChange(e.target.checked)}
344
+ className="mt-0.5"
345
+ />
346
+ <span className="text-foreground">{field.label}</span>
347
+ </label>
348
+ {help}
349
+ </div>
350
+ );
351
+
352
+ case 'NUMBER':
353
+ return (
354
+ <div>
355
+ {label}
356
+ <input
357
+ id={id}
358
+ type="number"
359
+ required={field.isRequired}
360
+ min={min}
361
+ max={max}
362
+ placeholder={field.placeholder}
363
+ value={strVal}
364
+ onChange={(e) => onChange(e.target.value)}
365
+ className={inputClass}
366
+ />
367
+ {help}
368
+ </div>
369
+ );
370
+
371
+ case 'EMAIL':
372
+ return (
373
+ <div>
374
+ {label}
375
+ <input
376
+ id={id}
377
+ type="email"
378
+ required={field.isRequired}
379
+ autoComplete="email"
380
+ placeholder={field.placeholder}
381
+ value={strVal}
382
+ onChange={(e) => onChange(e.target.value)}
383
+ className={inputClass}
384
+ />
385
+ {help}
386
+ </div>
387
+ );
388
+
389
+ case 'PHONE':
390
+ return (
391
+ <div>
392
+ {label}
393
+ <input
394
+ id={id}
395
+ type="tel"
396
+ required={field.isRequired}
397
+ autoComplete="tel"
398
+ placeholder={field.placeholder}
399
+ value={strVal}
400
+ onChange={(e) => onChange(e.target.value)}
401
+ className={inputClass}
402
+ />
403
+ {help}
404
+ </div>
405
+ );
406
+
407
+ case 'URL':
408
+ return (
409
+ <div>
410
+ {label}
411
+ <input
412
+ id={id}
413
+ type="url"
414
+ required={field.isRequired}
415
+ placeholder={field.placeholder}
416
+ value={strVal}
417
+ onChange={(e) => onChange(e.target.value)}
418
+ className={inputClass}
419
+ />
420
+ {help}
421
+ </div>
422
+ );
423
+
424
+ case 'DATE':
425
+ return (
426
+ <div>
427
+ {label}
428
+ <input
429
+ id={id}
430
+ type="date"
431
+ required={field.isRequired}
432
+ value={strVal}
433
+ onChange={(e) => onChange(e.target.value)}
434
+ className={inputClass}
435
+ />
436
+ {help}
437
+ </div>
438
+ );
439
+
440
+ case 'TEXT':
441
+ default:
442
+ return (
443
+ <div>
444
+ {label}
445
+ <input
446
+ id={id}
447
+ type="text"
448
+ required={field.isRequired}
449
+ maxLength={maxLength}
450
+ minLength={minLength}
451
+ pattern={pattern}
452
+ placeholder={field.placeholder}
453
+ value={strVal}
454
+ onChange={(e) => onChange(e.target.value)}
455
+ className={inputClass}
456
+ />
457
+ {help}
458
+ </div>
459
+ );
460
+ }
461
+ }