'use client'; /** * Contact form — fully driven by the merchant's dashboard configuration. * * The merchant configures the form in the Brainerce dashboard under * Customers → Contact Forms →
* * From there they can: * • edit the title, description, submit button label, success message * • add/remove/reorder/hide any field (TEXT/TEXTAREA/EMAIL/PHONE/NUMBER/SELECT/ * MULTI_SELECT/CHECKBOX/URL/DATE) * • toggle required, set placeholder/helpText/enumValues/validation * • provide per-locale translations for every label/placeholder/helpText/successMessage * • set field width (FULL/HALF/THIRD) for multi-column layouts * * Everything below is generic rendering logic — it adapts automatically to any * form shape returned by the API, so **you should not hardcode field keys or * labels here**. If you want a different visual layout, change the markup * inside `DynamicField` (keep the behavior) and keep using `schema.fields` as * the source of truth. * * API contract the page relies on: * 1. `GET /stores/{storeId}/contact-forms/main?locale={locale}` → `ContactFormPublic` * (see `brainerce` SDK type). Server pre-resolves translations for the * requested locale and strips any field with `isVisible=false`. * 2. `POST /stores/{storeId}/inquiries` with `{ formKey, fields, locale }` → * `CreateInquiryResponse`. Unknown keys are stripped server-side. * * Rate limit: 3 submissions / 60s per IP. Honeypot field below blocks naive bots. * If the merchant has more than one form (e.g. 'main' + 'newsletter'), you can * list them via `client.contactForms.list()` and pick by `key`. */ import { useEffect, useMemo, useState } from 'react'; import type { ContactFormPublic, ContactFormPublicField } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { useTranslations } from '@/core/lib/translations'; type FieldValue = string | string[] | boolean; function defaultValueFor(field: ContactFormPublicField): FieldValue { if (field.type === 'CHECKBOX') return false; if (field.type === 'MULTI_SELECT') return []; return field.defaultValue ?? ''; } function isEmpty(v: FieldValue): boolean { if (typeof v === 'string') return v.trim().length === 0; if (Array.isArray(v)) return v.length === 0; return v === false; } /** Map field.width → Tailwind col-span utilities (6-column grid). */ function widthColSpan(width?: string): string { switch (width) { case 'HALF': return 'col-span-6 sm:col-span-3'; case 'THIRD': return 'col-span-6 sm:col-span-2'; default: return 'col-span-6'; } } const inputClass = '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'; const inputErrorClass = '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'; const textareaClass = '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'; const textareaErrorClass = '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'; export default function ContactPage() { const t = useTranslations('contact'); const [schema, setSchema] = useState(null); const [schemaError, setSchemaError] = useState(null); const [values, setValues] = useState>({}); const [errors, setErrors] = useState>({}); const [honeypot, setHoneypot] = useState(''); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); const [submitError, setSubmitError] = useState(null); const locale = useMemo(() => { if (typeof document !== 'undefined') return document.documentElement.lang || undefined; return undefined; }, []); useEffect(() => { let cancelled = false; (async () => { try { const client = getClient(); const form = await client.contactForms.get('main', locale); if (cancelled) return; setSchema(form); const initial: Record = {}; for (const field of form.fields) initial[field.key] = defaultValueFor(field); setValues(initial); } catch (err) { if (cancelled) return; setSchemaError(err instanceof Error ? err.message : t('genericError')); } })(); return () => { cancelled = true; }; }, [t, locale]); function updateValue(key: string, value: FieldValue) { setValues((prev) => ({ ...prev, [key]: value })); // Clear field error on input change if (errors[key]) { setErrors((prev) => { const next = { ...prev }; delete next[key]; return next; }); } } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (loading || !schema) return; if (honeypot.trim().length > 0) { setSent(true); return; } // ── Client-side required-field validation ── const newErrors: Record = {}; for (const field of schema.fields) { if (field.isRequired && isEmpty(values[field.key] ?? defaultValueFor(field))) { newErrors[field.key] = t('fieldRequired', { label: field.label }); } } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; // block submission — do NOT send to server } setErrors({}); try { setLoading(true); setSubmitError(null); const payload: Record = {}; for (const field of schema.fields) { const raw = values[field.key]; if (isEmpty(raw)) continue; payload[field.key] = typeof raw === 'string' ? raw.trim() : raw; } await getClient().createInquiry({ formKey: schema.key, fields: payload, locale, }); setSent(true); const reset: Record = {}; for (const field of schema.fields) reset[field.key] = defaultValueFor(field); setValues(reset); setHoneypot(''); } catch (err) { setSubmitError(err instanceof Error ? err.message : t('genericError')); } finally { setLoading(false); } } if (schemaError) { return (
{schemaError}
); } if (!schema) { return (
); } return (

{schema.name || t('title')}

{schema.description ? (

{schema.description}

) : (

{t('subtitle')}

)}
{submitError && (
{submitError}
)} {sent ? (

{t('thanksTitle')}

{schema.successMessage || t('thanksBody')}

) : ( {/* 6-column grid — FULL=span 6, HALF=span 3, THIRD=span 2; stacks on mobile */}
{schema.fields.map((field) => ( updateValue(field.key, v)} t={t} /> ))}
)}
); } function DynamicField({ field, value, error, onChange, t, }: { field: ContactFormPublicField; value: FieldValue; error?: string; onChange: (v: FieldValue) => void; t: (key: string, values?: Record) => string; }) { const id = `contact-${field.key}`; const hasError = !!error; const label = ( ); const help = field.helpText ? (

{field.helpText}

) : null; const errorEl = error ?

{error}

: null; const maxLength = field.validation?.maxLength; const minLength = field.validation?.minLength; const min = field.validation?.min; const max = field.validation?.max; const pattern = field.validation?.pattern; const strVal = typeof value === 'string' ? value : ''; const iClass = hasError ? inputErrorClass : inputClass; const tClass = hasError ? textareaErrorClass : textareaClass; switch (field.type) { case 'TEXTAREA': return (
{label}