Download frontend/src/components/settings/CtaFormsSettings.jsx from Seth0330/qualifier: direct link, hf CLI and curl.
- Browser
- Download file 33.4 kB
-
https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/frontend/src/components/settings/CtaFormsSettings.jsx
- Command line
-
hf download hf://spaces/Seth0330/qualifier/frontend/src/components/settings/CtaFormsSettings.jsx
-
curl -L -o CtaFormsSettings.jsx https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/frontend/src/components/settings/CtaFormsSettings.jsx
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"> | |
| </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> | |
| ); | |
| } | |