create-brainerce-store 1.33.0 → 1.33.2

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.33.0",
34
+ version: "1.33.2",
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
@@ -429,6 +429,7 @@
429
429
  "sendAnother": "Send another message",
430
430
  "thanksTitle": "Thank you!",
431
431
  "thanksBody": "Your message has been received. We'll reply by email as soon as possible.",
432
- "genericError": "Something went wrong. Please try again."
432
+ "genericError": "Something went wrong. Please try again.",
433
+ "fieldRequired": "{label} is required"
433
434
  }
434
435
  }
package/messages/he.json CHANGED
@@ -429,6 +429,7 @@
429
429
  "sendAnother": "שליחת הודעה נוספת",
430
430
  "thanksTitle": "תודה!",
431
431
  "thanksBody": "ההודעה התקבלה. נחזור אליך באימייל בהקדם האפשרי.",
432
- "genericError": "משהו השתבש. אנא נסה שוב."
432
+ "genericError": "משהו השתבש. אנא נסה שוב.",
433
+ "fieldRequired": "השדה {label} הוא חובה"
433
434
  }
434
435
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.33.0",
3
+ "version": "1.33.2",
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,461 +1,528 @@
1
- 'use client';
2
-
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';
36
- import { getClient } from '@/lib/brainerce';
37
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
38
- import { useTranslations } from '@/lib/translations';
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
-
60
- export default function ContactPage() {
61
- const t = useTranslations('contact');
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('');
66
- const [loading, setLoading] = useState(false);
67
- const [sent, setSent] = useState(false);
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
- }
99
-
100
- async function handleSubmit(e: React.FormEvent) {
101
- e.preventDefault();
102
- if (loading || !schema) return;
103
-
104
- if (honeypot.trim().length > 0) {
105
- setSent(true);
106
- return;
107
- }
108
-
109
- try {
110
- setLoading(true);
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,
124
- });
125
-
126
- setSent(true);
127
- const reset: Record<string, FieldValue> = {};
128
- for (const field of schema.fields) reset[field.key] = defaultValueFor(field);
129
- setValues(reset);
130
- setHoneypot('');
131
- } catch (err) {
132
- setSubmitError(err instanceof Error ? err.message : t('genericError'));
133
- } finally {
134
- setLoading(false);
135
- }
136
- }
137
-
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
- }
155
-
156
- return (
157
- <div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
158
- <div className="mb-8 text-center">
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
- )}
165
- </div>
166
-
167
- {submitError && (
168
- <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
169
- {submitError}
170
- </div>
171
- )}
172
-
173
- {sent ? (
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">
175
- <p className="font-medium">{t('thanksTitle')}</p>
176
- <p className="mt-1">{schema.successMessage || t('thanksBody')}</p>
177
- <button
178
- type="button"
179
- onClick={() => setSent(false)}
180
- className="text-primary mt-4 text-sm font-medium hover:underline"
181
- >
182
- {t('sendAnother')}
183
- </button>
184
- </div>
185
- ) : (
186
- <form onSubmit={handleSubmit} className="space-y-4" noValidate>
187
- <div
188
- aria-hidden="true"
189
- className="pointer-events-none absolute -start-[10000px] h-0 w-0 overflow-hidden"
190
- >
191
- <label htmlFor="contact-honeypot">Leave this field empty</label>
192
- <input
193
- id="contact-honeypot"
194
- type="text"
195
- tabIndex={-1}
196
- autoComplete="off"
197
- value={honeypot}
198
- onChange={(e) => setHoneypot(e.target.value)}
199
- />
200
- </div>
201
-
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}
209
- />
210
- ))}
211
-
212
- <button
213
- type="submit"
214
- disabled={loading}
215
- 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"
216
- >
217
- {loading ? (
218
- <>
219
- <LoadingSpinner
220
- size="sm"
221
- className="border-primary-foreground/30 border-t-primary-foreground"
222
- />
223
- {t('sending')}
224
- </>
225
- ) : (
226
- schema.submitButton || t('send')
227
- )}
228
- </button>
229
- </form>
230
- )}
231
- </div>
232
- );
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
- }
1
+ 'use client';
2
+
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
+ * • set field width (FULL/HALF/THIRD) for multi-column layouts
16
+ *
17
+ * Everything below is generic rendering logic it adapts automatically to any
18
+ * form shape returned by the API, so **you should not hardcode field keys or
19
+ * labels here**. If you want a different visual layout, change the markup
20
+ * inside `DynamicField` (keep the behavior) and keep using `schema.fields` as
21
+ * the source of truth.
22
+ *
23
+ * API contract the page relies on:
24
+ * 1. `GET /stores/{storeId}/contact-forms/main?locale={locale}` `ContactFormPublic`
25
+ * (see `brainerce` SDK type). Server pre-resolves translations for the
26
+ * requested locale and strips any field with `isVisible=false`.
27
+ * 2. `POST /stores/{storeId}/inquiries` with `{ formKey, fields, locale }` →
28
+ * `CreateInquiryResponse`. Unknown keys are stripped server-side.
29
+ *
30
+ * Rate limit: 3 submissions / 60s per IP. Honeypot field below blocks naive bots.
31
+ * If the merchant has more than one form (e.g. 'main' + 'newsletter'), you can
32
+ * list them via `client.contactForms.list()` and pick by `key`.
33
+ */
34
+
35
+ import { useEffect, useMemo, useState } from 'react';
36
+ import type { ContactFormPublic, ContactFormPublicField } from 'brainerce';
37
+ import { getClient } from '@/lib/brainerce';
38
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
39
+ import { useTranslations } from '@/lib/translations';
40
+
41
+ type FieldValue = string | string[] | boolean;
42
+
43
+ function defaultValueFor(field: ContactFormPublicField): FieldValue {
44
+ if (field.type === 'CHECKBOX') return false;
45
+ if (field.type === 'MULTI_SELECT') return [];
46
+ return field.defaultValue ?? '';
47
+ }
48
+
49
+ function isEmpty(v: FieldValue): boolean {
50
+ if (typeof v === 'string') return v.trim().length === 0;
51
+ if (Array.isArray(v)) return v.length === 0;
52
+ return v === false;
53
+ }
54
+
55
+ /** Map field.width Tailwind col-span utilities (6-column grid). */
56
+ function widthColSpan(width?: string): string {
57
+ switch (width) {
58
+ case 'HALF':
59
+ return 'col-span-6 sm:col-span-3';
60
+ case 'THIRD':
61
+ return 'col-span-6 sm:col-span-2';
62
+ default:
63
+ return 'col-span-6';
64
+ }
65
+ }
66
+
67
+ const inputClass =
68
+ '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';
69
+
70
+ const inputErrorClass =
71
+ 'border-red-400 bg-background text-foreground placeholder:text-muted-foreground focus:ring-red-200 focus:border-red-500 h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
72
+
73
+ const textareaClass =
74
+ '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';
75
+
76
+ const textareaErrorClass =
77
+ 'border-red-400 bg-background text-foreground placeholder:text-muted-foreground focus:ring-red-200 focus:border-red-500 w-full rounded border px-3 py-2 text-sm focus:outline-none focus:ring-2';
78
+
79
+ export default function ContactPage() {
80
+ const t = useTranslations('contact');
81
+ const [schema, setSchema] = useState<ContactFormPublic | null>(null);
82
+ const [schemaError, setSchemaError] = useState<string | null>(null);
83
+ const [values, setValues] = useState<Record<string, FieldValue>>({});
84
+ const [errors, setErrors] = useState<Record<string, string>>({});
85
+ const [honeypot, setHoneypot] = useState('');
86
+ const [loading, setLoading] = useState(false);
87
+ const [sent, setSent] = useState(false);
88
+ const [submitError, setSubmitError] = useState<string | null>(null);
89
+
90
+ const locale = useMemo(() => {
91
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
92
+ return undefined;
93
+ }, []);
94
+
95
+ useEffect(() => {
96
+ let cancelled = false;
97
+ (async () => {
98
+ try {
99
+ const client = getClient();
100
+ const form = await client.contactForms.get('main', locale);
101
+ if (cancelled) return;
102
+ setSchema(form);
103
+ const initial: Record<string, FieldValue> = {};
104
+ for (const field of form.fields) initial[field.key] = defaultValueFor(field);
105
+ setValues(initial);
106
+ } catch (err) {
107
+ if (cancelled) return;
108
+ setSchemaError(err instanceof Error ? err.message : t('genericError'));
109
+ }
110
+ })();
111
+ return () => {
112
+ cancelled = true;
113
+ };
114
+ }, [t, locale]);
115
+
116
+ function updateValue(key: string, value: FieldValue) {
117
+ setValues((prev) => ({ ...prev, [key]: value }));
118
+ // Clear field error on input change
119
+ if (errors[key]) {
120
+ setErrors((prev) => {
121
+ const next = { ...prev };
122
+ delete next[key];
123
+ return next;
124
+ });
125
+ }
126
+ }
127
+
128
+ async function handleSubmit(e: React.FormEvent) {
129
+ e.preventDefault();
130
+ if (loading || !schema) return;
131
+
132
+ if (honeypot.trim().length > 0) {
133
+ setSent(true);
134
+ return;
135
+ }
136
+
137
+ // ── Client-side required-field validation ──
138
+ const newErrors: Record<string, string> = {};
139
+ for (const field of schema.fields) {
140
+ if (field.isRequired && isEmpty(values[field.key] ?? defaultValueFor(field))) {
141
+ newErrors[field.key] = t('fieldRequired', { label: field.label });
142
+ }
143
+ }
144
+ if (Object.keys(newErrors).length > 0) {
145
+ setErrors(newErrors);
146
+ return; // block submission — do NOT send to server
147
+ }
148
+ setErrors({});
149
+
150
+ try {
151
+ setLoading(true);
152
+ setSubmitError(null);
153
+
154
+ const payload: Record<string, unknown> = {};
155
+ for (const field of schema.fields) {
156
+ const raw = values[field.key];
157
+ if (isEmpty(raw)) continue;
158
+ payload[field.key] = typeof raw === 'string' ? raw.trim() : raw;
159
+ }
160
+
161
+ await getClient().createInquiry({
162
+ formKey: schema.key,
163
+ fields: payload,
164
+ locale,
165
+ });
166
+
167
+ setSent(true);
168
+ const reset: Record<string, FieldValue> = {};
169
+ for (const field of schema.fields) reset[field.key] = defaultValueFor(field);
170
+ setValues(reset);
171
+ setHoneypot('');
172
+ } catch (err) {
173
+ setSubmitError(err instanceof Error ? err.message : t('genericError'));
174
+ } finally {
175
+ setLoading(false);
176
+ }
177
+ }
178
+
179
+ if (schemaError) {
180
+ return (
181
+ <div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
182
+ <div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
183
+ {schemaError}
184
+ </div>
185
+ </div>
186
+ );
187
+ }
188
+
189
+ if (!schema) {
190
+ return (
191
+ <div className="mx-auto flex max-w-2xl items-center justify-center px-4 py-24 sm:px-6 lg:px-8">
192
+ <LoadingSpinner />
193
+ </div>
194
+ );
195
+ }
196
+
197
+ return (
198
+ <div className="mx-auto max-w-2xl px-4 py-12 sm:px-6 lg:px-8">
199
+ <div className="mb-8 text-center">
200
+ <h1 className="text-foreground text-3xl font-bold">{schema.name || t('title')}</h1>
201
+ {schema.description ? (
202
+ <p className="text-muted-foreground mt-2 text-sm">{schema.description}</p>
203
+ ) : (
204
+ <p className="text-muted-foreground mt-2 text-sm">{t('subtitle')}</p>
205
+ )}
206
+ </div>
207
+
208
+ {submitError && (
209
+ <div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
210
+ {submitError}
211
+ </div>
212
+ )}
213
+
214
+ {sent ? (
215
+ <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">
216
+ <p className="font-medium">{t('thanksTitle')}</p>
217
+ <p className="mt-1">{schema.successMessage || t('thanksBody')}</p>
218
+ <button
219
+ type="button"
220
+ onClick={() => setSent(false)}
221
+ className="text-primary mt-4 text-sm font-medium hover:underline"
222
+ >
223
+ {t('sendAnother')}
224
+ </button>
225
+ </div>
226
+ ) : (
227
+ <form onSubmit={handleSubmit} className="space-y-2" noValidate>
228
+ <div
229
+ aria-hidden="true"
230
+ className="pointer-events-none absolute -start-[10000px] h-0 w-0 overflow-hidden"
231
+ >
232
+ <label htmlFor="contact-honeypot">Leave this field empty</label>
233
+ <input
234
+ id="contact-honeypot"
235
+ type="text"
236
+ tabIndex={-1}
237
+ autoComplete="off"
238
+ value={honeypot}
239
+ onChange={(e) => setHoneypot(e.target.value)}
240
+ />
241
+ </div>
242
+
243
+ {/* 6-column grid FULL=span 6, HALF=span 3, THIRD=span 2; stacks on mobile */}
244
+ <div className="grid grid-cols-6 gap-4">
245
+ {schema.fields.map((field) => (
246
+ <DynamicField
247
+ key={field.key}
248
+ field={field}
249
+ value={values[field.key] ?? defaultValueFor(field)}
250
+ error={errors[field.key]}
251
+ onChange={(v) => updateValue(field.key, v)}
252
+ t={t}
253
+ />
254
+ ))}
255
+ </div>
256
+
257
+ <button
258
+ type="submit"
259
+ disabled={loading}
260
+ 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"
261
+ >
262
+ {loading ? (
263
+ <>
264
+ <LoadingSpinner
265
+ size="sm"
266
+ className="border-primary-foreground/30 border-t-primary-foreground"
267
+ />
268
+ {t('sending')}
269
+ </>
270
+ ) : (
271
+ schema.submitButton || t('send')
272
+ )}
273
+ </button>
274
+ </form>
275
+ )}
276
+ </div>
277
+ );
278
+ }
279
+
280
+ function DynamicField({
281
+ field,
282
+ value,
283
+ error,
284
+ onChange,
285
+ t,
286
+ }: {
287
+ field: ContactFormPublicField;
288
+ value: FieldValue;
289
+ error?: string;
290
+ onChange: (v: FieldValue) => void;
291
+ t: (key: string, values?: Record<string, string>) => string;
292
+ }) {
293
+ const id = `contact-${field.key}`;
294
+ const hasError = !!error;
295
+ const label = (
296
+ <label htmlFor={id} className="text-foreground mb-1.5 block text-sm font-medium">
297
+ {field.label}
298
+ {field.isRequired ? (
299
+ <span className="text-red-500 ms-0.5" aria-hidden>*</span>
300
+ ) : (
301
+ <span className="text-muted-foreground ms-1">({t('optional')})</span>
302
+ )}
303
+ </label>
304
+ );
305
+ const help = field.helpText ? (
306
+ <p className="text-muted-foreground mt-1 text-xs">{field.helpText}</p>
307
+ ) : null;
308
+ const errorEl = error ? (
309
+ <p className="text-red-500 mt-1 text-xs">{error}</p>
310
+ ) : null;
311
+
312
+ const maxLength = field.validation?.maxLength;
313
+ const minLength = field.validation?.minLength;
314
+ const min = field.validation?.min;
315
+ const max = field.validation?.max;
316
+ const pattern = field.validation?.pattern;
317
+ const strVal = typeof value === 'string' ? value : '';
318
+ const iClass = hasError ? inputErrorClass : inputClass;
319
+ const tClass = hasError ? textareaErrorClass : textareaClass;
320
+
321
+ switch (field.type) {
322
+ case 'TEXTAREA':
323
+ return (
324
+ <div className={widthColSpan(field.width)}>
325
+ {label}
326
+ <textarea
327
+ id={id}
328
+ required={field.isRequired}
329
+ maxLength={maxLength}
330
+ minLength={minLength}
331
+ rows={6}
332
+ placeholder={field.placeholder}
333
+ value={strVal}
334
+ onChange={(e) => onChange(e.target.value)}
335
+ className={tClass}
336
+ />
337
+ {help}
338
+ {errorEl}
339
+ </div>
340
+ );
341
+
342
+ case 'SELECT':
343
+ return (
344
+ <div className={widthColSpan(field.width)}>
345
+ {label}
346
+ <select
347
+ id={id}
348
+ required={field.isRequired}
349
+ value={strVal}
350
+ onChange={(e) => onChange(e.target.value)}
351
+ className={iClass}
352
+ >
353
+ <option value="">—</option>
354
+ {field.enumValues?.map((opt) => (
355
+ <option key={opt.value} value={opt.value}>
356
+ {opt.label}
357
+ </option>
358
+ ))}
359
+ </select>
360
+ {help}
361
+ {errorEl}
362
+ </div>
363
+ );
364
+
365
+ case 'MULTI_SELECT': {
366
+ const arr = Array.isArray(value) ? value : [];
367
+ return (
368
+ <div className={widthColSpan(field.width)}>
369
+ {label}
370
+ <div className="space-y-2">
371
+ {field.enumValues?.map((opt) => {
372
+ const checked = arr.includes(opt.value);
373
+ return (
374
+ <label key={opt.value} className="flex items-center gap-2 text-sm">
375
+ <input
376
+ type="checkbox"
377
+ checked={checked}
378
+ onChange={(e) => {
379
+ if (e.target.checked) onChange([...arr, opt.value]);
380
+ else onChange(arr.filter((v) => v !== opt.value));
381
+ }}
382
+ />
383
+ <span>{opt.label}</span>
384
+ </label>
385
+ );
386
+ })}
387
+ </div>
388
+ {help}
389
+ {errorEl}
390
+ </div>
391
+ );
392
+ }
393
+
394
+ case 'CHECKBOX':
395
+ return (
396
+ <div className={widthColSpan(field.width)}>
397
+ <label htmlFor={id} className="flex items-start gap-2 text-sm">
398
+ <input
399
+ id={id}
400
+ type="checkbox"
401
+ required={field.isRequired}
402
+ checked={value === true}
403
+ onChange={(e) => onChange(e.target.checked)}
404
+ className="mt-0.5"
405
+ />
406
+ <span className="text-foreground">{field.label}</span>
407
+ </label>
408
+ {help}
409
+ {errorEl}
410
+ </div>
411
+ );
412
+
413
+ case 'NUMBER':
414
+ return (
415
+ <div className={widthColSpan(field.width)}>
416
+ {label}
417
+ <input
418
+ id={id}
419
+ type="number"
420
+ required={field.isRequired}
421
+ min={min}
422
+ max={max}
423
+ placeholder={field.placeholder}
424
+ value={strVal}
425
+ onChange={(e) => onChange(e.target.value)}
426
+ className={iClass}
427
+ />
428
+ {help}
429
+ {errorEl}
430
+ </div>
431
+ );
432
+
433
+ case 'EMAIL':
434
+ return (
435
+ <div className={widthColSpan(field.width)}>
436
+ {label}
437
+ <input
438
+ id={id}
439
+ type="email"
440
+ required={field.isRequired}
441
+ autoComplete="email"
442
+ placeholder={field.placeholder}
443
+ value={strVal}
444
+ onChange={(e) => onChange(e.target.value)}
445
+ className={iClass}
446
+ />
447
+ {help}
448
+ {errorEl}
449
+ </div>
450
+ );
451
+
452
+ case 'PHONE':
453
+ return (
454
+ <div className={widthColSpan(field.width)}>
455
+ {label}
456
+ <input
457
+ id={id}
458
+ type="tel"
459
+ required={field.isRequired}
460
+ autoComplete="tel"
461
+ placeholder={field.placeholder}
462
+ value={strVal}
463
+ onChange={(e) => onChange(e.target.value)}
464
+ className={iClass}
465
+ />
466
+ {help}
467
+ {errorEl}
468
+ </div>
469
+ );
470
+
471
+ case 'URL':
472
+ return (
473
+ <div className={widthColSpan(field.width)}>
474
+ {label}
475
+ <input
476
+ id={id}
477
+ type="url"
478
+ required={field.isRequired}
479
+ placeholder={field.placeholder}
480
+ value={strVal}
481
+ onChange={(e) => onChange(e.target.value)}
482
+ className={iClass}
483
+ />
484
+ {help}
485
+ {errorEl}
486
+ </div>
487
+ );
488
+
489
+ case 'DATE':
490
+ return (
491
+ <div className={widthColSpan(field.width)}>
492
+ {label}
493
+ <input
494
+ id={id}
495
+ type="date"
496
+ required={field.isRequired}
497
+ value={strVal}
498
+ onChange={(e) => onChange(e.target.value)}
499
+ className={iClass}
500
+ />
501
+ {help}
502
+ {errorEl}
503
+ </div>
504
+ );
505
+
506
+ case 'TEXT':
507
+ default:
508
+ return (
509
+ <div className={widthColSpan(field.width)}>
510
+ {label}
511
+ <input
512
+ id={id}
513
+ type="text"
514
+ required={field.isRequired}
515
+ maxLength={maxLength}
516
+ minLength={minLength}
517
+ pattern={pattern}
518
+ placeholder={field.placeholder}
519
+ value={strVal}
520
+ onChange={(e) => onChange(e.target.value)}
521
+ className={iClass}
522
+ />
523
+ {help}
524
+ {errorEl}
525
+ </div>
526
+ );
527
+ }
528
+ }
@@ -1,7 +1,7 @@
1
1
  <% if (i18nEnabled) { %>
2
2
  'use client';
3
3
 
4
- import { createContext, useContext } from 'react';
4
+ import { createContext, useCallback, useContext } from 'react';
5
5
 
6
6
  type Messages = Record<string, Record<string, string>>;
7
7
 
@@ -11,33 +11,40 @@ export { MessagesContext };
11
11
 
12
12
  export function useTranslations(namespace: string) {
13
13
  const messages = useContext(MessagesContext);
14
- const ns = (messages[namespace] || {}) as Record<string, string>;
15
- return function t(key: string, values?: Record<string, string>): string {
16
- let result = ns[key] || `${namespace}.${key}`;
17
- if (values) {
18
- for (const [k, v] of Object.entries(values)) {
19
- result = result.replace(`{${k}}`, v);
14
+ return useCallback(
15
+ (key: string, values?: Record<string, string>): string => {
16
+ const ns = (messages[namespace] || {}) as Record<string, string>;
17
+ let result = ns[key] || `${namespace}.${key}`;
18
+ if (values) {
19
+ for (const [k, v] of Object.entries(values)) {
20
+ result = result.replace(`{${k}}`, v);
21
+ }
20
22
  }
21
- }
22
- return result;
23
- };
23
+ return result;
24
+ },
25
+ [messages, namespace]
26
+ );
24
27
  }
25
28
  <% } else { %>
29
+ import { useCallback } from 'react';
26
30
  import { messages } from '@/i18n';
27
31
 
28
32
  type Messages = typeof messages;
29
33
  type Namespace = keyof Messages;
30
34
 
31
35
  export function useTranslations<N extends Namespace>(namespace: N) {
32
- const ns = messages[namespace] as Record<string, string>;
33
- return function t(key: string, values?: Record<string, string>): string {
34
- let result = ns[key] || `${String(namespace)}.${key}`;
35
- if (values) {
36
- for (const [k, v] of Object.entries(values)) {
37
- result = result.replace(`{${k}}`, v);
36
+ return useCallback(
37
+ (key: string, values?: Record<string, string>): string => {
38
+ const ns = messages[namespace] as Record<string, string>;
39
+ let result = ns[key] || `${String(namespace)}.${key}`;
40
+ if (values) {
41
+ for (const [k, v] of Object.entries(values)) {
42
+ result = result.replace(`{${k}}`, v);
43
+ }
38
44
  }
39
- }
40
- return result;
41
- };
45
+ return result;
46
+ },
47
+ [namespace]
48
+ );
42
49
  }
43
50
  <% } %>