EMAILOUT / frontend /src /components /settings /CtaFormsSettings.jsx
Seth
update
7fe6a68
Raw
History Blame Contribute Delete
33.4 kB
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>
);
}