Seth commited on
Commit
32f4dc0
·
1 Parent(s): ef3e137
backend/app/cta_forms_routes.py ADDED
@@ -0,0 +1,507 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embeddable CTA forms: create in Settings, embed on WordPress, submissions → Leads.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import uuid
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException, Request
14
+ from fastapi.responses import JSONResponse, PlainTextResponse, Response
15
+ from sqlalchemy.orm import Session
16
+
17
+ from .database import CrmLead, CtaForm, db_commit_with_retry, get_db
18
+ from .models import CtaFormCreateRequest, CtaFormPatchRequest, CtaFormSubmitRequest
19
+ from .tenant_deps import TenantContext, get_tenant_context
20
+
21
+ router = APIRouter(tags=["cta-forms"])
22
+
23
+ CTA_LEAD_SOURCE = "cta_form"
24
+ CTA_LEAD_ID_PREFIX = "cta-"
25
+
26
+ DEFAULT_FIELDS: List[dict] = [
27
+ {"key": "name", "label": "Name", "type": "text", "required": True, "map_to": "name"},
28
+ {"key": "email", "label": "Email", "type": "email", "required": True, "map_to": "email"},
29
+ {
30
+ "key": "company_name",
31
+ "label": "Company Name",
32
+ "type": "text",
33
+ "required": False,
34
+ "map_to": "company_name",
35
+ },
36
+ ]
37
+
38
+ _CORS_HEADERS = {
39
+ "Access-Control-Allow-Origin": "*",
40
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
41
+ "Access-Control-Allow-Headers": "Content-Type",
42
+ }
43
+
44
+
45
+ def _safe_str(val: Any) -> str:
46
+ if val is None:
47
+ return ""
48
+ return str(val).strip()
49
+
50
+
51
+ def _slug_key(label: str, existing: set[str]) -> str:
52
+ base = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") or "field"
53
+ key = base
54
+ n = 2
55
+ while key in existing:
56
+ key = f"{base}_{n}"
57
+ n += 1
58
+ existing.add(key)
59
+ return key
60
+
61
+
62
+ def _normalize_fields(raw: List[Any]) -> List[dict]:
63
+ out: List[dict] = []
64
+ seen: set[str] = set()
65
+ for item in raw or []:
66
+ if isinstance(item, dict):
67
+ label = _safe_str(item.get("label")) or "Field"
68
+ key = _safe_str(item.get("key")) or _slug_key(label, seen)
69
+ if key in seen:
70
+ key = _slug_key(key, seen)
71
+ else:
72
+ seen.add(key)
73
+ ftype = _safe_str(item.get("type")) or "text"
74
+ if ftype not in ("text", "email", "tel", "textarea"):
75
+ ftype = "text"
76
+ map_to = _safe_str(item.get("map_to")) or "custom"
77
+ if map_to not in ("name", "email", "company_name", "title", "phone", "message", "custom"):
78
+ map_to = "custom"
79
+ out.append(
80
+ {
81
+ "key": key[:64],
82
+ "label": label[:120],
83
+ "type": ftype,
84
+ "required": bool(item.get("required")),
85
+ "map_to": map_to,
86
+ }
87
+ )
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
+
119
+ def _public_origin(request: Request) -> str:
120
+ fwd = request.headers.get("x-forwarded-proto")
121
+ proto = (fwd.split(",")[0].strip() if fwd else request.url.scheme or "https").lower()
122
+ host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
123
+ host = host.split(",")[0].strip() if host else ""
124
+ if not host:
125
+ return str(request.base_url).rstrip("/")
126
+ base = f"{proto}://{host}"
127
+ if base.startswith("http://") and ".hf.space" in base:
128
+ base = "https://" + base[7:]
129
+ return base.rstrip("/")
130
+
131
+
132
+ def _split_name(full: str) -> tuple[str, str]:
133
+ parts = _safe_str(full).split(None, 1)
134
+ if not parts:
135
+ return "", ""
136
+ if len(parts) == 1:
137
+ return parts[0], ""
138
+ return parts[0], parts[1]
139
+
140
+
141
+ def _submission_to_lead_fields(
142
+ form: CtaForm,
143
+ payload: Dict[str, str],
144
+ ) -> dict:
145
+ fields_def = form.fields if isinstance(form.fields, list) else []
146
+ mapped: Dict[str, str] = {}
147
+ custom_lines: List[str] = []
148
+
149
+ for fd in fields_def:
150
+ if not isinstance(fd, dict):
151
+ continue
152
+ key = _safe_str(fd.get("key"))
153
+ label = _safe_str(fd.get("label")) or key
154
+ val = _safe_str(payload.get(key))
155
+ map_to = _safe_str(fd.get("map_to")) or "custom"
156
+ if map_to == "custom":
157
+ if val:
158
+ custom_lines.append(f"{label}: {val}")
159
+ else:
160
+ if val:
161
+ mapped[map_to] = val
162
+
163
+ first_name, last_name = "", ""
164
+ if mapped.get("name"):
165
+ first_name, last_name = _split_name(mapped["name"])
166
+
167
+ email = mapped.get("email", "")
168
+ company = mapped.get("company_name", "")
169
+ title = mapped.get("title", "")
170
+ phone = mapped.get("phone", "")
171
+ message = mapped.get("message", "")
172
+
173
+ body_lines = [f"Form: {form.name or 'CTA Form'}", "---"]
174
+ for fd in fields_def:
175
+ if not isinstance(fd, dict):
176
+ continue
177
+ key = _safe_str(fd.get("key"))
178
+ label = _safe_str(fd.get("label")) or key
179
+ val = _safe_str(payload.get(key))
180
+ if val:
181
+ body_lines.append(f"{label}: {val}")
182
+
183
+ body = "\n".join(body_lines).strip()
184
+ if message and message not in body:
185
+ body = f"{body}\n\nMessage:\n{message}".strip()
186
+
187
+ return {
188
+ "first_name": first_name,
189
+ "last_name": last_name,
190
+ "email": email,
191
+ "company_name": company,
192
+ "title": title,
193
+ "phone": phone,
194
+ "last_reply_subject": f"CTA: {form.name or 'Form submission'}",
195
+ "last_reply_body": body,
196
+ "custom_lines": custom_lines,
197
+ }
198
+
199
+
200
+ def _create_lead_from_submission(
201
+ db: Session,
202
+ form: CtaForm,
203
+ payload: Dict[str, str],
204
+ *,
205
+ page_url: str = "",
206
+ when: Optional[datetime] = None,
207
+ ) -> CrmLead:
208
+ when = when or datetime.utcnow()
209
+ parsed = _submission_to_lead_fields(form, payload)
210
+ if not parsed["email"]:
211
+ raise HTTPException(status_code=400, detail="Email is required")
212
+
213
+ submission_id = uuid.uuid4().hex[:16]
214
+ lead_key = f"{CTA_LEAD_ID_PREFIX}{form.public_id}-{submission_id}"
215
+
216
+ raw: Dict[str, Any] = {
217
+ "source": CTA_LEAD_SOURCE,
218
+ "form_id": form.id,
219
+ "form_public_id": form.public_id,
220
+ "form_name": form.name,
221
+ "submission": payload,
222
+ "page_url": page_url or None,
223
+ "messages": [
224
+ {
225
+ "direction": "inbound",
226
+ "channel": "cta_form",
227
+ "subject": parsed["last_reply_subject"],
228
+ "body": parsed["last_reply_body"],
229
+ "at": when.replace(microsecond=0).isoformat() + "Z",
230
+ }
231
+ ],
232
+ }
233
+
234
+ row = CrmLead(
235
+ tenant_id=form.tenant_id,
236
+ smartlead_lead_id=lead_key,
237
+ campaign_id=form.public_id,
238
+ campaign_name=form.name or "CTA Form",
239
+ email=parsed["email"],
240
+ first_name=parsed["first_name"],
241
+ last_name=parsed["last_name"],
242
+ company_name=parsed["company_name"],
243
+ title=parsed["title"],
244
+ last_reply_subject=parsed["last_reply_subject"],
245
+ last_reply_body=parsed["last_reply_body"],
246
+ last_reply_at=when,
247
+ crm_status="new_lead",
248
+ raw_webhook=raw,
249
+ )
250
+ db.add(row)
251
+ db.flush()
252
+ return row
253
+
254
+
255
+ def _get_active_form(db: Session, public_id: str) -> CtaForm:
256
+ row = (
257
+ db.query(CtaForm)
258
+ .filter(CtaForm.public_id == public_id, CtaForm.is_active == 1)
259
+ .first()
260
+ )
261
+ if not row:
262
+ raise HTTPException(status_code=404, detail="Form not found")
263
+ return row
264
+
265
+
266
+ def _validate_submission(form: CtaForm, body: CtaFormSubmitRequest) -> Dict[str, str]:
267
+ fields_def = form.fields if isinstance(form.fields, list) else []
268
+ out: Dict[str, str] = {}
269
+ for fd in fields_def:
270
+ if not isinstance(fd, dict):
271
+ continue
272
+ key = _safe_str(fd.get("key"))
273
+ if not key:
274
+ continue
275
+ val = _safe_str(body.fields.get(key))
276
+ if fd.get("required") and not val:
277
+ label = _safe_str(fd.get("label")) or key
278
+ raise HTTPException(status_code=400, detail=f"{label} is required")
279
+ if val:
280
+ out[key] = val
281
+ return out
282
+
283
+
284
+ # ---- Authenticated management ----
285
+
286
+
287
+ @router.get("/api/cta-forms")
288
+ def list_cta_forms(request: Request, tc: TenantContext = Depends(get_tenant_context)):
289
+ rows = (
290
+ tc.db.query(CtaForm)
291
+ .filter(CtaForm.tenant_id == tc.tenant_id)
292
+ .order_by(CtaForm.updated_at.desc(), CtaForm.id.desc())
293
+ .all()
294
+ )
295
+ return {"forms": [_form_to_dict(r, request=request) for r in rows]}
296
+
297
+
298
+ @router.post("/api/cta-forms")
299
+ def create_cta_form(
300
+ body: CtaFormCreateRequest,
301
+ request: Request,
302
+ tc: TenantContext = Depends(get_tenant_context),
303
+ ):
304
+ fields = _normalize_fields([f.model_dump() for f in body.fields] if body.fields else DEFAULT_FIELDS)
305
+ row = CtaForm(
306
+ tenant_id=tc.tenant_id,
307
+ user_id=tc.user_id,
308
+ public_id=uuid.uuid4().hex,
309
+ name=body.name.strip(),
310
+ fields=fields,
311
+ is_active=1,
312
+ )
313
+ tc.db.add(row)
314
+ db_commit_with_retry(tc.db)
315
+ tc.db.refresh(row)
316
+ return _form_to_dict(row, request=request)
317
+
318
+
319
+ @router.get("/api/cta-forms/{form_id}")
320
+ def get_cta_form(form_id: int, request: Request, tc: TenantContext = Depends(get_tenant_context)):
321
+ row = (
322
+ tc.db.query(CtaForm)
323
+ .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
324
+ .first()
325
+ )
326
+ if not row:
327
+ raise HTTPException(status_code=404, detail="Form not found")
328
+ return _form_to_dict(row, request=request)
329
+
330
+
331
+ @router.patch("/api/cta-forms/{form_id}")
332
+ def patch_cta_form(
333
+ form_id: int,
334
+ body: CtaFormPatchRequest,
335
+ request: Request,
336
+ tc: TenantContext = Depends(get_tenant_context),
337
+ ):
338
+ row = (
339
+ tc.db.query(CtaForm)
340
+ .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
341
+ .first()
342
+ )
343
+ if not row:
344
+ raise HTTPException(status_code=404, detail="Form not found")
345
+ if body.name is not None:
346
+ row.name = body.name.strip()
347
+ if body.fields is not None:
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)
354
+ return _form_to_dict(row, request=request)
355
+
356
+
357
+ @router.delete("/api/cta-forms/{form_id}")
358
+ def delete_cta_form(form_id: int, tc: TenantContext = Depends(get_tenant_context)):
359
+ row = (
360
+ tc.db.query(CtaForm)
361
+ .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
362
+ .first()
363
+ )
364
+ if not row:
365
+ raise HTTPException(status_code=404, detail="Form not found")
366
+ tc.db.delete(row)
367
+ db_commit_with_retry(tc.db)
368
+ return {"ok": True}
369
+
370
+
371
+ # ---- Public embed + submit ----
372
+
373
+
374
+ @router.get("/api/public/cta-forms/{public_id}")
375
+ def public_cta_form(public_id: str, request: Request, db: Session = Depends(get_db)):
376
+ row = _get_active_form(db, public_id)
377
+ return JSONResponse(
378
+ content={
379
+ "public_id": row.public_id,
380
+ "name": row.name or "",
381
+ "fields": row.fields if isinstance(row.fields, list) else [],
382
+ },
383
+ headers=_CORS_HEADERS,
384
+ )
385
+
386
+
387
+ @router.options("/api/public/cta-forms/{public_id}/submit")
388
+ async def public_cta_submit_options():
389
+ return Response(headers=_CORS_HEADERS)
390
+
391
+
392
+ @router.post("/api/public/cta-forms/{public_id}/submit")
393
+ def public_cta_submit(
394
+ public_id: str,
395
+ body: CtaFormSubmitRequest,
396
+ db: Session = Depends(get_db),
397
+ ):
398
+ form = _get_active_form(db, public_id)
399
+ payload = _validate_submission(form, body)
400
+ lead = _create_lead_from_submission(
401
+ db,
402
+ form,
403
+ payload,
404
+ page_url=_safe_str(body.page_url),
405
+ )
406
+ db_commit_with_retry(db)
407
+ return JSONResponse(
408
+ content={"ok": True, "lead_id": lead.id, "message": "Thank you — we will be in touch soon."},
409
+ headers=_CORS_HEADERS,
410
+ )
411
+
412
+
413
+ @router.get("/api/embed/cta/{public_id}.js")
414
+ def embed_cta_script(public_id: str, request: Request, db: Session = Depends(get_db)):
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;
427
+ mount = document.createElement('div');
428
+ mount.id = 'ezofis-cta-' + FORM_ID;
429
+ if (s && s.parentNode) s.parentNode.insertBefore(mount, s);
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';
444
+ root.appendChild(err);
445
+ var form = document.createElement('form');
446
+ root.appendChild(form);
447
+ FIELDS.forEach(function(f) {{
448
+ var wrap = document.createElement('div');
449
+ var lab = document.createElement('label');
450
+ lab.textContent = f.label + (f.required ? ' *' : '');
451
+ wrap.appendChild(lab);
452
+ var inp;
453
+ if (f.type === 'textarea') {{
454
+ inp = document.createElement('textarea');
455
+ }} else {{
456
+ inp = document.createElement('input');
457
+ inp.type = f.type || 'text';
458
+ }}
459
+ inp.name = f.key;
460
+ inp.required = !!f.required;
461
+ wrap.appendChild(inp);
462
+ form.appendChild(wrap);
463
+ }});
464
+ var btn = document.createElement('button');
465
+ btn.type = 'submit';
466
+ btn.textContent = 'Submit';
467
+ form.appendChild(btn);
468
+ form.addEventListener('submit', function(e) {{
469
+ e.preventDefault();
470
+ err.style.display = 'none';
471
+ btn.disabled = true;
472
+ var data = {{ fields: {{}}, page_url: window.location.href }};
473
+ FIELDS.forEach(function(f) {{
474
+ var el = form.querySelector('[name="' + f.key + '"]');
475
+ if (el) data.fields[f.key] = el.value || '';
476
+ }});
477
+ fetch(API + '/api/public/cta-forms/' + FORM_ID + '/submit', {{
478
+ method: 'POST',
479
+ headers: {{ 'Content-Type': 'application/json' }},
480
+ body: JSON.stringify(data)
481
+ }}).then(function(r) {{
482
+ return r.json().then(function(j) {{ return {{ ok: r.ok, j: j }}; }});
483
+ }}).then(function(res) {{
484
+ if (res.ok) {{
485
+ form.innerHTML = '<div class="ok">' + (res.j.message || 'Thank you!') + '</div>';
486
+ }} else {{
487
+ err.textContent = (res.j && res.j.detail) ? (typeof res.j.detail === 'string' ? res.j.detail : 'Submission failed') : 'Submission failed';
488
+ err.style.display = 'block';
489
+ btn.disabled = false;
490
+ }}
491
+ }}).catch(function() {{
492
+ err.textContent = 'Network error. Please try again.';
493
+ err.style.display = 'block';
494
+ btn.disabled = false;
495
+ }});
496
+ }});
497
+ }})();
498
+ """
499
+ return PlainTextResponse(js, media_type="application/javascript", headers=_CORS_HEADERS)
500
+
501
+
502
+ def lead_is_cta_form(row: CrmLead) -> bool:
503
+ raw = row.raw_webhook if isinstance(row.raw_webhook, dict) else {}
504
+ if raw.get("source") == CTA_LEAD_SOURCE:
505
+ return True
506
+ sid = _safe_str(row.smartlead_lead_id)
507
+ return sid.startswith(CTA_LEAD_ID_PREFIX)
backend/app/database.py CHANGED
@@ -367,6 +367,22 @@ class UnipileHostedAuthState(Base):
367
  created_at = Column(DateTime, default=datetime.utcnow)
368
 
369
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  def run_migrations(connection_engine):
371
  """Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
