Seth commited on
Commit
b1324bf
·
1 Parent(s): 32f4dc0
backend/app/cta_forms_routes.py CHANGED
@@ -88,31 +88,74 @@ def _normalize_fields(raw: List[Any]) -> List[dict]:
88
  return out or list(DEFAULT_FIELDS)
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  def _form_to_dict(row: CtaForm, *, request: Optional[Request] = None) -> dict:
92
  origin = ""
93
  if request is not None:
94
  origin = _public_origin(request)
95
  pid = row.public_id
 
 
 
 
 
 
 
 
 
96
  return {
97
  "id": row.id,
98
  "public_id": pid,
99
  "name": row.name or "",
100
  "fields": row.fields if isinstance(row.fields, list) else [],
 
 
101
  "is_active": bool(row.is_active),
102
  "created_at": row.created_at.isoformat() if row.created_at else None,
103
  "updated_at": row.updated_at.isoformat() if row.updated_at else None,
104
- "embed_iframe": (
105
- f'<iframe src="{origin}/embed/cta/{pid}" title="{row.name or "Form"}" '
106
- f'width="100%" height="480" style="border:none;border-radius:12px;" loading="lazy"></iframe>'
107
- if origin
108
- else ""
109
- ),
110
- "embed_script": (
111
- f'<div id="ezofis-cta-{pid}"></div>\n'
112
- f'<script src="{origin}/api/embed/cta/{pid}.js" async></script>'
113
- if origin
114
- else ""
115
- ),
116
  }
117
 
118
 
@@ -308,6 +351,8 @@ def create_cta_form(
308
  public_id=uuid.uuid4().hex,
309
  name=body.name.strip(),
310
  fields=fields,
 
 
311
  is_active=1,
312
  )
313
  tc.db.add(row)
