""" Embeddable CTA forms: create in Settings, embed on WordPress, submissions → Leads. """ from __future__ import annotations import json import re import uuid from datetime import datetime from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, PlainTextResponse, Response from sqlalchemy.orm import Session from .database import CrmLead, CtaForm, db_commit_with_retry, get_db from .crm_lead_contacts import link_lead_to_contact from .models import CtaFormCreateRequest, CtaFormPatchRequest, CtaFormSubmitRequest from .tenant_deps import TenantContext, get_tenant_context router = APIRouter(tags=["cta-forms"]) CTA_LEAD_SOURCE = "cta_form" CTA_LEAD_ID_PREFIX = "cta-" DEFAULT_FIELDS: List[dict] = [ {"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", }, ] _CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", } def _safe_str(val: Any) -> str: if val is None: return "" return str(val).strip() def _slug_key(label: str, existing: set[str]) -> str: base = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") or "field" key = base n = 2 while key in existing: key = f"{base}_{n}" n += 1 existing.add(key) return key def _normalize_fields(raw: List[Any]) -> List[dict]: out: List[dict] = [] seen: set[str] = set() for item in raw or []: if isinstance(item, dict): label = _safe_str(item.get("label")) or "Field" key = _safe_str(item.get("key")) or _slug_key(label, seen) if key in seen: key = _slug_key(key, seen) else: seen.add(key) ftype = _safe_str(item.get("type")) or "text" if ftype not in ("text", "email", "tel", "textarea"): ftype = "text" map_to = _safe_str(item.get("map_to")) or "custom" if map_to not in ("name", "email", "company_name", "title", "phone", "message", "custom"): map_to = "custom" out.append( { "key": key[:64], "label": label[:120], "type": ftype, "required": bool(item.get("required")), "map_to": map_to, } ) return out or list(DEFAULT_FIELDS) def _build_embed_codes( *, origin: str, public_id: str, name: str, ) -> dict: title = (name or "Form").replace('"', """) iframe_style = ( "width:100%;max-width:100%;border:none;border-radius:12px;display:block;min-height:280px;" ) return { "embed_iframe": ( f'' if origin else "" ), "embed_script": ( f'
\n' f'' if origin else "" ), } def _form_to_dict(row: CtaForm, *, request: Optional[Request] = None) -> dict: origin = "" if request is not None: origin = _public_origin(request) pid = row.public_id codes = _build_embed_codes( origin=origin, public_id=pid, name=row.name or "", ) return { "id": row.id, "public_id": pid, "name": row.name or "", "fields": row.fields if isinstance(row.fields, list) else [], "is_active": bool(row.is_active), "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, **codes, } def _public_origin(request: Request) -> str: fwd = request.headers.get("x-forwarded-proto") proto = (fwd.split(",")[0].strip() if fwd else request.url.scheme or "https").lower() host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc host = host.split(",")[0].strip() if host else "" if not host: return str(request.base_url).rstrip("/") base = f"{proto}://{host}" if base.startswith("http://") and ".hf.space" in base: base = "https://" + base[7:] return base.rstrip("/") def _split_name(full: str) -> tuple[str, str]: parts = _safe_str(full).split(None, 1) if not parts: return "", "" if len(parts) == 1: return parts[0], "" return parts[0], parts[1] def _submission_to_lead_fields( form: CtaForm, payload: Dict[str, str], ) -> dict: fields_def = form.fields if isinstance(form.fields, list) else [] mapped: Dict[str, str] = {} custom_lines: List[str] = [] for fd in fields_def: if not isinstance(fd, dict): continue key = _safe_str(fd.get("key")) label = _safe_str(fd.get("label")) or key val = _safe_str(payload.get(key)) map_to = _safe_str(fd.get("map_to")) or "custom" if map_to == "custom": if val: custom_lines.append(f"{label}: {val}") else: if val: mapped[map_to] = val first_name, last_name = "", "" if mapped.get("name"): first_name, last_name = _split_name(mapped["name"]) email = mapped.get("email", "") company = mapped.get("company_name", "") title = mapped.get("title", "") phone = mapped.get("phone", "") message = mapped.get("message", "") body_lines = [f"Form: {form.name or 'CTA Form'}", "---"] for fd in fields_def: if not isinstance(fd, dict): continue key = _safe_str(fd.get("key")) label = _safe_str(fd.get("label")) or key val = _safe_str(payload.get(key)) if val: body_lines.append(f"{label}: {val}") body = "\n".join(body_lines).strip() if message and message not in body: body = f"{body}\n\nMessage:\n{message}".strip() return { "first_name": first_name, "last_name": last_name, "email": email, "company_name": company, "title": title, "phone": phone, "last_reply_subject": f"CTA: {form.name or 'Form submission'}", "last_reply_body": body, "custom_lines": custom_lines, } def _create_lead_from_submission( db: Session, form: CtaForm, payload: Dict[str, str], *, page_url: str = "", when: Optional[datetime] = None, ) -> CrmLead: when = when or datetime.utcnow() parsed = _submission_to_lead_fields(form, payload) if not parsed["email"]: raise HTTPException(status_code=400, detail="Email is required") submission_id = uuid.uuid4().hex[:16] lead_key = f"{CTA_LEAD_ID_PREFIX}{form.public_id}-{submission_id}" raw: Dict[str, Any] = { "source": CTA_LEAD_SOURCE, "form_id": form.id, "form_public_id": form.public_id, "form_name": form.name, "submission": payload, "page_url": page_url or None, "messages": [ { "direction": "inbound", "channel": "cta_form", "subject": parsed["last_reply_subject"], "body": parsed["last_reply_body"], "at": when.replace(microsecond=0).isoformat() + "Z", } ], } row = CrmLead( tenant_id=form.tenant_id, smartlead_lead_id=lead_key, campaign_id=form.public_id, campaign_name=form.name or "CTA Form", email=parsed["email"], first_name=parsed["first_name"], last_name=parsed["last_name"], company_name=parsed["company_name"], title=parsed["title"], last_reply_subject=parsed["last_reply_subject"], last_reply_body=parsed["last_reply_body"], last_reply_at=when, crm_status="new_lead", raw_webhook=raw, ) db.add(row) db.flush() link_lead_to_contact(db, row) return row def _get_active_form(db: Session, public_id: str) -> CtaForm: row = ( db.query(CtaForm) .filter(CtaForm.public_id == public_id, CtaForm.is_active == 1) .first() ) if not row: raise HTTPException(status_code=404, detail="Form not found") return row def _validate_submission(form: CtaForm, body: CtaFormSubmitRequest) -> Dict[str, str]: fields_def = form.fields if isinstance(form.fields, list) else [] out: Dict[str, str] = {} for fd in fields_def: if not isinstance(fd, dict): continue key = _safe_str(fd.get("key")) if not key: continue val = _safe_str(body.fields.get(key)) if fd.get("required") and not val: label = _safe_str(fd.get("label")) or key raise HTTPException(status_code=400, detail=f"{label} is required") if val: out[key] = val return out # ---- Authenticated management ---- @router.get("/api/cta-forms") def list_cta_forms(request: Request, tc: TenantContext = Depends(get_tenant_context)): rows = ( tc.db.query(CtaForm) .filter(CtaForm.tenant_id == tc.tenant_id) .order_by(CtaForm.updated_at.desc(), CtaForm.id.desc()) .all() ) return {"forms": [_form_to_dict(r, request=request) for r in rows]} @router.post("/api/cta-forms") def create_cta_form( body: CtaFormCreateRequest, request: Request, tc: TenantContext = Depends(get_tenant_context), ): fields = _normalize_fields([f.model_dump() for f in body.fields] if body.fields else DEFAULT_FIELDS) row = CtaForm( tenant_id=tc.tenant_id, user_id=tc.user_id, public_id=uuid.uuid4().hex, name=body.name.strip(), fields=fields, is_active=1, ) tc.db.add(row) db_commit_with_retry(tc.db) tc.db.refresh(row) return _form_to_dict(row, request=request) @router.get("/api/cta-forms/{form_id}") def get_cta_form(form_id: int, request: Request, tc: TenantContext = Depends(get_tenant_context)): row = ( tc.db.query(CtaForm) .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id) .first() ) if not row: raise HTTPException(status_code=404, detail="Form not found") return _form_to_dict(row, request=request) @router.patch("/api/cta-forms/{form_id}") def patch_cta_form( form_id: int, body: CtaFormPatchRequest, request: Request, tc: TenantContext = Depends(get_tenant_context), ): row = ( tc.db.query(CtaForm) .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id) .first() ) if not row: raise HTTPException(status_code=404, detail="Form not found") if body.name is not None: row.name = body.name.strip() if body.fields is not None: row.fields = _normalize_fields([f.model_dump() for f in body.fields]) if body.is_active is not None: row.is_active = 1 if body.is_active else 0 row.updated_at = datetime.utcnow() db_commit_with_retry(tc.db) tc.db.refresh(row) return _form_to_dict(row, request=request) @router.delete("/api/cta-forms/{form_id}") def delete_cta_form(form_id: int, tc: TenantContext = Depends(get_tenant_context)): row = ( tc.db.query(CtaForm) .filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id) .first() ) if not row: raise HTTPException(status_code=404, detail="Form not found") tc.db.delete(row) db_commit_with_retry(tc.db) return {"ok": True} # ---- Public embed + submit ---- @router.get("/api/public/cta-forms/{public_id}") def public_cta_form(public_id: str, request: Request, db: Session = Depends(get_db)): row = _get_active_form(db, public_id) return JSONResponse( content={ "public_id": row.public_id, "name": row.name or "", "fields": row.fields if isinstance(row.fields, list) else [], }, headers=_CORS_HEADERS, ) @router.options("/api/public/cta-forms/{public_id}/submit") async def public_cta_submit_options(): return Response(headers=_CORS_HEADERS) @router.post("/api/public/cta-forms/{public_id}/submit") def public_cta_submit( public_id: str, body: CtaFormSubmitRequest, db: Session = Depends(get_db), ): form = _get_active_form(db, public_id) payload = _validate_submission(form, body) lead = _create_lead_from_submission( db, form, payload, page_url=_safe_str(body.page_url), ) db_commit_with_retry(db) return JSONResponse( content={"ok": True, "lead_id": lead.id, "message": "Thank you — we will be in touch soon."}, headers=_CORS_HEADERS, ) @router.get("/api/embed/cta/{public_id}.js") def embed_cta_script(public_id: str, request: Request, db: Session = Depends(get_db)): form = _get_active_form(db, public_id) origin = _public_origin(request) fields_json = json.dumps(form.fields if isinstance(form.fields, list) else []) js = f"""(function() {{ var FORM_ID = {json.dumps(public_id)}; var API = {json.dumps(origin)}; var FIELDS = {fields_json}; var mount = document.getElementById('ezofis-cta-' + FORM_ID); if (!mount) {{ var s = document.currentScript; mount = document.createElement('div'); mount.id = 'ezofis-cta-' + FORM_ID; if (s && s.parentNode) s.parentNode.insertBefore(mount, s); else document.body.appendChild(mount); }} var css = document.createElement('style'); 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}}'; document.head.appendChild(css); var root = document.createElement('div'); root.className = 'ezofis-cta'; mount.appendChild(root); var err = document.createElement('div'); err.className = 'err'; err.style.display = 'none'; root.appendChild(err); var form = document.createElement('form'); root.appendChild(form); FIELDS.forEach(function(f) {{ var wrap = document.createElement('div'); var lab = document.createElement('label'); lab.textContent = f.label + (f.required ? ' *' : ''); wrap.appendChild(lab); var inp; if (f.type === 'textarea') {{ inp = document.createElement('textarea'); }} else {{ inp = document.createElement('input'); inp.type = f.type || 'text'; }} inp.name = f.key; inp.required = !!f.required; wrap.appendChild(inp); form.appendChild(wrap); }}); var btn = document.createElement('button'); btn.type = 'submit'; btn.textContent = 'Submit'; form.appendChild(btn); form.addEventListener('submit', function(e) {{ e.preventDefault(); err.style.display = 'none'; btn.disabled = true; var data = {{ fields: {{}}, page_url: window.location.href }}; FIELDS.forEach(function(f) {{ var el = form.querySelector('[name="' + f.key + '"]'); if (el) data.fields[f.key] = el.value || ''; }}); fetch(API + '/api/public/cta-forms/' + FORM_ID + '/submit', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify(data) }}).then(function(r) {{ return r.json().then(function(j) {{ return {{ ok: r.ok, j: j }}; }}); }}).then(function(res) {{ if (res.ok) {{ form.innerHTML = '
' + (res.j.message || 'Thank you!') + '
'; }} else {{ err.textContent = (res.j && res.j.detail) ? (typeof res.j.detail === 'string' ? res.j.detail : 'Submission failed') : 'Submission failed'; err.style.display = 'block'; btn.disabled = false; }} }}).catch(function() {{ err.textContent = 'Network error. Please try again.'; err.style.display = 'block'; btn.disabled = false; }}); }}); }})(); """ return PlainTextResponse(js, media_type="application/javascript", headers=_CORS_HEADERS) def lead_is_cta_form(row: CrmLead) -> bool: raw = row.raw_webhook if isinstance(row.raw_webhook, dict) else {} if raw.get("source") == CTA_LEAD_SOURCE: return True sid = _safe_str(row.smartlead_lead_id) return sid.startswith(CTA_LEAD_ID_PREFIX)