| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| import { |
| ArrowLeft, |
| ClipboardCopy, |
| ExternalLink, |
| FileInput, |
| Loader2, |
| Plus, |
| Trash2, |
| GripVertical, |
| } from 'lucide-react'; |
| import { Button } from '@/components/ui/button'; |
| import { Input } from '@/components/ui/input'; |
| import { Textarea } from '@/components/ui/textarea'; |
| import { |
| Select, |
| SelectContent, |
| SelectItem, |
| SelectTrigger, |
| SelectValue, |
| } from '@/components/ui/select'; |
| import AppShell from '@/components/layout/AppShell'; |
| import CtaFormPreview from '@/components/settings/CtaFormPreview'; |
| import { apiFetch } from '@/lib/api'; |
| import { buildCtaEmbedCodes } from '@/lib/ctaFormEmbed'; |
| import { cn } from '@/lib/utils'; |
|
|
| const FIELD_PRESETS = [ |
| { key: 'name', label: 'Name', type: 'text', required: true, map_to: 'name' }, |
| { key: 'email', label: 'Email', type: 'email', required: true, map_to: 'email' }, |
| { |
| key: 'company_name', |
| label: 'Company Name', |
| type: 'text', |
| required: false, |
| map_to: 'company_name', |
| }, |
| { key: 'title', label: 'Job Title', type: 'text', required: false, map_to: 'title' }, |
| { key: 'phone', label: 'Phone', type: 'tel', required: false, map_to: 'phone' }, |
| { key: 'message', label: 'Message', type: 'textarea', required: false, map_to: 'message' }, |
| ]; |
|
|
| const MAP_OPTIONS = [ |
| { value: 'name', label: 'Full name' }, |
| { value: 'email', label: 'Email' }, |
| { value: 'company_name', label: 'Company' }, |
| { value: 'title', label: 'Job title' }, |
| { value: 'phone', label: 'Phone' }, |
| { value: 'message', label: 'Message' }, |
| { value: 'custom', label: 'Custom (in submission only)' }, |
| ]; |
|
|
| const AUTOSAVE_MS = 600; |
|
|
| function emptyDraft() { |
| return { |
| name: 'New contact form', |
| fields: FIELD_PRESETS.slice(0, 3).map((f) => ({ ...f })), |
| }; |
| } |
|
|
| function draftFromForm(form) { |
| return { |
| name: form.name, |
| fields: (form.fields || []).map((f) => ({ ...f })), |
| }; |
| } |
|
|
| function draftSnapshot(draft) { |
| return JSON.stringify({ |
| name: (draft.name || '').trim(), |
| fields: draft.fields || [], |
| }); |
| } |
|
|
| function CopyBlock({ label, code }) { |
| return ( |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between gap-2"> |
| <p className="text-xs font-medium text-slate-600">{label}</p> |
| <Button |
| type="button" |
| variant="outline" |
| size="sm" |
| className="h-7 gap-1 text-xs" |
| disabled={!code} |
| onClick={() => code && navigator.clipboard.writeText(code)} |
| > |
| <ClipboardCopy className="h-3 w-3" /> |
| Copy |
| </Button> |
| </div> |
| <Textarea |
| readOnly |
| value={code || ''} |
| rows={3} |
| className="text-xs font-mono h-auto min-h-[4.5rem]" |
| /> |
| </div> |
| ); |
| } |
|
|
| function SaveIndicator({ state }) { |
| if (state === 'saving') { |
| return ( |
| <span className="inline-flex items-center gap-1.5 text-xs text-slate-500"> |
| <Loader2 className="h-3 w-3 animate-spin" /> |
| Saving… |
| </span> |
| ); |
| } |
| if (state === 'saved') { |
| return <span className="text-xs text-green-600">Saved</span>; |
| } |
| if (state === 'error') { |
| return <span className="text-xs text-red-600">Could not save</span>; |
| } |
| return null; |
| } |
|
|
| export default function CtaFormsSettings({ onBack }) { |
| const [forms, setForms] = useState([]); |
| const [loading, setLoading] = useState(true); |
| const [selectedId, setSelectedId] = useState(null); |
| const [draft, setDraft] = useState(emptyDraft()); |
| const [saveState, setSaveState] = useState('idle'); |
| const [creating, setCreating] = useState(false); |
| const [error, setError] = useState(''); |
|
|
| const skipAutosaveRef = useRef(true); |
| const saveTimerRef = useRef(null); |
| const lastSavedSnapshotRef = useRef(''); |
| const savedFadeTimerRef = useRef(null); |
| const dragFieldIdxRef = useRef(null); |
| const [dropFieldIdx, setDropFieldIdx] = useState(null); |
|
|
| const selected = useMemo( |
| () => forms.find((f) => f.id === selectedId) || null, |
| [forms, selectedId] |
| ); |
|
|
| const embedCodes = useMemo(() => { |
| if (!selected?.public_id) { |
| return { embed_iframe: '', embed_script: '' }; |
| } |
| return buildCtaEmbedCodes({ |
| publicId: selected.public_id, |
| name: draft.name, |
| }); |
| }, [selected?.public_id, draft.name]); |
|
|
| const previewUrl = selected?.public_id ? `/embed/cta/${selected.public_id}` : ''; |
|
|
| const loadForms = useCallback(async () => { |
| setLoading(true); |
| setError(''); |
| try { |
| const r = await apiFetch('/api/cta-forms'); |
| const data = r.ok ? await r.json() : { forms: [] }; |
| if (!r.ok) throw new Error(data.detail || 'Could not load forms'); |
| const list = data.forms || []; |
| setForms(list); |
| setSelectedId((prev) => { |
| if (prev && list.some((f) => f.id === prev)) return prev; |
| return list.length ? list[0].id : null; |
| }); |
| } catch (e) { |
| setError(e.message || 'Load failed'); |
| setForms([]); |
| } finally { |
| setLoading(false); |
| } |
| }, []); |
|
|
| useEffect(() => { |
| loadForms(); |
| }, [loadForms]); |
|
|
| useEffect(() => { |
| if (!selected) return; |
| const next = draftFromForm(selected); |
| skipAutosaveRef.current = true; |
| lastSavedSnapshotRef.current = draftSnapshot(next); |
| setDraft(next); |
| setSaveState('idle'); |
| }, [selected?.id]); |
|
|
| useEffect(() => { |
| if (!selectedId) return; |
| if (skipAutosaveRef.current) { |
| skipAutosaveRef.current = false; |
| return; |
| } |
|
|
| const snapshot = draftSnapshot(draft); |
| if (snapshot === lastSavedSnapshotRef.current) return; |
|
|
| const name = draft.name?.trim(); |
| if (!name) return; |
|
|
| if (saveTimerRef.current) clearTimeout(saveTimerRef.current); |
| saveTimerRef.current = setTimeout(async () => { |
| const payload = JSON.parse(snapshot); |
| if (payload.name !== (draft.name || '').trim()) return; |
| if (snapshot !== draftSnapshot(draft)) return; |
|
|
| setSaveState('saving'); |
| try { |
| const res = await apiFetch(`/api/cta-forms/${selectedId}`, { |
| method: 'PATCH', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| name: payload.name, |
| fields: payload.fields, |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.detail || 'Save failed'); |
| lastSavedSnapshotRef.current = snapshot; |
| setForms((prev) => prev.map((f) => (f.id === data.id ? data : f))); |
| if (savedFadeTimerRef.current) clearTimeout(savedFadeTimerRef.current); |
| setSaveState('saved'); |
| savedFadeTimerRef.current = window.setTimeout(() => setSaveState('idle'), 2000); |
| } catch (e) { |
| setError(e.message || 'Save failed'); |
| setSaveState('error'); |
| } |
| }, AUTOSAVE_MS); |
|
|
| return () => { |
| if (saveTimerRef.current) clearTimeout(saveTimerRef.current); |
| }; |
| }, [draft, selectedId]); |
|
|
| const selectForm = (f) => { |
| const next = draftFromForm(f); |
| setSelectedId(f.id); |
| skipAutosaveRef.current = true; |
| lastSavedSnapshotRef.current = draftSnapshot(next); |
| setDraft(next); |
| setSaveState('idle'); |
| }; |
|
|
| const createForm = async () => { |
| setCreating(true); |
| setError(''); |
| try { |
| const res = await apiFetch('/api/cta-forms', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| name: 'New contact form', |
| fields: FIELD_PRESETS.slice(0, 3), |
| }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.detail || 'Create failed'); |
| await loadForms(); |
| setSelectedId(data.id); |
| const next = draftFromForm(data); |
| skipAutosaveRef.current = true; |
| lastSavedSnapshotRef.current = draftSnapshot(next); |
| setDraft(next); |
| } catch (e) { |
| setError(e.message || 'Create failed'); |
| } finally { |
| setCreating(false); |
| } |
| }; |
|
|
| const deleteForm = async () => { |
| if (!selectedId) return; |
| if (!window.confirm('Delete this form? Embed codes will stop working.')) return; |
| try { |
| const res = await apiFetch(`/api/cta-forms/${selectedId}`, { method: 'DELETE' }); |
| if (!res.ok) { |
| const data = await res.json().catch(() => ({})); |
| throw new Error(data.detail || 'Delete failed'); |
| } |
| setSelectedId(null); |
| setDraft(emptyDraft()); |
| await loadForms(); |
| } catch (e) { |
| setError(e.message || 'Delete failed'); |
| } |
| }; |
|
|
| const addPreset = (preset) => { |
| setDraft((d) => { |
| const keys = new Set(d.fields.map((f) => f.key)); |
| let key = preset.key; |
| let n = 2; |
| while (keys.has(key)) { |
| key = `${preset.key}_${n}`; |
| n += 1; |
| } |
| return { |
| ...d, |
| fields: [...d.fields, { ...preset, key }], |
| }; |
| }); |
| }; |
|
|
| const updateField = (idx, patch) => { |
| setDraft((d) => ({ |
| ...d, |
| fields: d.fields.map((f, i) => (i === idx ? { ...f, ...patch } : f)), |
| })); |
| }; |
|
|
| const removeField = (idx) => { |
| setDraft((d) => ({ |
| ...d, |
| fields: d.fields.filter((_, i) => i !== idx), |
| })); |
| }; |
|
|
| const reorderFields = (fromIdx, toIdx) => { |
| if (fromIdx === toIdx || fromIdx == null || toIdx == null) return; |
| setDraft((d) => { |
| const fields = [...d.fields]; |
| const [moved] = fields.splice(fromIdx, 1); |
| fields.splice(toIdx, 0, moved); |
| return { ...d, fields }; |
| }); |
| }; |
|
|
| const onFieldDragStart = (idx) => (e) => { |
| dragFieldIdxRef.current = idx; |
| e.dataTransfer.effectAllowed = 'move'; |
| e.dataTransfer.setData('text/plain', String(idx)); |
| }; |
|
|
| const onFieldDragOver = (idx) => (e) => { |
| e.preventDefault(); |
| e.dataTransfer.dropEffect = 'move'; |
| setDropFieldIdx(idx); |
| }; |
|
|
| const onFieldDrop = (idx) => (e) => { |
| e.preventDefault(); |
| const fromIdx = Number(e.dataTransfer.getData('text/plain')); |
| reorderFields(fromIdx, idx); |
| dragFieldIdxRef.current = null; |
| setDropFieldIdx(null); |
| }; |
|
|
| const onFieldDragEnd = () => { |
| dragFieldIdxRef.current = null; |
| setDropFieldIdx(null); |
| }; |
|
|
| return ( |
| <AppShell |
| title="CTA Forms" |
| subtitle="Build embeddable lead forms for your website. Submissions appear in Leads." |
| > |
| <div className="w-full max-w-7xl"> |
| <div className="mb-6 flex flex-wrap items-center gap-3"> |
| <Button type="button" variant="outline" className="gap-2" onClick={onBack}> |
| <ArrowLeft className="h-4 w-4" aria-hidden /> |
| Back to settings |
| </Button> |
| <Button type="button" onClick={createForm} disabled={creating} className="gap-2"> |
| {creating ? ( |
| <Loader2 className="h-4 w-4 animate-spin" /> |
| ) : ( |
| <Plus className="h-4 w-4" /> |
| )} |
| New form |
| </Button> |
| </div> |
| |
| {error ? ( |
| <p className="mb-4 text-sm text-red-600 rounded-lg border border-red-200 bg-red-50 px-3 py-2"> |
| {error} |
| </p> |
| ) : null} |
| |
| {loading ? ( |
| <div className="flex justify-center py-20 text-slate-400"> |
| <Loader2 className="h-10 w-10 animate-spin" /> |
| </div> |
| ) : ( |
| <div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] xl:grid-cols-[220px_minmax(0,1fr)_300px]"> |
| <aside className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm h-fit"> |
| <p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500"> |
| Your forms |
| </p> |
| {forms.length === 0 ? ( |
| <p className="px-2 py-4 text-sm text-slate-500"> |
| No forms yet. Create one to get embed codes. |
| </p> |
| ) : ( |
| <ul className="space-y-1"> |
| {forms.map((f) => ( |
| <li key={f.id}> |
| <button |
| type="button" |
| onClick={() => selectForm(f)} |
| className={cn( |
| 'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors', |
| selectedId === f.id |
| ? 'bg-violet-100 text-violet-800 font-medium' |
| : 'text-slate-700 hover:bg-slate-50' |
| )} |
| > |
| {f.name || 'Untitled'} |
| </button> |
| </li> |
| ))} |
| </ul> |
| )} |
| </aside> |
| |
| <div className="space-y-6 min-w-0"> |
| {!selected ? ( |
| <div className="rounded-2xl border border-dashed border-slate-200 bg-white p-10 text-center text-slate-500"> |
| Create a form to configure fields and copy embed codes for WordPress. |
| </div> |
| ) : ( |
| <> |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4"> |
| <div className="flex flex-wrap items-end gap-3 justify-between"> |
| <div className="flex-1 min-w-[12rem]"> |
| <label className="text-xs font-medium text-slate-600"> |
| Form name |
| </label> |
| <p className="text-[10px] text-slate-400 mt-0.5"> |
| Internal label only — not shown on the embed |
| </p> |
| <Input |
| value={draft.name} |
| onChange={(e) => |
| setDraft((d) => ({ |
| ...d, |
| name: e.target.value, |
| })) |
| } |
| className="mt-1" |
| /> |
| </div> |
| <div className="flex items-center gap-3 pb-1"> |
| <SaveIndicator state={saveState} /> |
| <Button |
| type="button" |
| variant="outline" |
| size="sm" |
| className="text-red-600" |
| onClick={deleteForm} |
| > |
| <Trash2 className="h-4 w-4" /> |
| </Button> |
| </div> |
| </div> |
| |
| <div> |
| <p className="text-xs font-medium text-slate-600 mb-2"> |
| Add field |
| </p> |
| <div className="flex flex-wrap gap-2"> |
| {FIELD_PRESETS.map((p) => ( |
| <Button |
| key={p.key} |
| type="button" |
| variant="outline" |
| size="sm" |
| onClick={() => addPreset(p)} |
| > |
| + {p.label} |
| </Button> |
| ))} |
| </div> |
| </div> |
| |
| <div className="space-y-3"> |
| <p className="text-xs font-semibold uppercase tracking-wide text-slate-500"> |
| Fields |
| </p> |
| {draft.fields.length > 1 ? ( |
| <p className="text-[10px] text-slate-400"> |
| Drag the handle to reorder fields |
| </p> |
| ) : null} |
| {draft.fields.length === 0 ? ( |
| <p className="text-sm text-slate-500"> |
| Add at least Name and Email. |
| </p> |
| ) : ( |
| draft.fields.map((f, idx) => ( |
| <div |
| key={f.key} |
| onDragOver={onFieldDragOver(idx)} |
| onDrop={onFieldDrop(idx)} |
| onDragEnd={onFieldDragEnd} |
| className={cn( |
| 'flex flex-wrap gap-2 items-end rounded-xl border bg-slate-50/80 p-3 transition-colors', |
| dropFieldIdx === idx |
| ? 'border-violet-300 bg-violet-50/60' |
| : 'border-slate-100' |
| )} |
| > |
| <button |
| type="button" |
| draggable |
| onDragStart={onFieldDragStart(idx)} |
| onDragEnd={onFieldDragEnd} |
| className="mb-2.5 shrink-0 cursor-grab rounded p-0.5 text-slate-300 hover:text-slate-500 active:cursor-grabbing" |
| aria-label={`Drag to reorder ${f.label}`} |
| > |
| <GripVertical className="h-4 w-4" /> |
| </button> |
| <div className="flex-1 min-w-[8rem]"> |
| <label className="text-[10px] text-slate-500"> |
| Label |
| </label> |
| <Input |
| value={f.label} |
| onChange={(e) => |
| updateField(idx, { |
| label: e.target.value, |
| }) |
| } |
| className="h-9 text-sm" |
| /> |
| </div> |
| <div className="w-28"> |
| <label className="text-[10px] text-slate-500"> |
| Type |
| </label> |
| <Select |
| value={f.type} |
| onValueChange={(v) => |
| updateField(idx, { type: v }) |
| } |
| > |
| <SelectTrigger className="h-9 text-sm"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| <SelectItem value="text"> |
| Text |
| </SelectItem> |
| <SelectItem value="email"> |
| Email |
| </SelectItem> |
| <SelectItem value="tel"> |
| Phone |
| </SelectItem> |
| <SelectItem value="textarea"> |
| Long text |
| </SelectItem> |
| </SelectContent> |
| </Select> |
| </div> |
| <div className="w-36"> |
| <label className="text-[10px] text-slate-500"> |
| Maps to lead |
| </label> |
| <Select |
| value={f.map_to} |
| onValueChange={(v) => |
| updateField(idx, { map_to: v }) |
| } |
| > |
| <SelectTrigger className="h-9 text-sm"> |
| <SelectValue /> |
| </SelectTrigger> |
| <SelectContent> |
| {MAP_OPTIONS.map((o) => ( |
| <SelectItem |
| key={o.value} |
| value={o.value} |
| > |
| {o.label} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| <label className="flex items-center gap-1.5 text-xs text-slate-600 pb-2"> |
| <input |
| type="checkbox" |
| checked={!!f.required} |
| onChange={(e) => |
| updateField(idx, { |
| required: e.target.checked, |
| }) |
| } |
| /> |
| Required |
| </label> |
| <Button |
| type="button" |
| variant="ghost" |
| size="icon" |
| className="h-9 w-9 text-red-500" |
| onClick={() => removeField(idx)} |
| > |
| <Trash2 className="h-4 w-4" /> |
| </Button> |
| </div> |
| )) |
| )} |
| </div> |
| </section> |
| |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4 xl:hidden"> |
| <PreviewPanel draft={draft} previewUrl={previewUrl} /> |
| </section> |
| |
| <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4"> |
| <div className="flex items-center gap-2 text-slate-800 font-semibold"> |
| <FileInput className="h-5 w-5 text-violet-600" /> |
| Embed on WordPress |
| </div> |
| <p className="text-sm text-slate-600"> |
| Paste either code into a WordPress HTML block, page builder, or |
| theme widget. The form sizes itself to fit your page width. |
| Submissions go straight to <strong>Leads</strong> in this workspace. |
| </p> |
| |
| <CopyBlock |
| label="Iframe embed (recommended)" |
| code={embedCodes.embed_iframe} |
| /> |
| <CopyBlock label="Script embed" code={embedCodes.embed_script} /> |
| {previewUrl ? ( |
| <p className="text-xs text-slate-500"> |
| Full-page preview:{' '} |
| <a |
| href={previewUrl} |
| target="_blank" |
| rel="noreferrer" |
| className="inline-flex items-center gap-1 text-violet-600 underline" |
| > |
| Open in new tab |
| <ExternalLink className="h-3 w-3" /> |
| </a> |
| </p> |
| ) : null} |
| </section> |
| </> |
| )} |
| </div> |
| |
| {selected ? ( |
| <aside className="hidden xl:block"> |
| <div className="sticky top-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"> |
| <PreviewPanel draft={draft} previewUrl={previewUrl} /> |
| </div> |
| </aside> |
| ) : null} |
| </div> |
| )} |
| </div> |
| </AppShell> |
| ); |
| } |
|
|
| function PreviewPanel({ draft, previewUrl }) { |
| return ( |
| <div className="space-y-3"> |
| <div className="flex items-center justify-between gap-2"> |
| <p className="text-sm font-semibold text-slate-800">Live preview</p> |
| {previewUrl ? ( |
| <a |
| href={previewUrl} |
| target="_blank" |
| rel="noreferrer" |
| className="inline-flex items-center gap-1 text-xs text-violet-600 hover:underline" |
| > |
| Open tab |
| <ExternalLink className="h-3 w-3" /> |
| </a> |
| ) : null} |
| </div> |
| <div className="overflow-hidden rounded-xl border border-slate-200 bg-slate-50 p-1"> |
| <CtaFormPreview fields={draft.fields} className="border-0 shadow-none w-full" /> |
| </div> |
| <p className="text-[10px] text-slate-400 text-center"> |
| Updates as you edit · matches embed appearance |
| </p> |
| </div> |
| ); |
| } |
|
|