@@ -348,6 +393,10 @@ def patch_cta_form(
348
  row.fields = _normalize_fields([f.model_dump() for f in body.fields])
349
  if body.is_active is not None:
350
  row.is_active = 1 if body.is_active else 0
 
 
 
 
351
  row.updated_at = datetime.utcnow()
352
  db_commit_with_retry(tc.db)
353
  tc.db.refresh(row)
@@ -415,12 +464,10 @@ def embed_cta_script(public_id: str, request: Request, db: Session = Depends(get
415
  form = _get_active_form(db, public_id)
416
  origin = _public_origin(request)
417
  fields_json = json.dumps(form.fields if isinstance(form.fields, list) else [])
418
- form_name = json.dumps(form.name or "Contact us")
419
  js = f"""(function() {{
420
  var FORM_ID = {json.dumps(public_id)};
421
  var API = {json.dumps(origin)};
422
  var FIELDS = {fields_json};
423
- var TITLE = {form_name};
424
  var mount = document.getElementById('ezofis-cta-' + FORM_ID);
425
  if (!mount) {{
426
  var s = document.currentScript;
@@ -430,14 +477,11 @@ def embed_cta_script(public_id: str, request: Request, db: Session = Depends(get
430
  else document.body.appendChild(mount);
431
  }}
432
  var css = document.createElement('style');
433
- css.textContent = '.ezofis-cta{{font-family:system-ui,-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:20px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;box-sizing:border-box}}.ezofis-cta *{{box-sizing:border-box}}.ezofis-cta h3{{margin:0 0 16px;font-size:1.125rem;color:#1e293b}}.ezofis-cta label{{display:block;font-size:0.8125rem;font-weight:500;color:#475569;margin-bottom:4px}}.ezofis-cta input,.ezofis-cta textarea{{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:0.9375rem;margin-bottom:12px}}.ezofis-cta textarea{{min-height:96px;resize:vertical}}.ezofis-cta button{{width:100%;padding:12px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-weight:600;font-size:0.9375rem;cursor:pointer}}.ezofis-cta button:disabled{{opacity:0.6;cursor:not-allowed}}.ezofis-cta .ok{{color:#15803d;font-size:0.875rem;text-align:center;padding:12px}}.ezofis-cta .err{{color:#b91c1c;font-size:0.8125rem;margin-bottom:8px}}';
434
  document.head.appendChild(css);
435
  var root = document.createElement('div');
436
  root.className = 'ezofis-cta';
437
  mount.appendChild(root);
438
- var h = document.createElement('h3');
439
- h.textContent = TITLE;
440
- root.appendChild(h);
441
  var err = document.createElement('div');
442
  err.className = 'err';
443
  err.style.display = 'none';
 
88
  return out or list(DEFAULT_FIELDS)
89
 
90
 
91
+ def _normalize_embed_width(raw: Any) -> str:
92
+ s = _safe_str(raw) or "100%"
93
+ if len(s) > 32:
94
+ s = s[:32]
95
+ return s or "100%"
96
+
97
+
98
+ def _normalize_embed_height(raw: Any) -> str:
99
+ s = _safe_str(raw) or "480"
100
+ if s.isdigit():
101
+ return s
102
+ if s.endswith("px") or s.endswith("%"):
103
+ return s[:32]
104
+ return "480"
105
+
106
+
107
+ def _build_embed_codes(
108
+ *,
109
+ origin: str,
110
+ public_id: str,
111
+ name: str,
112
+ embed_width: str,
113
+ embed_height: str,
114
+ ) -> dict:
115
+ title = (name or "Form").replace('"', "&quot;")
116
+ w = _normalize_embed_width(embed_width)
117
+ h = _normalize_embed_height(embed_height)
118
+ return {
119
+ "embed_iframe": (
120
+ f'<iframe src="{origin}/embed/cta/{public_id}" title="{title}" '
121
+ f'width="{w}" height="{h}" style="border:none;border-radius:12px;" loading="lazy"></iframe>'
122
+ if origin
123
+ else ""
124
+ ),
125
+ "embed_script": (
126
+ f'<div id="ezofis-cta-{public_id}"></div>\n'
127
+ f'<script src="{origin}/api/embed/cta/{public_id}.js" async></script>'
128
+ if origin
129
+ else ""
130
+ ),
131
+ }
132
+
133
+
134
  def _form_to_dict(row: CtaForm, *, request: Optional[Request] = None) -> dict:
135
  origin = ""
136
  if request is not None:
137
  origin = _public_origin(request)
138
  pid = row.public_id
139
+ embed_w = _normalize_embed_width(getattr(row, "embed_width", None))
140
+ embed_h = _normalize_embed_height(getattr(row, "embed_height", None))
141
+ codes = _build_embed_codes(
142
+ origin=origin,
143
+ public_id=pid,
144
+ name=row.name or "",
145
+ embed_width=embed_w,
146
+ embed_height=embed_h,
147
+ )
148
  return {
149
  "id": row.id,
150
  "public_id": pid,
151
  "name": row.name or "",
152
  "fields": row.fields if isinstance(row.fields, list) else [],
153
+ "embed_width": embed_w,
154
+ "embed_height": embed_h,
155
  "is_active": bool(row.is_active),
156
  "created_at": row.created_at.isoformat() if row.created_at else None,
157
  "updated_at": row.updated_at.isoformat() if row.updated_at else None,
158
+ **codes,
 
 
 
 
 
 
 
 
 
 
 
159
  }
160
 
161
 
 
351
  public_id=uuid.uuid4().hex,
352
  name=body.name.strip(),
353
  fields=fields,
354
+ embed_width=_normalize_embed_width(body.embed_width),
355
+ embed_height=_normalize_embed_height(body.embed_height),
356
  is_active=1,
357
  )
358
  tc.db.add(row)
 
393
  row.fields = _normalize_fields([f.model_dump() for f in body.fields])
394
  if body.is_active is not None:
395
  row.is_active = 1 if body.is_active else 0
396
+ if body.embed_width is not None:
397
+ row.embed_width = _normalize_embed_width(body.embed_width)
398
+ if body.embed_height is not None:
399
+ row.embed_height = _normalize_embed_height(body.embed_height)
400
  row.updated_at = datetime.utcnow()
401
  db_commit_with_retry(tc.db)
402
  tc.db.refresh(row)
 
464
  form = _get_active_form(db, public_id)
465
  origin = _public_origin(request)
466
  fields_json = json.dumps(form.fields if isinstance(form.fields, list) else [])
 
467
  js = f"""(function() {{
468
  var FORM_ID = {json.dumps(public_id)};
469
  var API = {json.dumps(origin)};
470
  var FIELDS = {fields_json};
 
471
  var mount = document.getElementById('ezofis-cta-' + FORM_ID);
472
  if (!mount) {{
473
  var s = document.currentScript;
 
477
  else document.body.appendChild(mount);
478
  }}
479
  var css = document.createElement('style');
480
+ css.textContent = '.ezofis-cta{{font-family:system-ui,-apple-system,sans-serif;max-width:100%;margin:0 auto;padding:16px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;box-sizing:border-box}}.ezofis-cta *{{box-sizing:border-box}}.ezofis-cta label{{display:block;font-size:0.8125rem;font-weight:500;color:#475569;margin-bottom:4px}}.ezofis-cta input,.ezofis-cta textarea{{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:0.9375rem;margin-bottom:12px}}.ezofis-cta textarea{{min-height:96px;resize:vertical}}.ezofis-cta button{{width:100%;padding:12px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-weight:600;font-size:0.9375rem;cursor:pointer}}.ezofis-cta button:disabled{{opacity:0.6;cursor:not-allowed}}.ezofis-cta .ok{{color:#15803d;font-size:0.875rem;text-align:center;padding:12px}}.ezofis-cta .err{{color:#b91c1c;font-size:0.8125rem;margin-bottom:8px}}';
481
  document.head.appendChild(css);
482
  var root = document.createElement('div');
483
  root.className = 'ezofis-cta';
484
  mount.appendChild(root);
 
 
 
485
  var err = document.createElement('div');
486
  err.className = 'err';
487
  err.style.display = 'none';
backend/app/database.py CHANGED
@@ -378,6 +378,8 @@ class CtaForm(Base):
378
  public_id = Column(String, unique=True, index=True, nullable=False)
379
  name = Column(String, nullable=False, default="")
380
  fields = Column(JSON, nullable=False, default=list)
 
 
381
  is_active = Column(Integer, default=1)
382
  created_at = Column(DateTime, default=datetime.utcnow)
383
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -593,6 +595,14 @@ def run_migrations(connection_engine):
593
  if "bounced_at" not in rcols:
594
  conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN bounced_at DATETIME"))
595
 
 
 
 
 
 
 
 
 
596
 
597
  # Create tables then migrate legacy SQLite schemas
598
  Base.metadata.create_all(bind=engine)
 
378
  public_id = Column(String, unique=True, index=True, nullable=False)
379
  name = Column(String, nullable=False, default="")
380
  fields = Column(JSON, nullable=False, default=list)
381
+ embed_width = Column(String, default="100%")
382
+ embed_height = Column(String, default="480")
383
  is_active = Column(Integer, default=1)
384
  created_at = Column(DateTime, default=datetime.utcnow)
385
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 
595
  if "bounced_at" not in rcols:
596
  conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN bounced_at DATETIME"))
597
 
598
+ insp = inspect(connection_engine)
599
+ if insp.has_table("cta_forms"):
600
+ ccols = [c["name"] for c in insp.get_columns("cta_forms")]
601
+ if "embed_width" not in ccols:
602
+ conn.execute(text("ALTER TABLE cta_forms ADD COLUMN embed_width TEXT DEFAULT '100%'"))
603
+ if "embed_height" not in ccols:
604
+ conn.execute(text("ALTER TABLE cta_forms ADD COLUMN embed_height TEXT DEFAULT '480'"))
605
+
606
 
607
  # Create tables then migrate legacy SQLite schemas
608
  Base.metadata.create_all(bind=engine)
backend/app/models.py CHANGED
@@ -301,12 +301,16 @@ class CtaFormFieldDef(BaseModel):
301
  class CtaFormCreateRequest(BaseModel):
302
  name: str = Field(..., min_length=1, max_length=200)
303
  fields: List[CtaFormFieldDef] = Field(default_factory=list)
 
 
304
 
305
 
306
  class CtaFormPatchRequest(BaseModel):
307
  name: Optional[str] = Field(None, min_length=1, max_length=200)
308
  fields: Optional[List[CtaFormFieldDef]] = None
309
  is_active: Optional[bool] = None
 
 
310
 
311
 
312
  class CtaFormSubmitRequest(BaseModel):
 
301
  class CtaFormCreateRequest(BaseModel):
302
  name: str = Field(..., min_length=1, max_length=200)
303
  fields: List[CtaFormFieldDef] = Field(default_factory=list)
304
+ embed_width: Optional[str] = Field(default="100%", max_length=32)
305
+ embed_height: Optional[str] = Field(default="480", max_length=32)
306
 
307
 
308
  class CtaFormPatchRequest(BaseModel):
309
  name: Optional[str] = Field(None, min_length=1, max_length=200)
310
  fields: Optional[List[CtaFormFieldDef]] = None
311
  is_active: Optional[bool] = None
312
+ embed_width: Optional[str] = Field(None, max_length=32)
313
+ embed_height: Optional[str] = Field(None, max_length=32)
314
 
315
 
316
  class CtaFormSubmitRequest(BaseModel):
frontend/src/components/settings/CtaFormPreview.jsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ /**
5
+ * Static preview of embed form fields (matches public embed styling).
6
+ */
7
+ export default function CtaFormPreview({ fields, className }) {
8
+ const list = fields || [];
9
+
10
+ return (
11
+ <div className={cn('rounded-xl border border-slate-200 bg-white p-4', className)}>
12
+ {list.length === 0 ? (
13
+ <p className="text-sm text-slate-400 text-center py-6">Add fields to preview</p>
14
+ ) : (
15
+ <div className="space-y-3">
16
+ {list.map((f, idx) => (
17
+ <div key={`${f.key}-${idx}`}>
18
+ <label className="block text-xs font-medium text-slate-600 mb-1">
19
+ {f.label}
20
+ {f.required ? ' *' : ''}
21
+ </label>
22
+ {f.type === 'textarea' ? (
23
+ <textarea
24
+ readOnly
25
+ tabIndex={-1}
26
+ rows={3}
27
+ placeholder=""
28
+ className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm bg-white pointer-events-none"
29
+ />
30
+ ) : (
31
+ <input
32
+ readOnly
33
+ tabIndex={-1}
34
+ type={f.type || 'text'}
35
+ placeholder=""
36
+ className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm bg-white pointer-events-none"
37
+ />
38
+ )}
39
+ </div>
40
+ ))}
41
+ <button
42
+ type="button"
43
+ tabIndex={-1}
44
+ className="w-full rounded-lg bg-violet-600 py-2.5 text-sm font-semibold text-white pointer-events-none"
45
+ >
46
+ Submit
47
+ </button>
48
+ </div>
49
+ )}
50
+ </div>
51
+ );
52
+ }
frontend/src/components/settings/CtaFormsSettings.jsx CHANGED
@@ -1,7 +1,8 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import {
3
  ArrowLeft,
4
  ClipboardCopy,
 
5
  FileInput,
6
  Loader2,
7
  Plus,
@@ -19,7 +20,9 @@ import {
19
  SelectValue,
20
  } from '@/components/ui/select';
21
  import AppShell from '@/components/layout/AppShell';
 
22
  import { apiFetch } from '@/lib/api';
 
23
  import { cn } from '@/lib/utils';
24
 
25
  const FIELD_PRESETS = [
@@ -47,10 +50,23 @@ const MAP_OPTIONS = [
47
  { value: 'custom', label: 'Custom (in submission only)' },
48
  ];
49
 
 
 
50
  function emptyDraft() {
51
  return {
52
  name: 'New contact form',
53
  fields: FIELD_PRESETS.slice(0, 3).map((f) => ({ ...f })),
 
 
 
 
 
 
 
 
 
 
 
54
  };
55
  }
56
 
@@ -71,25 +87,65 @@ function CopyBlock({ label, code }) {
71
  Copy
72
  </Button>
73
  </div>
74
- <Textarea readOnly value={code || ''} rows={3} className="text-xs font-mono h-auto min-h-[4.5rem]" />
 
 
 
 
 
75
  </div>
76
  );
77
  }
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  export default function CtaFormsSettings({ onBack }) {
80
  const [forms, setForms] = useState([]);
81
  const [loading, setLoading] = useState(true);
82
  const [selectedId, setSelectedId] = useState(null);
83
  const [draft, setDraft] = useState(emptyDraft());
84
- const [saving, setSaving] = useState(false);
85
  const [creating, setCreating] = useState(false);
86
  const [error, setError] = useState('');
87
 
 
 
 
88
  const selected = useMemo(
89
  () => forms.find((f) => f.id === selectedId) || null,
90
  [forms, selectedId]
91
  );
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  const loadForms = useCallback(async () => {
94
  setLoading(true);
95
  setError('');
@@ -117,18 +173,55 @@ export default function CtaFormsSettings({ onBack }) {
117
 
118
  useEffect(() => {
119
  if (!selected) return;
120
- setDraft({
121
- name: selected.name,
122
- fields: (selected.fields || []).map((f) => ({ ...f })),
123
- });
124
  }, [selected?.id]);
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  const selectForm = (f) => {
127
  setSelectedId(f.id);
128
- setDraft({
129
- name: f.name,
130
- fields: (f.fields || []).map((x) => ({ ...x })),
131
- });
132
  };
133
 
134
  const createForm = async () => {
@@ -141,13 +234,16 @@ export default function CtaFormsSettings({ onBack }) {
141
  body: JSON.stringify({
142
  name: 'New contact form',
143
  fields: FIELD_PRESETS.slice(0, 3),
 
 
144
  }),
145
  });
146
  const data = await res.json();
147
  if (!res.ok) throw new Error(data.detail || 'Create failed');
148
  await loadForms();
149
  setSelectedId(data.id);
150
- setDraft({ name: data.name, fields: data.fields || [] });
 
151
  } catch (e) {
152
  setError(e.message || 'Create failed');
153
  } finally {
@@ -155,29 +251,6 @@ export default function CtaFormsSettings({ onBack }) {
155
  }
156
  };
157
 
158
- const saveForm = async () => {
159
- if (!selectedId) return;
160
- setSaving(true);
161
- setError('');
162
- try {
163
- const res = await apiFetch(`/api/cta-forms/${selectedId}`, {
164
- method: 'PATCH',
165
- headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({
167
- name: draft.name.trim(),
168
- fields: draft.fields,
169
- }),
170
- });
171
- const data = await res.json();
172
- if (!res.ok) throw new Error(data.detail || 'Save failed');
173
- setForms((prev) => prev.map((f) => (f.id === data.id ? data : f)));
174
- } catch (e) {
175
- setError(e.message || 'Save failed');
176
- } finally {
177
- setSaving(false);
178
- }
179
- };
180
-
181
  const deleteForm = async () => {
182
  if (!selectedId) return;
183
  if (!window.confirm('Delete this form? Embed codes will stop working.')) return;
@@ -225,12 +298,19 @@ export default function CtaFormsSettings({ onBack }) {
225
  }));
226
  };
227
 
 
 
 
 
 
 
 
228
  return (
229
  <AppShell
230
  title="CTA Forms"
231
  subtitle="Build embeddable lead forms for your website. Submissions appear in Leads."
232
  >
233
- <div className="w-full max-w-5xl">
234
  <div className="mb-6 flex flex-wrap items-center gap-3">
235
  <Button type="button" variant="outline" className="gap-2" onClick={onBack}>
236
  <ArrowLeft className="h-4 w-4" aria-hidden />
@@ -257,8 +337,8 @@ export default function CtaFormsSettings({ onBack }) {
257
  <Loader2 className="h-10 w-10 animate-spin" />
258
  </div>
259
  ) : (
260
- <div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]">
261
- <aside className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
262
  <p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
263
  Your forms
264
  </p>
@@ -288,7 +368,7 @@ export default function CtaFormsSettings({ onBack }) {
288
  )}
289
  </aside>
290
 
291
- <div className="space-y-6">
292
  {!selected ? (
293
  <div className="rounded-2xl border border-dashed border-slate-200 bg-white p-10 text-center text-slate-500">
294
  Create a form to configure fields and copy embed codes for WordPress.
@@ -301,6 +381,9 @@ export default function CtaFormsSettings({ onBack }) {
301
  <label className="text-xs font-medium text-slate-600">
302
  Form name
303
  </label>
 
 
 
304
  <Input
305
  value={draft.name}
306
  onChange={(e) =>
@@ -312,7 +395,8 @@ export default function CtaFormsSettings({ onBack }) {
312
  className="mt-1"
313
  />
314
  </div>
315
- <div className="flex gap-2">
 
316
  <Button
317
  type="button"
318
  variant="outline"
@@ -322,17 +406,6 @@ export default function CtaFormsSettings({ onBack }) {
322
  >
323
  <Trash2 className="h-4 w-4" />
324
  </Button>
325
- <Button
326
- type="button"
327
- onClick={saveForm}
328
- disabled={saving}
329
- >
330
- {saving ? (
331
- <Loader2 className="h-4 w-4 animate-spin" />
332
- ) : (
333
- 'Save form'
334
- )}
335
- </Button>
336
  </div>
337
  </div>
338
 
@@ -465,6 +538,14 @@ export default function CtaFormsSettings({ onBack }) {
465
  </div>
466
  </section>
467
 
 
 
 
 
 
 
 
 
468
  <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4">
469
  <div className="flex items-center gap-2 text-slate-800 font-semibold">
470
  <FileInput className="h-5 w-5 text-violet-600" />
@@ -475,21 +556,58 @@ export default function CtaFormsSettings({ onBack }) {
475
  theme widget. Submissions go straight to{' '}
476
  <strong>Leads</strong> in this workspace.
477
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  <CopyBlock
479
  label="Iframe embed (recommended)"
480
- code={selected.embed_iframe}
481
  />
482
- <CopyBlock label="Script embed" code={selected.embed_script} />
483
- {selected.public_id ? (
484
  <p className="text-xs text-slate-500">
485
- Preview:{' '}
486
  <a
487
- href={`/embed/cta/${selected.public_id}`}
488
  target="_blank"
489
  rel="noreferrer"
490
- className="text-violet-600 underline"
491
  >
492
- Open form preview
 
493
  </a>
494
  </p>
495
  ) : null}
@@ -497,9 +615,57 @@ export default function CtaFormsSettings({ onBack }) {
497
  </>
498
  )}
499
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
500
  </div>
501
  )}
502
  </div>
503
  </AppShell>
504
  );
505
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import {
3
  ArrowLeft,
4
  ClipboardCopy,
5
+ ExternalLink,
6
  FileInput,
7
  Loader2,
8
  Plus,
 
20
  SelectValue,
21
  } from '@/components/ui/select';
22
  import AppShell from '@/components/layout/AppShell';
23
+ import CtaFormPreview from '@/components/settings/CtaFormPreview';
24
  import { apiFetch } from '@/lib/api';
25
+ import { buildCtaEmbedCodes } from '@/lib/ctaFormEmbed';
26
  import { cn } from '@/lib/utils';
27
 
28
  const FIELD_PRESETS = [
 
50
  { value: 'custom', label: 'Custom (in submission only)' },
51
  ];
52
 
53
+ const AUTOSAVE_MS = 600;
54
+
55
  function emptyDraft() {
56
  return {
57
  name: 'New contact form',
58
  fields: FIELD_PRESETS.slice(0, 3).map((f) => ({ ...f })),
59
+ embed_width: '100%',
60
+ embed_height: '480',
61
+ };
62
+ }
63
+
64
+ function draftFromForm(form) {
65
+ return {
66
+ name: form.name,
67
+ fields: (form.fields || []).map((f) => ({ ...f })),
68
+ embed_width: form.embed_width || '100%',
69
+ embed_height: form.embed_height || '480',
70
  };
71
  }
72
 
 
87
  Copy
88
  </Button>
89
  </div>
90
+ <Textarea
91
+ readOnly
92
+ value={code || ''}
93
+ rows={3}
94
+ className="text-xs font-mono h-auto min-h-[4.5rem]"
95
+ />
96
  </div>
97
  );
98
  }
99
 
100
+ function SaveIndicator({ state }) {
101
+ if (state === 'saving') {
102
+ return (
103
+ <span className="inline-flex items-center gap-1.5 text-xs text-slate-500">
104
+ <Loader2 className="h-3 w-3 animate-spin" />
105
+ Saving…
106
+ </span>
107
+ );
108
+ }
109
+ if (state === 'saved') {
110
+ return <span className="text-xs text-green-600">Saved</span>;
111
+ }
112
+ if (state === 'error') {
113
+ return <span className="text-xs text-red-600">Could not save</span>;
114
+ }
115
+ return null;
116
+ }
117
+
118
  export default function CtaFormsSettings({ onBack }) {
119
  const [forms, setForms] = useState([]);
120
  const [loading, setLoading] = useState(true);
121
  const [selectedId, setSelectedId] = useState(null);
122
  const [draft, setDraft] = useState(emptyDraft());
123
+ const [saveState, setSaveState] = useState('idle');
124
  const [creating, setCreating] = useState(false);
125
  const [error, setError] = useState('');
126
 
127
+ const skipAutosaveRef = useRef(true);
128
+ const saveTimerRef = useRef(null);
129
+
130
  const selected = useMemo(
131
  () => forms.find((f) => f.id === selectedId) || null,
132
  [forms, selectedId]
133
  );
134
 
135
+ const embedCodes = useMemo(() => {
136
+ if (!selected?.public_id) {
137
+ return { embed_iframe: '', embed_script: '' };
138
+ }
139
+ return buildCtaEmbedCodes({
140
+ publicId: selected.public_id,
141
+ name: draft.name,
142
+ embedWidth: draft.embed_width,
143
+ embedHeight: draft.embed_height,
144
+ });
145
+ }, [selected?.public_id, draft.name, draft.embed_width, draft.embed_height]);
146
+
147
+ const previewUrl = selected?.public_id ? `/embed/cta/${selected.public_id}` : '';
148
+
149
  const loadForms = useCallback(async () => {
150
  setLoading(true);
151
  setError('');
 
173
 
174
  useEffect(() => {
175
  if (!selected) return;
176
+ skipAutosaveRef.current = true;
177
+ setDraft(draftFromForm(selected));
178
+ setSaveState('idle');
 
179
  }, [selected?.id]);
180
 
181
+ useEffect(() => {
182
+ if (!selectedId || !selected) return;
183
+ if (skipAutosaveRef.current) {
184
+ skipAutosaveRef.current = false;
185
+ return;
186
+ }
187
+ const name = draft.name?.trim();
188
+ if (!name) return;
189
+
190
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
191
+ saveTimerRef.current = setTimeout(async () => {
192
+ setSaveState('saving');
193
+ try {
194
+ const res = await apiFetch(`/api/cta-forms/${selectedId}`, {
195
+ method: 'PATCH',
196
+ headers: { 'Content-Type': 'application/json' },
197
+ body: JSON.stringify({
198
+ name,
199
+ fields: draft.fields,
200
+ embed_width: draft.embed_width,
201
+ embed_height: draft.embed_height,
202
+ }),
203
+ });
204
+ const data = await res.json();
205
+ if (!res.ok) throw new Error(data.detail || 'Save failed');
206
+ setForms((prev) => prev.map((f) => (f.id === data.id ? data : f)));
207
+ setSaveState('saved');
208
+ window.setTimeout(() => setSaveState('idle'), 2000);
209
+ } catch (e) {
210
+ setError(e.message || 'Save failed');
211
+ setSaveState('error');
212
+ }
213
+ }, AUTOSAVE_MS);
214
+
215
+ return () => {
216
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
217
+ };
218
+ }, [draft, selectedId, selected]);
219
+
220
  const selectForm = (f) => {
221
  setSelectedId(f.id);
222
+ skipAutosaveRef.current = true;
223
+ setDraft(draftFromForm(f));
224
+ setSaveState('idle');
 
225
  };
226
 
227
  const createForm = async () => {
 
234
  body: JSON.stringify({
235
  name: 'New contact form',
236
  fields: FIELD_PRESETS.slice(0, 3),
237
+ embed_width: '100%',
238
+ embed_height: '480',
239
  }),
240
  });
241
  const data = await res.json();
242
  if (!res.ok) throw new Error(data.detail || 'Create failed');
243
  await loadForms();
244
  setSelectedId(data.id);
245
+ skipAutosaveRef.current = true;
246
+ setDraft(draftFromForm(data));
247
  } catch (e) {
248
  setError(e.message || 'Create failed');
249
  } finally {
 
251
  }
252
  };
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  const deleteForm = async () => {
255
  if (!selectedId) return;
256
  if (!window.confirm('Delete this form? Embed codes will stop working.')) return;
 
298
  }));
299
  };
300
 
301
+ const previewFrameHeight = useMemo(() => {
302
+ const h = String(draft.embed_height || '480');
303
+ if (h.endsWith('px')) return h;
304
+ if (/^\d+$/.test(h)) return `${h}px`;
305
+ return '480px';
306
+ }, [draft.embed_height]);
307
+
308
  return (
309
  <AppShell
310
  title="CTA Forms"
311
  subtitle="Build embeddable lead forms for your website. Submissions appear in Leads."
312
  >
313
+ <div className="w-full max-w-7xl">
314
  <div className="mb-6 flex flex-wrap items-center gap-3">
315
  <Button type="button" variant="outline" className="gap-2" onClick={onBack}>
316
  <ArrowLeft className="h-4 w-4" aria-hidden />
 
337
  <Loader2 className="h-10 w-10 animate-spin" />
338
  </div>
339
  ) : (
340
+ <div className="grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)] xl:grid-cols-[220px_minmax(0,1fr)_300px]">
341
+ <aside className="rounded-2xl border border-slate-200 bg-white p-3 shadow-sm h-fit">
342
  <p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
343
  Your forms
344
  </p>
 
368
  )}
369
  </aside>
370
 
371
+ <div className="space-y-6 min-w-0">
372
  {!selected ? (
373
  <div className="rounded-2xl border border-dashed border-slate-200 bg-white p-10 text-center text-slate-500">
374
  Create a form to configure fields and copy embed codes for WordPress.
 
381
  <label className="text-xs font-medium text-slate-600">
382
  Form name
383
  </label>
384
+ <p className="text-[10px] text-slate-400 mt-0.5">
385
+ Internal label only — not shown on the embed
386
+ </p>
387
  <Input
388
  value={draft.name}
389
  onChange={(e) =>
 
395
  className="mt-1"
396
  />
397
  </div>
398
+ <div className="flex items-center gap-3 pb-1">
399
+ <SaveIndicator state={saveState} />
400
  <Button
401
  type="button"
402
  variant="outline"
 
406
  >
407
  <Trash2 className="h-4 w-4" />
408
  </Button>
 
 
 
 
 
 
 
 
 
 
 
409
  </div>
410
  </div>
411
 
 
538
  </div>
539
  </section>
540
 
541
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4 xl:hidden">
542
+ <PreviewPanel
543
+ draft={draft}
544
+ previewUrl={previewUrl}
545
+ previewFrameHeight={previewFrameHeight}
546
+ />
547
+ </section>
548
+
549
  <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4">
550
  <div className="flex items-center gap-2 text-slate-800 font-semibold">
551
  <FileInput className="h-5 w-5 text-violet-600" />
 
556
  theme widget. Submissions go straight to{' '}
557
  <strong>Leads</strong> in this workspace.
558
  </p>
559
+
560
+ <div className="grid gap-3 sm:grid-cols-2">
561
+ <div>
562
+ <label className="text-xs font-medium text-slate-600">
563
+ Embed width
564
+ </label>
565
+ <Input
566
+ value={draft.embed_width}
567
+ onChange={(e) =>
568
+ setDraft((d) => ({
569
+ ...d,
570
+ embed_width: e.target.value,
571
+ }))
572
+ }
573
+ placeholder="100% or 480px"
574
+ className="mt-1 h-9 text-sm"
575
+ />
576
+ </div>
577
+ <div>
578
+ <label className="text-xs font-medium text-slate-600">
579
+ Embed height
580
+ </label>
581
+ <Input
582
+ value={draft.embed_height}
583
+ onChange={(e) =>
584
+ setDraft((d) => ({
585
+ ...d,
586
+ embed_height: e.target.value,
587
+ }))
588
+ }
589
+ placeholder="480 or 600px"
590
+ className="mt-1 h-9 text-sm"
591
+ />
592
+ </div>
593
+ </div>
594
+
595
  <CopyBlock
596
  label="Iframe embed (recommended)"
597
+ code={embedCodes.embed_iframe}
598
  />
599
+ <CopyBlock label="Script embed" code={embedCodes.embed_script} />
600
+ {previewUrl ? (
601
  <p className="text-xs text-slate-500">
602
+ Full-page preview:{' '}
603
  <a
604
+ href={previewUrl}
605
  target="_blank"
606
  rel="noreferrer"
607
+ className="inline-flex items-center gap-1 text-violet-600 underline"
608
  >
609
+ Open in new tab
610
+ <ExternalLink className="h-3 w-3" />
611
  </a>
612
  </p>
613
  ) : null}
 
615
  </>
616
  )}
617
  </div>
618
+
619
+ {selected ? (
620
+ <aside className="hidden xl:block">
621
+ <div className="sticky top-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
622
+ <PreviewPanel
623
+ draft={draft}
624
+ previewUrl={previewUrl}
625
+ previewFrameHeight={previewFrameHeight}
626
+ />
627
+ </div>
628
+ </aside>
629
+ ) : null}
630
  </div>
631
  )}
632
  </div>
633
  </AppShell>
634
  );
635
  }
636
+
637
+ function PreviewPanel({ draft, previewUrl, previewFrameHeight }) {
638
+ return (
639
+ <div className="space-y-3">
640
+ <div className="flex items-center justify-between gap-2">
641
+ <p className="text-sm font-semibold text-slate-800">Live preview</p>
642
+ {previewUrl ? (
643
+ <a
644
+ href={previewUrl}
645
+ target="_blank"
646
+ rel="noreferrer"
647
+ className="inline-flex items-center gap-1 text-xs text-violet-600 hover:underline"
648
+ >
649
+ Open tab
650
+ <ExternalLink className="h-3 w-3" />
651
+ </a>
652
+ ) : null}
653
+ </div>
654
+ <div
655
+ className="mx-auto overflow-hidden rounded-xl border border-slate-200 bg-slate-50"
656
+ style={{
657
+ width: draft.embed_width || '100%',
658
+ maxWidth: '100%',
659
+ height: previewFrameHeight,
660
+ }}
661
+ >
662
+ <div className="h-full overflow-y-auto p-1">
663
+ <CtaFormPreview fields={draft.fields} className="border-0 shadow-none" />
664
+ </div>
665
+ </div>
666
+ <p className="text-[10px] text-slate-400 text-center">
667
+ Updates as you edit · matches embed appearance
668
+ </p>
669
+ </div>
670
+ );
671
+ }
frontend/src/lib/ctaFormEmbed.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Client-side embed snippets (updates instantly while editing).
3
+ */
4
+ export function buildCtaEmbedCodes({
5
+ publicId,
6
+ name,
7
+ embedWidth = '100%',
8
+ embedHeight = '480',
9
+ origin,
10
+ }) {
11
+ if (!publicId) {
12
+ return { embed_iframe: '', embed_script: '' };
13
+ }
14
+ const base =
15
+ origin || (typeof window !== 'undefined' ? window.location.origin : '');
16
+ const title = (name || 'Form').replace(/"/g, '&quot;');
17
+ const w = embedWidth || '100%';
18
+ const h = embedHeight || '480';
19
+ return {
20
+ embed_iframe: `<iframe src="${base}/embed/cta/${publicId}" title="${title}" width="${w}" height="${h}" style="border:none;border-radius:12px;" loading="lazy"></iframe>`,
21
+ embed_script: `<div id="ezofis-cta-${publicId}"></div>\n<script src="${base}/api/embed/cta/${publicId}.js" async></script>`,
22
+ };
23
+ }
frontend/src/pages/CtaFormEmbed.jsx CHANGED
@@ -1,7 +1,5 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
2
  import { Loader2 } from 'lucide-react';
3
- import { apiFetch } from '@/lib/api';
4
- import { APP_NAME } from '@/lib/branding';
5
 
6
  /**
7
  * Public embed page (iframe). No sign-in required.
@@ -75,7 +73,7 @@ export default function CtaFormEmbed() {
75
 
76
  if (phase === 'loading') {
77
  return (
78
- <div className="flex min-h-[100dvh] items-center justify-center bg-slate-50">
79
  <Loader2 className="h-8 w-8 animate-spin text-violet-600" />
80
  </div>
81
  );
@@ -83,18 +81,15 @@ export default function CtaFormEmbed() {
83
 
84
  if (phase === 'error') {
85
  return (
86
- <div className="flex min-h-[100dvh] items-center justify-center bg-slate-50 p-6">
87
  <p className="text-sm text-red-600">{error}</p>
88
  </div>
89
  );
90
  }
91
 
92
  return (
93
- <div className="min-h-[100dvh] bg-slate-50 p-4 sm:p-6 flex items-start justify-center">
94
- <div className="w-full max-w-md rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
95
- <h1 className="text-lg font-semibold text-slate-800 mb-1">{form.name}</h1>
96
- <p className="text-xs text-slate-400 mb-5">{APP_NAME}</p>
97
-
98
  {done ? (
99
  <p className="text-sm text-green-700 text-center py-6">{doneMsg}</p>
100
  ) : (
 
1
+ import React, { useEffect, useState } from 'react';
2
  import { Loader2 } from 'lucide-react';
 
 
3
 
4
  /**
5
  * Public embed page (iframe). No sign-in required.
 
73
 
74
  if (phase === 'loading') {
75
  return (
76
+ <div className="flex min-h-[12rem] items-center justify-center bg-transparent p-4">
77
  <Loader2 className="h-8 w-8 animate-spin text-violet-600" />
78
  </div>
79
  );
 
81
 
82
  if (phase === 'error') {
83
  return (
84
+ <div className="flex min-h-[12rem] items-center justify-center bg-transparent p-4">
85
  <p className="text-sm text-red-600">{error}</p>
86
  </div>
87
  );
88
  }
89
 
90
  return (
91
+ <div className="bg-transparent p-4">
92
+ <div className="w-full rounded-xl border border-slate-200 bg-white p-4">
 
 
 
93
  {done ? (
94
  <p className="text-sm text-green-700 text-center py-6">{doneMsg}</p>
95
  ) : (