372
  from sqlalchemy import inspect, text
@@ -383,6 +399,7 @@ def run_migrations(connection_engine):
383
  "unipile_accounts",
384
  "linkedin_campaigns",
385
  "unipile_hosted_auth_states",
 
386
  )
387
 
388
  with connection_engine.begin() as conn:
 
367
  created_at = Column(DateTime, default=datetime.utcnow)
368
 
369
 
370
+ class CtaForm(Base):
371
+ """Embeddable lead-capture form (WordPress / external sites → Leads)."""
372
+
373
+ __tablename__ = "cta_forms"
374
+
375
+ id = Column(Integer, primary_key=True, index=True)
376
+ tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True)
377
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
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)
384
+
385
+
386
  def run_migrations(connection_engine):
387
  """Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
388
  from sqlalchemy import inspect, text
 
399
  "unipile_accounts",
400
  "linkedin_campaigns",
401
  "unipile_hosted_auth_states",
402
+ "cta_forms",
403
  )
404
 
405
  with connection_engine.begin() as conn:
backend/app/main.py CHANGED
@@ -82,6 +82,7 @@ from .auth_routes import router as auth_router
82
  from .tenant_deps import TenantContext, get_tenant_context
83
  from .tenant_routes import router as tenant_router
84
  from .outreach_routes import router as outreach_router
 
85
  from .outreach_routes import (
86
  _format_wait_step_label,
87
  parse_tracking_label,
@@ -137,6 +138,7 @@ app.add_middleware(
137
  app.include_router(auth_router)
138
  app.include_router(tenant_router)
139
  app.include_router(outreach_router)
 
140
 
141
  # Create uploads directory
142
  UPLOAD_DIR = Path("/data/uploads")
@@ -931,6 +933,7 @@ def _crm_lead_to_dict(row: CrmLead) -> dict:
931
  if isinstance(row.raw_webhook, dict)
932
  else None
933
  )
 
934
  or ("emailout_campaign" if lead_is_outreach_campaign(row) else "smartlead"),
935
  }
936
 
 
82
  from .tenant_deps import TenantContext, get_tenant_context
83
  from .tenant_routes import router as tenant_router
84
  from .outreach_routes import router as outreach_router
85
+ from .cta_forms_routes import router as cta_forms_router, lead_is_cta_form
86
  from .outreach_routes import (
87
  _format_wait_step_label,
88
  parse_tracking_label,
 
138
  app.include_router(auth_router)
139
  app.include_router(tenant_router)
140
  app.include_router(outreach_router)
141
+ app.include_router(cta_forms_router)
142
 
143
  # Create uploads directory
144
  UPLOAD_DIR = Path("/data/uploads")
 
933
  if isinstance(row.raw_webhook, dict)
934
  else None
935
  )
936
+ or ("cta_form" if lead_is_cta_form(row) else None)
937
  or ("emailout_campaign" if lead_is_outreach_campaign(row) else "smartlead"),
938
  }
939
 
backend/app/models.py CHANGED
@@ -285,3 +285,30 @@ class LinkedinCampaignPatchRequest(BaseModel):
285
  """Update LinkedIn campaign automation settings."""
286
 
287
  followup_interval_hours: Optional[int] = Field(None, ge=1, le=720)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  """Update LinkedIn campaign automation settings."""
286
 
287
  followup_interval_hours: Optional[int] = Field(None, ge=1, le=720)
288
+
289
+
290
+ class CtaFormFieldDef(BaseModel):
291
+ key: str = Field(..., min_length=1, max_length=64)
292
+ label: str = Field(..., min_length=1, max_length=120)
293
+ type: str = Field(default="text", pattern="^(text|email|tel|textarea)$")
294
+ required: bool = False
295
+ map_to: str = Field(
296
+ default="custom",
297
+ pattern="^(name|email|company_name|title|phone|message|custom)$",
298
+ )
299
+
300
+
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):
313
+ fields: Dict[str, str] = Field(default_factory=dict)
314
+ page_url: Optional[str] = Field(None, max_length=2000)
frontend/src/App.jsx CHANGED
@@ -9,26 +9,35 @@ import Leads from "./pages/Leads";
9
  import Deals from "./pages/Deals";
10
  import SalesDashboard from "./pages/SalesDashboard";
11
  import Settings from "./pages/Settings";
 
12
  import "./index.css";
13
 
14
  export default function App() {
15
  return (
16
  <BrowserRouter>
17
- <AuthProvider>
18
- <RequireAuth>
19
- <GeneratorWorkflowProvider>
20
- <Routes>
21
- <Route path="/" element={<EmailSequenceGenerator />} />
22
- <Route path="/contacts" element={<Contacts />} />
23
- <Route path="/leads" element={<Leads />} />
24
- <Route path="/deals" element={<Deals />} />
25
- <Route path="/dashboard" element={<SalesDashboard />} />
26
- <Route path="/settings" element={<Settings />} />
27
- <Route path="/history" element={<Navigate to="/leads" replace />} />
28
- </Routes>
29
- </GeneratorWorkflowProvider>
30
- </RequireAuth>
31
- </AuthProvider>
 
 
 
 
 
 
 
 
32
  </BrowserRouter>
33
  );
34
  }
 
9
  import Deals from "./pages/Deals";
10
  import SalesDashboard from "./pages/SalesDashboard";
11
  import Settings from "./pages/Settings";
12
+ import CtaFormEmbed from "./pages/CtaFormEmbed";
13
  import "./index.css";
14
 
15
  export default function App() {
16
  return (
17
  <BrowserRouter>
18
+ <Routes>
19
+ <Route path="/embed/cta/:publicId" element={<CtaFormEmbed />} />
20
+ <Route
21
+ path="/*"
22
+ element={
23
+ <AuthProvider>
24
+ <RequireAuth>
25
+ <GeneratorWorkflowProvider>
26
+ <Routes>
27
+ <Route path="/" element={<EmailSequenceGenerator />} />
28
+ <Route path="/contacts" element={<Contacts />} />
29
+ <Route path="/leads" element={<Leads />} />
30
+ <Route path="/deals" element={<Deals />} />
31
+ <Route path="/dashboard" element={<SalesDashboard />} />
32
+ <Route path="/settings" element={<Settings />} />
33
+ <Route path="/history" element={<Navigate to="/leads" replace />} />
34
+ </Routes>
35
+ </GeneratorWorkflowProvider>
36
+ </RequireAuth>
37
+ </AuthProvider>
38
+ }
39
+ />
40
+ </Routes>
41
  </BrowserRouter>
42
  );
43
  }
frontend/src/components/settings/CtaFormsSettings.jsx ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ ArrowLeft,
4
+ ClipboardCopy,
5
+ FileInput,
6
+ Loader2,
7
+ Plus,
8
+ Trash2,
9
+ GripVertical,
10
+ } from 'lucide-react';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Input } from '@/components/ui/input';
13
+ import { Textarea } from '@/components/ui/textarea';
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
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 = [
26
+ { key: 'name', label: 'Name', type: 'text', required: true, map_to: 'name' },
27
+ { key: 'email', label: 'Email', type: 'email', required: true, map_to: 'email' },
28
+ {
29
+ key: 'company_name',
30
+ label: 'Company Name',
31
+ type: 'text',
32
+ required: false,
33
+ map_to: 'company_name',
34
+ },
35
+ { key: 'title', label: 'Job Title', type: 'text', required: false, map_to: 'title' },
36
+ { key: 'phone', label: 'Phone', type: 'tel', required: false, map_to: 'phone' },
37
+ { key: 'message', label: 'Message', type: 'textarea', required: false, map_to: 'message' },
38
+ ];
39
+
40
+ const MAP_OPTIONS = [
41
+ { value: 'name', label: 'Full name' },
42
+ { value: 'email', label: 'Email' },
43
+ { value: 'company_name', label: 'Company' },
44
+ { value: 'title', label: 'Job title' },
45
+ { value: 'phone', label: 'Phone' },
46
+ { value: 'message', label: 'Message' },
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
+
57
+ function CopyBlock({ label, code }) {
58
+ return (
59
+ <div className="space-y-2">
60
+ <div className="flex items-center justify-between gap-2">
61
+ <p className="text-xs font-medium text-slate-600">{label}</p>
62
+ <Button
63
+ type="button"
64
+ variant="outline"
65
+ size="sm"
66
+ className="h-7 gap-1 text-xs"
67
+ disabled={!code}
68
+ onClick={() => code && navigator.clipboard.writeText(code)}
69
+ >
70
+ <ClipboardCopy className="h-3 w-3" />
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('');
96
+ try {
97
+ const r = await apiFetch('/api/cta-forms');
98
+ const data = r.ok ? await r.json() : { forms: [] };
99
+ if (!r.ok) throw new Error(data.detail || 'Could not load forms');
100
+ const list = data.forms || [];
101
+ setForms(list);
102
+ setSelectedId((prev) => {
103
+ if (prev && list.some((f) => f.id === prev)) return prev;
104
+ return list.length ? list[0].id : null;
105
+ });
106
+ } catch (e) {
107
+ setError(e.message || 'Load failed');
108
+ setForms([]);
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ }, []);
113
+
114
+ useEffect(() => {
115
+ loadForms();
116
+ }, [loadForms]);
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 () => {
135
+ setCreating(true);
136
+ setError('');
137
+ try {
138
+ const res = await apiFetch('/api/cta-forms', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
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 {
154
+ setCreating(false);
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;
184
+ try {
185
+ const res = await apiFetch(`/api/cta-forms/${selectedId}`, { method: 'DELETE' });
186
+ if (!res.ok) {
187
+ const data = await res.json().catch(() => ({}));
188
+ throw new Error(data.detail || 'Delete failed');
189
+ }
190
+ setSelectedId(null);
191
+ setDraft(emptyDraft());
192
+ await loadForms();
193
+ } catch (e) {
194
+ setError(e.message || 'Delete failed');
195
+ }
196
+ };
197
+
198
+ const addPreset = (preset) => {
199
+ setDraft((d) => {
200
+ const keys = new Set(d.fields.map((f) => f.key));
201
+ let key = preset.key;
202
+ let n = 2;
203
+ while (keys.has(key)) {
204
+ key = `${preset.key}_${n}`;
205
+ n += 1;
206
+ }
207
+ return {
208
+ ...d,
209
+ fields: [...d.fields, { ...preset, key }],
210
+ };
211
+ });
212
+ };
213
+
214
+ const updateField = (idx, patch) => {
215
+ setDraft((d) => ({
216
+ ...d,
217
+ fields: d.fields.map((f, i) => (i === idx ? { ...f, ...patch } : f)),
218
+ }));
219
+ };
220
+
221
+ const removeField = (idx) => {
222
+ setDraft((d) => ({
223
+ ...d,
224
+ fields: d.fields.filter((_, i) => i !== idx),
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 />
237
+ Back to settings
238
+ </Button>
239
+ <Button type="button" onClick={createForm} disabled={creating} className="gap-2">
240
+ {creating ? (
241
+ <Loader2 className="h-4 w-4 animate-spin" />
242
+ ) : (
243
+ <Plus className="h-4 w-4" />
244
+ )}
245
+ New form
246
+ </Button>
247
+ </div>
248
+
249
+ {error ? (
250
+ <p className="mb-4 text-sm text-red-600 rounded-lg border border-red-200 bg-red-50 px-3 py-2">
251
+ {error}
252
+ </p>
253
+ ) : null}
254
+
255
+ {loading ? (
256
+ <div className="flex justify-center py-20 text-slate-400">
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>
265
+ {forms.length === 0 ? (
266
+ <p className="px-2 py-4 text-sm text-slate-500">
267
+ No forms yet. Create one to get embed codes.
268
+ </p>
269
+ ) : (
270
+ <ul className="space-y-1">
271
+ {forms.map((f) => (
272
+ <li key={f.id}>
273
+ <button
274
+ type="button"
275
+ onClick={() => selectForm(f)}
276
+ className={cn(
277
+ 'w-full rounded-lg px-3 py-2 text-left text-sm transition-colors',
278
+ selectedId === f.id
279
+ ? 'bg-violet-100 text-violet-800 font-medium'
280
+ : 'text-slate-700 hover:bg-slate-50'
281
+ )}
282
+ >
283
+ {f.name || 'Untitled'}
284
+ </button>
285
+ </li>
286
+ ))}
287
+ </ul>
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.
295
+ </div>
296
+ ) : (
297
+ <>
298
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm space-y-4">
299
+ <div className="flex flex-wrap items-end gap-3 justify-between">
300
+ <div className="flex-1 min-w-[12rem]">
301
+ <label className="text-xs font-medium text-slate-600">
302
+ Form name
303
+ </label>
304
+ <Input
305
+ value={draft.name}
306
+ onChange={(e) =>
307
+ setDraft((d) => ({
308
+ ...d,
309
+ name: e.target.value,
310
+ }))
311
+ }
312
+ className="mt-1"
313
+ />
314
+ </div>
315
+ <div className="flex gap-2">
316
+ <Button
317
+ type="button"
318
+ variant="outline"
319
+ size="sm"
320
+ className="text-red-600"
321
+ onClick={deleteForm}
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
+
339
+ <div>
340
+ <p className="text-xs font-medium text-slate-600 mb-2">
341
+ Add field
342
+ </p>
343
+ <div className="flex flex-wrap gap-2">
344
+ {FIELD_PRESETS.map((p) => (
345
+ <Button
346
+ key={p.key}
347
+ type="button"
348
+ variant="outline"
349
+ size="sm"
350
+ onClick={() => addPreset(p)}
351
+ >
352
+ + {p.label}
353
+ </Button>
354
+ ))}
355
+ </div>
356
+ </div>
357
+
358
+ <div className="space-y-3">
359
+ <p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
360
+ Fields
361
+ </p>
362
+ {draft.fields.length === 0 ? (
363
+ <p className="text-sm text-slate-500">
364
+ Add at least Name and Email.
365
+ </p>
366
+ ) : (
367
+ draft.fields.map((f, idx) => (
368
+ <div
369
+ key={`${f.key}-${idx}`}
370
+ className="flex flex-wrap gap-2 items-end rounded-xl border border-slate-100 bg-slate-50/80 p-3"
371
+ >
372
+ <GripVertical className="h-4 w-4 text-slate-300 mb-2.5 shrink-0" />
373
+ <div className="flex-1 min-w-[8rem]">
374
+ <label className="text-[10px] text-slate-500">
375
+ Label
376
+ </label>
377
+ <Input
378
+ value={f.label}
379
+ onChange={(e) =>
380
+ updateField(idx, {
381
+ label: e.target.value,
382
+ })
383
+ }
384
+ className="h-9 text-sm"
385
+ />
386
+ </div>
387
+ <div className="w-28">
388
+ <label className="text-[10px] text-slate-500">
389
+ Type
390
+ </label>
391
+ <Select
392
+ value={f.type}
393
+ onValueChange={(v) =>
394
+ updateField(idx, { type: v })
395
+ }
396
+ >
397
+ <SelectTrigger className="h-9 text-sm">
398
+ <SelectValue />
399
+ </SelectTrigger>
400
+ <SelectContent>
401
+ <SelectItem value="text">
402
+ Text
403
+ </SelectItem>
404
+ <SelectItem value="email">
405
+ Email
406
+ </SelectItem>
407
+ <SelectItem value="tel">
408
+ Phone
409
+ </SelectItem>
410
+ <SelectItem value="textarea">
411
+ Long text
412
+ </SelectItem>
413
+ </SelectContent>
414
+ </Select>
415
+ </div>
416
+ <div className="w-36">
417
+ <label className="text-[10px] text-slate-500">
418
+ Maps to lead
419
+ </label>
420
+ <Select
421
+ value={f.map_to}
422
+ onValueChange={(v) =>
423
+ updateField(idx, { map_to: v })
424
+ }
425
+ >
426
+ <SelectTrigger className="h-9 text-sm">
427
+ <SelectValue />
428
+ </SelectTrigger>
429
+ <SelectContent>
430
+ {MAP_OPTIONS.map((o) => (
431
+ <SelectItem
432
+ key={o.value}
433
+ value={o.value}
434
+ >
435
+ {o.label}
436
+ </SelectItem>
437
+ ))}
438
+ </SelectContent>
439
+ </Select>
440
+ </div>
441
+ <label className="flex items-center gap-1.5 text-xs text-slate-600 pb-2">
442
+ <input
443
+ type="checkbox"
444
+ checked={!!f.required}
445
+ onChange={(e) =>
446
+ updateField(idx, {
447
+ required: e.target.checked,
448
+ })
449
+ }
450
+ />
451
+ Required
452
+ </label>
453
+ <Button
454
+ type="button"
455
+ variant="ghost"
456
+ size="icon"
457
+ className="h-9 w-9 text-red-500"
458
+ onClick={() => removeField(idx)}
459
+ >
460
+ <Trash2 className="h-4 w-4" />
461
+ </Button>
462
+ </div>
463
+ ))
464
+ )}
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" />
471
+ Embed on WordPress
472
+ </div>
473
+ <p className="text-sm text-slate-600">
474
+ Paste either code into a WordPress HTML block, page builder, or
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}
496
+ </section>
497
+ </>
498
+ )}
499
+ </div>
500
+ </div>
501
+ )}
502
+ </div>
503
+ </AppShell>
504
+ );
505
+ }
frontend/src/pages/CtaFormEmbed.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.
8
+ * Route: /embed/cta/:publicId
9
+ */
10
+ export default function CtaFormEmbed() {
11
+ const publicId = window.location.pathname.split('/').filter(Boolean).pop() || '';
12
+ const [phase, setPhase] = useState('loading');
13
+ const [form, setForm] = useState(null);
14
+ const [error, setError] = useState('');
15
+ const [values, setValues] = useState({});
16
+ const [submitting, setSubmitting] = useState(false);
17
+ const [done, setDone] = useState(false);
18
+ const [doneMsg, setDoneMsg] = useState('');
19
+
20
+ useEffect(() => {
21
+ if (!publicId) {
22
+ setError('Invalid form link');
23
+ setPhase('error');
24
+ return;
25
+ }
26
+ fetch(`/api/public/cta-forms/${encodeURIComponent(publicId)}`)
27
+ .then((r) => r.json().then((j) => ({ ok: r.ok, j })))
28
+ .then(({ ok, j }) => {
29
+ if (!ok) throw new Error(j.detail || 'Form not found');
30
+ setForm(j);
31
+ const init = {};
32
+ (j.fields || []).forEach((f) => {
33
+ init[f.key] = '';
34
+ });
35
+ setValues(init);
36
+ setPhase('ready');
37
+ })
38
+ .catch((e) => {
39
+ setError(e.message || 'Could not load form');
40
+ setPhase('error');
41
+ });
42
+ }, [publicId]);
43
+
44
+ const onChange = (key, val) => {
45
+ setValues((prev) => ({ ...prev, [key]: val }));
46
+ };
47
+
48
+ const onSubmit = async (e) => {
49
+ e.preventDefault();
50
+ setSubmitting(true);
51
+ setError('');
52
+ try {
53
+ const res = await fetch(`/api/public/cta-forms/${encodeURIComponent(publicId)}/submit`, {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ fields: values,
58
+ page_url: window.location.href,
59
+ }),
60
+ });
61
+ const data = await res.json().catch(() => ({}));
62
+ if (!res.ok) {
63
+ throw new Error(
64
+ typeof data.detail === 'string' ? data.detail : 'Submission failed'
65
+ );
66
+ }
67
+ setDone(true);
68
+ setDoneMsg(data.message || 'Thank you — we will be in touch soon.');
69
+ } catch (err) {
70
+ setError(err.message || 'Submission failed');
71
+ } finally {
72
+ setSubmitting(false);
73
+ }
74
+ };
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
+ );
82
+ }
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
+ ) : (
101
+ <form onSubmit={onSubmit} className="space-y-3">
102
+ {(form.fields || []).map((f) => (
103
+ <div key={f.key}>
104
+ <label className="block text-xs font-medium text-slate-600 mb-1">
105
+ {f.label}
106
+ {f.required ? ' *' : ''}
107
+ </label>
108
+ {f.type === 'textarea' ? (
109
+ <textarea
110
+ className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
111
+ rows={4}
112
+ required={!!f.required}
113
+ value={values[f.key] || ''}
114
+ onChange={(e) => onChange(f.key, e.target.value)}
115
+ />
116
+ ) : (
117
+ <input
118
+ type={f.type || 'text'}
119
+ className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm"
120
+ required={!!f.required}
121
+ value={values[f.key] || ''}
122
+ onChange={(e) => onChange(f.key, e.target.value)}
123
+ />
124
+ )}
125
+ </div>
126
+ ))}
127
+ {error ? <p className="text-xs text-red-600">{error}</p> : null}
128
+ <button
129
+ type="submit"
130
+ disabled={submitting}
131
+ className="w-full rounded-lg bg-violet-600 py-2.5 text-sm font-semibold text-white hover:bg-violet-700 disabled:opacity-60"
132
+ >
133
+ {submitting ? 'Sending…' : 'Submit'}
134
+ </button>
135
+ </form>
136
+ )}
137
+ </div>
138
+ </div>
139
+ );
140
+ }
frontend/src/pages/Leads.jsx CHANGED
@@ -175,6 +175,9 @@ export default function Leads() {
175
  lead?.source === 'emailout_campaign' ||
176
  (lead?.smartlead_lead_id || '').startsWith('emailout-');
177
 
 
 
 
178
  const loadThread = async (lead) => {
179
  setThreadLoading(true);
180
  setThreadData(null);
@@ -387,7 +390,7 @@ export default function Leads() {
387
  return (
388
  <AppShell
389
  title="Leads"
390
- subtitle="Replies from EMAILOUT and Smartlead campaigns appear here. UniPile webhooks are configured on Settings."
391
  >
392
  <MainTableWorkspace
393
  primaryAction={{ label: 'New lead', to: '/' }}
@@ -432,8 +435,8 @@ export default function Leads() {
432
  ) : leads.length === 0 ? (
433
  <div className="text-center py-16 text-slate-500 space-y-3">
434
  <p>
435
- No leads yet. When a prospect replies to an EMAILOUT campaign (via UniPile) or a
436
- Smartlead campaign, they will show up here automatically.
437
  </p>
438
  <Button size="sm" variant="secondary" onClick={() => seedDemoLeads()} disabled={seedBusy}>
439
  Load demo rows (preview UI)
@@ -740,23 +743,27 @@ export default function Leads() {
740
 
741
  <div className="mt-8 pt-6 border-t border-slate-100">
742
  <div className="flex items-center justify-between mb-2">
743
- <h4 className="font-semibold text-slate-800">Their reply</h4>
744
- <Button
745
- variant="outline"
746
- size="sm"
747
- className="gap-1"
748
- onClick={() => loadThread(selected)}
749
- disabled={threadLoading}
750
- >
751
- {threadLoading ? (
752
- <Loader2 className="h-4 w-4 animate-spin" />
753
- ) : (
754
- <ExternalLink className="h-4 w-4" />
755
- )}
756
- {isOutreachLead(selected)
757
- ? 'Full conversation'
758
- : 'Full thread (Smartlead)'}
759
- </Button>
 
 
 
 
760
  </div>
761
  {selected.last_reply_subject && (
762
  <p className="text-xs text-slate-500 mb-1">
 
175
  lead?.source === 'emailout_campaign' ||
176
  (lead?.smartlead_lead_id || '').startsWith('emailout-');
177
 
178
+ const isCtaFormLead = (lead) =>
179
+ lead?.source === 'cta_form' || (lead?.smartlead_lead_id || '').startsWith('cta-');
180
+
181
  const loadThread = async (lead) => {
182
  setThreadLoading(true);
183
  setThreadData(null);
 
390
  return (
391
  <AppShell
392
  title="Leads"
393
+ subtitle="Replies from campaigns, CTA website forms, and Smartlead appear here."
394
  >
395
  <MainTableWorkspace
396
  primaryAction={{ label: 'New lead', to: '/' }}
 
435
  ) : leads.length === 0 ? (
436
  <div className="text-center py-16 text-slate-500 space-y-3">
437
  <p>
438
+ No leads yet. When a prospect replies to a campaign, submits a CTA form on
439
+ your website, or replies in Smartlead, they will show up here.
440
  </p>
441
  <Button size="sm" variant="secondary" onClick={() => seedDemoLeads()} disabled={seedBusy}>
442
  Load demo rows (preview UI)
 
743
 
744
  <div className="mt-8 pt-6 border-t border-slate-100">
745
  <div className="flex items-center justify-between mb-2">
746
+ <h4 className="font-semibold text-slate-800">
747
+ {isCtaFormLead(selected) ? 'Form submission' : 'Their reply'}
748
+ </h4>
749
+ {!isCtaFormLead(selected) ? (
750
+ <Button
751
+ variant="outline"
752
+ size="sm"
753
+ className="gap-1"
754
+ onClick={() => loadThread(selected)}
755
+ disabled={threadLoading}
756
+ >
757
+ {threadLoading ? (
758
+ <Loader2 className="h-4 w-4 animate-spin" />
759
+ ) : (
760
+ <ExternalLink className="h-4 w-4" />
761
+ )}
762
+ {isOutreachLead(selected)
763
+ ? 'Full conversation'
764
+ : 'Full thread (Smartlead)'}
765
+ </Button>
766
+ ) : null}
767
  </div>
768
  {selected.last_reply_subject && (
769
  <p className="text-xs text-slate-500 mb-1">
frontend/src/pages/Settings.jsx CHANGED
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
  import {
3
  ArrowLeft,
4
  ClipboardCopy,
 
5
  Loader2,
6
  ShieldAlert,
7
  Link2,
@@ -12,6 +13,7 @@ import {
12
  } from 'lucide-react';
13
  import AppShell from '@/components/layout/AppShell';
14
  import EmailGeneratorTab from '@/components/campaigns/EmailGeneratorTab';
 
15
  import LinkedInConnectSettings from '@/components/settings/LinkedInConnectSettings';
16
  import ConnectMailboxSettings from '@/components/settings/ConnectMailboxSettings';
17
  import { Button } from '@/components/ui/button';
@@ -27,7 +29,7 @@ import { apiFetch } from '@/lib/api';
27
  import { cn } from '@/lib/utils';
28
 
29
  export default function Settings() {
30
- /** 'main' | 'email_generator' full-page generator inside Settings */
31
  const [settingsPanel, setSettingsPanel] = useState('main');
32
  const [me, setMe] = useState(null);
33
  const [meLoading, setMeLoading] = useState(true);
@@ -209,6 +211,10 @@ export default function Settings() {
209
  );
210
  }
211
 
 
 
 
 
212
  if (settingsPanel === 'email_generator') {
213
  return (
214
  <AppShell
@@ -238,8 +244,8 @@ export default function Settings() {
238
  title="Settings"
239
  subtitle={
240
  isAdmin
241
- ? 'Invitations, members, LinkedIn, mailbox, Smartlead webhook, and email generator.'
242
- : 'LinkedIn, mailbox, Smartlead webhook, and email generator. Invites and member management require admin.'
243
  }
244
  >
245
  <div className="space-y-8 max-w-3xl">
@@ -456,6 +462,20 @@ export default function Settings() {
456
  </Button>
457
  </section>
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
460
  <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
461
  <Link2 className="h-5 w-5 text-violet-600" />
 
2
  import {
3
  ArrowLeft,
4
  ClipboardCopy,
5
+ FileInput,
6
  Loader2,
7
  ShieldAlert,
8
  Link2,
 
13
  } from 'lucide-react';
14
  import AppShell from '@/components/layout/AppShell';
15
  import EmailGeneratorTab from '@/components/campaigns/EmailGeneratorTab';
16
+ import CtaFormsSettings from '@/components/settings/CtaFormsSettings';
17
  import LinkedInConnectSettings from '@/components/settings/LinkedInConnectSettings';
18
  import ConnectMailboxSettings from '@/components/settings/ConnectMailboxSettings';
19
  import { Button } from '@/components/ui/button';
 
29
  import { cn } from '@/lib/utils';
30
 
31
  export default function Settings() {
32
+ /** 'main' | 'email_generator' | 'cta_forms' */
33
  const [settingsPanel, setSettingsPanel] = useState('main');
34
  const [me, setMe] = useState(null);
35
  const [meLoading, setMeLoading] = useState(true);
 
211
  );
212
  }
213
 
214
+ if (settingsPanel === 'cta_forms') {
215
+ return <CtaFormsSettings onBack={() => setSettingsPanel('main')} />;
216
+ }
217
+
218
  if (settingsPanel === 'email_generator') {
219
  return (
220
  <AppShell
 
244
  title="Settings"
245
  subtitle={
246
  isAdmin
247
+ ? 'Invitations, members, LinkedIn, mailbox, CTA forms, Smartlead webhook, and email generator.'
248
+ : 'LinkedIn, mailbox, CTA forms, Smartlead webhook, and email generator. Invites and member management require admin.'
249
  }
250
  >
251
  <div className="space-y-8 max-w-3xl">
 
462
  </Button>
463
  </section>
464
 
465
+ <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
466
+ <div className="flex items-center gap-2 text-slate-800 font-semibold mb-2">
467
+ <FileInput className="h-5 w-5 text-violet-600" />
468
+ CTA Forms
469
+ </div>
470
+ <p className="text-sm text-slate-600 mb-4">
471
+ Create embeddable lead forms for your WordPress site. Name fields like Name, Email,
472
+ and Company — then copy the iframe or script embed code. Submissions appear in Leads.
473
+ </p>
474
+ <Button type="button" variant="outline" onClick={() => setSettingsPanel('cta_forms')}>
475
+ Manage CTA forms
476
+ </Button>
477
+ </section>
478
+
479
  <section className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
480
  <div className="flex items-center gap-2 text-slate-800 font-semibold mb-4">
481
  <Link2 className="h-5 w-5 text-violet-600" />