Seth commited on
Commit ·
32f4dc0
1
Parent(s): ef3e137
update
Browse files- backend/app/cta_forms_routes.py +507 -0
- backend/app/database.py +17 -0
- backend/app/main.py +3 -0
- backend/app/models.py +27 -0
- frontend/src/App.jsx +24 -15
- frontend/src/components/settings/CtaFormsSettings.jsx +505 -0
- frontend/src/pages/CtaFormEmbed.jsx +140 -0
- frontend/src/pages/Leads.jsx +27 -20
- frontend/src/pages/Settings.jsx +23 -3
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 |
-
<
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 436 |
-
|
| 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">
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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" />
|