Seth commited on
Commit Β·
480db82
1
Parent(s): 05ce0ac
update
Browse files- backend/app/__pycache__/__init__.cpython-314.pyc +0 -0
- backend/app/__pycache__/database.cpython-314.pyc +0 -0
- backend/app/__pycache__/main.cpython-314.pyc +0 -0
- backend/app/database.py +23 -0
- backend/app/main.py +392 -4
- backend/app/models.py +4 -0
- backend/app/smartlead_client.py +7 -0
- frontend/src/App.jsx +4 -3
- frontend/src/components/layout/AppHeader.jsx +8 -3
- frontend/src/components/layout/AppShell.jsx +9 -4
- frontend/src/pages/Leads.jsx +470 -0
backend/app/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
backend/app/__pycache__/database.cpython-314.pyc
CHANGED
|
Binary files a/backend/app/__pycache__/database.cpython-314.pyc and b/backend/app/__pycache__/database.cpython-314.pyc differ
|
|
|
backend/app/__pycache__/main.cpython-314.pyc
CHANGED
|
Binary files a/backend/app/__pycache__/main.cpython-314.pyc and b/backend/app/__pycache__/main.cpython-314.pyc differ
|
|
|
backend/app/database.py
CHANGED
|
@@ -70,6 +70,29 @@ class Contact(Base):
|
|
| 70 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
class SmartleadRun(Base):
|
| 74 |
__tablename__ = "smartlead_runs"
|
| 75 |
|
|
|
|
| 70 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 71 |
|
| 72 |
|
| 73 |
+
class CrmLead(Base):
|
| 74 |
+
"""Lead synced from Smartlead replies (webhook) β CRM pipeline status is local."""
|
| 75 |
+
__tablename__ = "crm_leads"
|
| 76 |
+
|
| 77 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 78 |
+
smartlead_lead_id = Column(String, index=True)
|
| 79 |
+
campaign_id = Column(String, index=True)
|
| 80 |
+
campaign_name = Column(String)
|
| 81 |
+
email = Column(String, index=True)
|
| 82 |
+
first_name = Column(String)
|
| 83 |
+
last_name = Column(String)
|
| 84 |
+
company_name = Column(String)
|
| 85 |
+
title = Column(String)
|
| 86 |
+
last_reply_subject = Column(String)
|
| 87 |
+
last_reply_body = Column(Text)
|
| 88 |
+
last_reply_at = Column(DateTime, nullable=True)
|
| 89 |
+
crm_status = Column(String, default="new_lead") # new_lead|attempted_to_contact|contacted|qualified|unqualified|none
|
| 90 |
+
contact_id = Column(Integer, nullable=True) # links to contacts.id after "Move to Contacts"
|
| 91 |
+
raw_webhook = Column(JSON)
|
| 92 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 93 |
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
class SmartleadRun(Base):
|
| 97 |
__tablename__ = "smartlead_runs"
|
| 98 |
|
backend/app/main.py
CHANGED
|
@@ -1,23 +1,32 @@
|
|
| 1 |
-
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Query
|
| 2 |
from fastapi.responses import FileResponse, StreamingResponse
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pathlib import Path
|
| 6 |
from sqlalchemy.orm import Session
|
|
|
|
| 7 |
import pandas as pd
|
| 8 |
import uuid
|
| 9 |
import os
|
| 10 |
import csv
|
| 11 |
import io
|
| 12 |
import concurrent.futures
|
| 13 |
-
from typing import Dict, List
|
| 14 |
import json
|
| 15 |
import asyncio
|
| 16 |
import math
|
|
|
|
| 17 |
from datetime import datetime
|
| 18 |
|
| 19 |
-
from .database import get_db, UploadedFile, Prompt, GeneratedSequence, SmartleadRun, Contact
|
| 20 |
-
from .models import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from .gpt_service import generate_email_sequence
|
| 22 |
from .smartlead_client import SmartleadClient
|
| 23 |
|
|
@@ -117,6 +126,140 @@ def _to_datetime(val):
|
|
| 117 |
return None
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@app.get("/api/health")
|
| 121 |
def health():
|
| 122 |
return {"status": "ok"}
|
|
@@ -883,6 +1026,251 @@ async def get_smartlead_runs(file_id: str = Query(None), db: Session = Depends(g
|
|
| 883 |
raise HTTPException(status_code=500, detail=f"Error fetching runs: {str(e)}")
|
| 884 |
|
| 885 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
# ---- Frontend static serving ----
|
| 887 |
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
| 888 |
INDEX_FILE = FRONTEND_DIST / "index.html"
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Query, Request
|
| 2 |
from fastapi.responses import FileResponse, StreamingResponse
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from pathlib import Path
|
| 6 |
from sqlalchemy.orm import Session
|
| 7 |
+
from sqlalchemy import func, or_
|
| 8 |
import pandas as pd
|
| 9 |
import uuid
|
| 10 |
import os
|
| 11 |
import csv
|
| 12 |
import io
|
| 13 |
import concurrent.futures
|
| 14 |
+
from typing import Dict, List, Optional
|
| 15 |
import json
|
| 16 |
import asyncio
|
| 17 |
import math
|
| 18 |
+
import re
|
| 19 |
from datetime import datetime
|
| 20 |
|
| 21 |
+
from .database import get_db, UploadedFile, Prompt, GeneratedSequence, SmartleadRun, Contact, CrmLead
|
| 22 |
+
from .models import (
|
| 23 |
+
UploadResponse,
|
| 24 |
+
PromptSaveRequest,
|
| 25 |
+
SequenceResponse,
|
| 26 |
+
SmartleadPushRequest,
|
| 27 |
+
SmartleadRunResponse,
|
| 28 |
+
CrmLeadPatchRequest,
|
| 29 |
+
)
|
| 30 |
from .gpt_service import generate_email_sequence
|
| 31 |
from .smartlead_client import SmartleadClient
|
| 32 |
|
|
|
|
| 126 |
return None
|
| 127 |
|
| 128 |
|
| 129 |
+
CRM_STATUS_ALLOWED = frozenset({
|
| 130 |
+
"none",
|
| 131 |
+
"new_lead",
|
| 132 |
+
"attempted_to_contact",
|
| 133 |
+
"contacted",
|
| 134 |
+
"qualified",
|
| 135 |
+
"unqualified",
|
| 136 |
+
})
|
| 137 |
+
SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _strip_html_simple(text: str) -> str:
|
| 141 |
+
if not text:
|
| 142 |
+
return ""
|
| 143 |
+
t = re.sub(r"<[^>]+>", " ", text)
|
| 144 |
+
return re.sub(r"\s+", " ", t).strip()
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _parse_smartlead_reply_payload(body: dict) -> Optional[dict]:
|
| 148 |
+
"""Normalize Smartlead webhook JSON into fields for CrmLead. Returns None if not a reply or missing identity."""
|
| 149 |
+
if not isinstance(body, dict):
|
| 150 |
+
return None
|
| 151 |
+
nest = body.get("data") if isinstance(body.get("data"), dict) else {}
|
| 152 |
+
lead = body.get("lead") or nest.get("lead") or {}
|
| 153 |
+
if not isinstance(lead, dict):
|
| 154 |
+
lead = {}
|
| 155 |
+
reply = body.get("reply") or nest.get("reply") or {}
|
| 156 |
+
if not isinstance(reply, dict):
|
| 157 |
+
reply = {}
|
| 158 |
+
|
| 159 |
+
event = str(body.get("event") or body.get("event_type") or body.get("type") or "").upper()
|
| 160 |
+
has_reply_content = bool(
|
| 161 |
+
reply.get("body")
|
| 162 |
+
or reply.get("html_body")
|
| 163 |
+
or reply.get("text_body")
|
| 164 |
+
or body.get("reply_body")
|
| 165 |
+
or body.get("message")
|
| 166 |
+
or nest.get("reply_body")
|
| 167 |
+
)
|
| 168 |
+
is_reply_event = "REPLIED" in event or event in ("EMAIL_REPLIED", "LEAD_REPLIED")
|
| 169 |
+
if not is_reply_event and not has_reply_content:
|
| 170 |
+
return None
|
| 171 |
+
|
| 172 |
+
campaign_id = _safe_str(
|
| 173 |
+
body.get("campaign_id")
|
| 174 |
+
or nest.get("campaign_id")
|
| 175 |
+
or lead.get("campaign_id")
|
| 176 |
+
or body.get("campaignId")
|
| 177 |
+
)
|
| 178 |
+
sl_id = lead.get("lead_id") or lead.get("id") or body.get("lead_id") or nest.get("lead_id")
|
| 179 |
+
sl_id_str = _safe_str(sl_id) if sl_id is not None else ""
|
| 180 |
+
|
| 181 |
+
email = (
|
| 182 |
+
lead.get("email")
|
| 183 |
+
or lead.get("email_address")
|
| 184 |
+
or body.get("lead_email")
|
| 185 |
+
or body.get("email")
|
| 186 |
+
or nest.get("email")
|
| 187 |
+
)
|
| 188 |
+
email = _safe_str(email).lower()
|
| 189 |
+
if not email and not sl_id_str:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
reply_body = (
|
| 193 |
+
reply.get("body")
|
| 194 |
+
or reply.get("html_body")
|
| 195 |
+
or reply.get("text_body")
|
| 196 |
+
or body.get("reply_body")
|
| 197 |
+
or body.get("message")
|
| 198 |
+
or nest.get("reply_body")
|
| 199 |
+
or ""
|
| 200 |
+
)
|
| 201 |
+
reply_body = _strip_html_simple(_safe_str(reply_body))
|
| 202 |
+
subject = _safe_str(reply.get("subject") or body.get("subject") or body.get("reply_subject") or "")
|
| 203 |
+
if not reply_body and not subject:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
received_raw = (
|
| 207 |
+
reply.get("received_at")
|
| 208 |
+
or reply.get("created_at")
|
| 209 |
+
or body.get("received_at")
|
| 210 |
+
or body.get("timestamp")
|
| 211 |
+
or nest.get("received_at")
|
| 212 |
+
)
|
| 213 |
+
received_at = _to_datetime(received_raw) if received_raw else datetime.utcnow()
|
| 214 |
+
|
| 215 |
+
fn = _safe_str(lead.get("first_name") or body.get("first_name"))
|
| 216 |
+
ln = _safe_str(lead.get("last_name") or body.get("last_name"))
|
| 217 |
+
company = _safe_str(
|
| 218 |
+
lead.get("company_name")
|
| 219 |
+
or lead.get("company")
|
| 220 |
+
or body.get("company_name")
|
| 221 |
+
or nest.get("company_name")
|
| 222 |
+
)
|
| 223 |
+
title = _safe_str(lead.get("title") or lead.get("job_title") or body.get("title"))
|
| 224 |
+
campaign_name = _safe_str(body.get("campaign_name") or nest.get("campaign_name") or "")
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"smartlead_lead_id": sl_id_str,
|
| 228 |
+
"campaign_id": campaign_id,
|
| 229 |
+
"campaign_name": campaign_name,
|
| 230 |
+
"email": email,
|
| 231 |
+
"first_name": fn,
|
| 232 |
+
"last_name": ln,
|
| 233 |
+
"company_name": company,
|
| 234 |
+
"title": title,
|
| 235 |
+
"last_reply_subject": subject,
|
| 236 |
+
"last_reply_body": reply_body,
|
| 237 |
+
"last_reply_at": received_at,
|
| 238 |
+
"raw_webhook": body,
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _crm_lead_to_dict(row: CrmLead) -> dict:
|
| 243 |
+
return {
|
| 244 |
+
"id": row.id,
|
| 245 |
+
"smartlead_lead_id": row.smartlead_lead_id,
|
| 246 |
+
"campaign_id": row.campaign_id,
|
| 247 |
+
"campaign_name": row.campaign_name or "",
|
| 248 |
+
"email": row.email or "",
|
| 249 |
+
"first_name": row.first_name or "",
|
| 250 |
+
"last_name": row.last_name or "",
|
| 251 |
+
"company_name": row.company_name or "",
|
| 252 |
+
"title": row.title or "",
|
| 253 |
+
"last_reply_subject": row.last_reply_subject or "",
|
| 254 |
+
"last_reply_body": row.last_reply_body or "",
|
| 255 |
+
"last_reply_at": row.last_reply_at.isoformat() if row.last_reply_at else None,
|
| 256 |
+
"crm_status": row.crm_status or "new_lead",
|
| 257 |
+
"contact_id": row.contact_id,
|
| 258 |
+
"created_at": row.created_at.isoformat() if row.created_at else None,
|
| 259 |
+
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
@app.get("/api/health")
|
| 264 |
def health():
|
| 265 |
return {"status": "ok"}
|
|
|
|
| 1026 |
raise HTTPException(status_code=500, detail=f"Error fetching runs: {str(e)}")
|
| 1027 |
|
| 1028 |
|
| 1029 |
+
@app.post("/api/webhooks/smartlead")
|
| 1030 |
+
async def smartlead_webhook(request: Request, db: Session = Depends(get_db)):
|
| 1031 |
+
"""
|
| 1032 |
+
Smartlead webhook β configure in Smartlead to POST reply events to this URL when a lead replies.
|
| 1033 |
+
Optional: set SMARTLEAD_WEBHOOK_SECRET and send the same value in header X-Webhook-Token (or Bearer).
|
| 1034 |
+
"""
|
| 1035 |
+
secret = os.getenv("SMARTLEAD_WEBHOOK_SECRET")
|
| 1036 |
+
if secret:
|
| 1037 |
+
token = request.headers.get("X-Webhook-Token") or ""
|
| 1038 |
+
if not token:
|
| 1039 |
+
auth = request.headers.get("Authorization") or ""
|
| 1040 |
+
if auth.lower().startswith("bearer "):
|
| 1041 |
+
token = auth[7:].strip()
|
| 1042 |
+
if token != secret:
|
| 1043 |
+
raise HTTPException(status_code=401, detail="Invalid webhook token")
|
| 1044 |
+
|
| 1045 |
+
try:
|
| 1046 |
+
body = await request.json()
|
| 1047 |
+
except Exception:
|
| 1048 |
+
raise HTTPException(status_code=400, detail="Expected JSON body")
|
| 1049 |
+
|
| 1050 |
+
parsed = _parse_smartlead_reply_payload(body)
|
| 1051 |
+
if not parsed:
|
| 1052 |
+
return {"ok": True, "ignored": True, "reason": "not_a_reply_or_missing_fields"}
|
| 1053 |
+
|
| 1054 |
+
q = db.query(CrmLead)
|
| 1055 |
+
row = None
|
| 1056 |
+
if parsed["smartlead_lead_id"] and parsed["campaign_id"]:
|
| 1057 |
+
row = q.filter(
|
| 1058 |
+
CrmLead.smartlead_lead_id == parsed["smartlead_lead_id"],
|
| 1059 |
+
CrmLead.campaign_id == parsed["campaign_id"],
|
| 1060 |
+
).first()
|
| 1061 |
+
if row is None and parsed["email"] and parsed["campaign_id"]:
|
| 1062 |
+
row = q.filter(
|
| 1063 |
+
CrmLead.email == parsed["email"],
|
| 1064 |
+
CrmLead.campaign_id == parsed["campaign_id"],
|
| 1065 |
+
).first()
|
| 1066 |
+
|
| 1067 |
+
if parsed["email"]:
|
| 1068 |
+
apollo = (
|
| 1069 |
+
db.query(Contact)
|
| 1070 |
+
.filter(func.lower(Contact.email) == parsed["email"].lower())
|
| 1071 |
+
.first()
|
| 1072 |
+
)
|
| 1073 |
+
if apollo:
|
| 1074 |
+
if not parsed["company_name"] and apollo.company:
|
| 1075 |
+
parsed["company_name"] = apollo.company
|
| 1076 |
+
if not parsed["title"] and apollo.title:
|
| 1077 |
+
parsed["title"] = apollo.title
|
| 1078 |
+
if not parsed["first_name"] and apollo.first_name:
|
| 1079 |
+
parsed["first_name"] = apollo.first_name
|
| 1080 |
+
if not parsed["last_name"] and apollo.last_name:
|
| 1081 |
+
parsed["last_name"] = apollo.last_name
|
| 1082 |
+
|
| 1083 |
+
new_ts = parsed["last_reply_at"]
|
| 1084 |
+
if row:
|
| 1085 |
+
old_ts = row.last_reply_at
|
| 1086 |
+
if not old_ts or (new_ts and new_ts >= old_ts):
|
| 1087 |
+
row.last_reply_subject = parsed["last_reply_subject"]
|
| 1088 |
+
row.last_reply_body = parsed["last_reply_body"]
|
| 1089 |
+
row.last_reply_at = new_ts
|
| 1090 |
+
row.raw_webhook = parsed["raw_webhook"]
|
| 1091 |
+
row.email = parsed["email"] or row.email
|
| 1092 |
+
row.first_name = parsed["first_name"] or row.first_name
|
| 1093 |
+
row.last_name = parsed["last_name"] or row.last_name
|
| 1094 |
+
row.company_name = parsed["company_name"] or row.company_name
|
| 1095 |
+
row.title = parsed["title"] or row.title
|
| 1096 |
+
row.campaign_name = parsed["campaign_name"] or row.campaign_name
|
| 1097 |
+
if parsed["smartlead_lead_id"]:
|
| 1098 |
+
row.smartlead_lead_id = parsed["smartlead_lead_id"]
|
| 1099 |
+
else:
|
| 1100 |
+
row = CrmLead(
|
| 1101 |
+
smartlead_lead_id=parsed["smartlead_lead_id"] or "",
|
| 1102 |
+
campaign_id=parsed["campaign_id"] or "",
|
| 1103 |
+
campaign_name=parsed["campaign_name"] or "",
|
| 1104 |
+
email=parsed["email"] or "",
|
| 1105 |
+
first_name=parsed["first_name"] or "",
|
| 1106 |
+
last_name=parsed["last_name"] or "",
|
| 1107 |
+
company_name=parsed["company_name"] or "",
|
| 1108 |
+
title=parsed["title"] or "",
|
| 1109 |
+
last_reply_subject=parsed["last_reply_subject"] or "",
|
| 1110 |
+
last_reply_body=parsed["last_reply_body"] or "",
|
| 1111 |
+
last_reply_at=new_ts,
|
| 1112 |
+
crm_status="new_lead",
|
| 1113 |
+
raw_webhook=parsed["raw_webhook"],
|
| 1114 |
+
)
|
| 1115 |
+
db.add(row)
|
| 1116 |
+
|
| 1117 |
+
db.commit()
|
| 1118 |
+
db.refresh(row)
|
| 1119 |
+
return {"ok": True, "lead_id": row.id}
|
| 1120 |
+
|
| 1121 |
+
|
| 1122 |
+
@app.get("/api/leads")
|
| 1123 |
+
async def list_leads(
|
| 1124 |
+
search: str = Query("", description="Search email, name, company"),
|
| 1125 |
+
status: str = Query("", description="crm_status filter"),
|
| 1126 |
+
sort_by: str = Query("last_reply_at"),
|
| 1127 |
+
sort_dir: str = Query("desc"),
|
| 1128 |
+
limit: int = Query(50, ge=1, le=200),
|
| 1129 |
+
offset: int = Query(0, ge=0),
|
| 1130 |
+
db: Session = Depends(get_db),
|
| 1131 |
+
):
|
| 1132 |
+
q = db.query(CrmLead)
|
| 1133 |
+
if search.strip():
|
| 1134 |
+
term = f"%{search.strip().lower()}%"
|
| 1135 |
+
q = q.filter(
|
| 1136 |
+
or_(
|
| 1137 |
+
func.lower(CrmLead.email).like(term),
|
| 1138 |
+
func.lower(func.coalesce(CrmLead.company_name, "")).like(term),
|
| 1139 |
+
func.lower(func.coalesce(CrmLead.first_name, "")).like(term),
|
| 1140 |
+
func.lower(func.coalesce(CrmLead.last_name, "")).like(term),
|
| 1141 |
+
)
|
| 1142 |
+
)
|
| 1143 |
+
if status.strip() and status in CRM_STATUS_ALLOWED:
|
| 1144 |
+
q = q.filter(CrmLead.crm_status == status)
|
| 1145 |
+
|
| 1146 |
+
total = q.count()
|
| 1147 |
+
|
| 1148 |
+
col_map = {
|
| 1149 |
+
"last_reply_at": CrmLead.last_reply_at,
|
| 1150 |
+
"created_at": CrmLead.created_at,
|
| 1151 |
+
"email": CrmLead.email,
|
| 1152 |
+
"company_name": CrmLead.company_name,
|
| 1153 |
+
}
|
| 1154 |
+
col = col_map.get(sort_by, CrmLead.last_reply_at)
|
| 1155 |
+
if sort_dir == "asc":
|
| 1156 |
+
q = q.order_by(col.asc())
|
| 1157 |
+
else:
|
| 1158 |
+
q = q.order_by(col.desc())
|
| 1159 |
+
|
| 1160 |
+
rows = q.offset(offset).limit(limit).all()
|
| 1161 |
+
return {
|
| 1162 |
+
"total": total,
|
| 1163 |
+
"leads": [_crm_lead_to_dict(r) for r in rows],
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
|
| 1167 |
+
@app.get("/api/leads/{lead_id}")
|
| 1168 |
+
async def get_lead(lead_id: int, db: Session = Depends(get_db)):
|
| 1169 |
+
row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
|
| 1170 |
+
if not row:
|
| 1171 |
+
raise HTTPException(status_code=404, detail="Lead not found")
|
| 1172 |
+
d = _crm_lead_to_dict(row)
|
| 1173 |
+
if row.contact_id:
|
| 1174 |
+
c = db.query(Contact).filter(Contact.id == row.contact_id).first()
|
| 1175 |
+
if c:
|
| 1176 |
+
d["contact"] = {
|
| 1177 |
+
"id": c.id,
|
| 1178 |
+
"email": c.email,
|
| 1179 |
+
"company": c.company,
|
| 1180 |
+
"title": c.title,
|
| 1181 |
+
}
|
| 1182 |
+
return d
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
@app.patch("/api/leads/{lead_id}")
|
| 1186 |
+
async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depends(get_db)):
|
| 1187 |
+
if body.crm_status not in CRM_STATUS_ALLOWED:
|
| 1188 |
+
raise HTTPException(
|
| 1189 |
+
status_code=400,
|
| 1190 |
+
detail=f"crm_status must be one of: {sorted(CRM_STATUS_ALLOWED)}",
|
| 1191 |
+
)
|
| 1192 |
+
row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
|
| 1193 |
+
if not row:
|
| 1194 |
+
raise HTTPException(status_code=404, detail="Lead not found")
|
| 1195 |
+
row.crm_status = body.crm_status
|
| 1196 |
+
db.commit()
|
| 1197 |
+
db.refresh(row)
|
| 1198 |
+
return _crm_lead_to_dict(row)
|
| 1199 |
+
|
| 1200 |
+
|
| 1201 |
+
@app.post("/api/leads/{lead_id}/move-to-contacts")
|
| 1202 |
+
async def move_lead_to_contacts(lead_id: int, db: Session = Depends(get_db)):
|
| 1203 |
+
lead = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
|
| 1204 |
+
if not lead:
|
| 1205 |
+
raise HTTPException(status_code=404, detail="Lead not found")
|
| 1206 |
+
if lead.contact_id:
|
| 1207 |
+
return {"contact_id": lead.contact_id, "message": "Already linked to Contacts"}
|
| 1208 |
+
|
| 1209 |
+
email = (lead.email or "").strip()
|
| 1210 |
+
if not email:
|
| 1211 |
+
raise HTTPException(status_code=400, detail="Lead has no email")
|
| 1212 |
+
|
| 1213 |
+
existing = (
|
| 1214 |
+
db.query(Contact)
|
| 1215 |
+
.filter(func.lower(Contact.email) == email.lower())
|
| 1216 |
+
.first()
|
| 1217 |
+
)
|
| 1218 |
+
if existing:
|
| 1219 |
+
lead.contact_id = existing.id
|
| 1220 |
+
db.commit()
|
| 1221 |
+
return {"contact_id": existing.id, "message": "Linked to existing contact"}
|
| 1222 |
+
|
| 1223 |
+
raw = {
|
| 1224 |
+
"Company Name": lead.company_name or "",
|
| 1225 |
+
"Title": lead.title or "",
|
| 1226 |
+
"source": "smartlead_reply",
|
| 1227 |
+
"smartlead_lead_id": lead.smartlead_lead_id,
|
| 1228 |
+
"campaign_id": lead.campaign_id,
|
| 1229 |
+
"last_reply_subject": lead.last_reply_subject,
|
| 1230 |
+
"last_reply_body": lead.last_reply_body,
|
| 1231 |
+
"smartlead_webhook": lead.raw_webhook,
|
| 1232 |
+
}
|
| 1233 |
+
contact = Contact(
|
| 1234 |
+
file_id=SMARTLEAD_IMPORT_FILE_ID,
|
| 1235 |
+
row_index=lead_id,
|
| 1236 |
+
first_name=lead.first_name or "",
|
| 1237 |
+
last_name=lead.last_name or "",
|
| 1238 |
+
email=email,
|
| 1239 |
+
company=lead.company_name or "",
|
| 1240 |
+
title=lead.title or "",
|
| 1241 |
+
source="smartlead",
|
| 1242 |
+
raw_data=raw,
|
| 1243 |
+
)
|
| 1244 |
+
db.add(contact)
|
| 1245 |
+
db.commit()
|
| 1246 |
+
db.refresh(contact)
|
| 1247 |
+
lead.contact_id = contact.id
|
| 1248 |
+
db.commit()
|
| 1249 |
+
return {"contact_id": contact.id, "message": "Contact created"}
|
| 1250 |
+
|
| 1251 |
+
|
| 1252 |
+
@app.get("/api/leads/{lead_id}/smartlead-thread")
|
| 1253 |
+
async def lead_smartlead_thread(lead_id: int, db: Session = Depends(get_db)):
|
| 1254 |
+
"""Fetch full thread from Smartlead API (Admin API key required)."""
|
| 1255 |
+
row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
|
| 1256 |
+
if not row:
|
| 1257 |
+
raise HTTPException(status_code=404, detail="Lead not found")
|
| 1258 |
+
if not row.smartlead_lead_id or not row.campaign_id:
|
| 1259 |
+
raise HTTPException(status_code=400, detail="Lead missing Smartlead campaign/lead id")
|
| 1260 |
+
try:
|
| 1261 |
+
lid = int(row.smartlead_lead_id)
|
| 1262 |
+
except (TypeError, ValueError):
|
| 1263 |
+
raise HTTPException(status_code=400, detail="Invalid smartlead_lead_id")
|
| 1264 |
+
try:
|
| 1265 |
+
client = SmartleadClient()
|
| 1266 |
+
hist = client.get_message_history(str(row.campaign_id), lid)
|
| 1267 |
+
except ValueError as e:
|
| 1268 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 1269 |
+
except Exception as e:
|
| 1270 |
+
raise HTTPException(status_code=502, detail=str(e))
|
| 1271 |
+
return {"history": hist}
|
| 1272 |
+
|
| 1273 |
+
|
| 1274 |
# ---- Frontend static serving ----
|
| 1275 |
FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
| 1276 |
INDEX_FILE = FRONTEND_DIST / "index.html"
|
backend/app/models.py
CHANGED
|
@@ -33,6 +33,10 @@ class SmartleadPushRequest(BaseModel):
|
|
| 33 |
dry_run: bool = False
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
class SmartleadRunResponse(BaseModel):
|
| 37 |
run_id: str
|
| 38 |
campaign_id: Optional[str] = None
|
|
|
|
| 33 |
dry_run: bool = False
|
| 34 |
|
| 35 |
|
| 36 |
+
class CrmLeadPatchRequest(BaseModel):
|
| 37 |
+
crm_status: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
class SmartleadRunResponse(BaseModel):
|
| 41 |
run_id: str
|
| 42 |
campaign_id: Optional[str] = None
|
backend/app/smartlead_client.py
CHANGED
|
@@ -190,6 +190,13 @@ class SmartleadClient:
|
|
| 190 |
|
| 191 |
return None
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
def update_campaign_settings(self, campaign_id: str, settings: Dict) -> Dict:
|
| 194 |
"""Update campaign settings"""
|
| 195 |
return self._make_request("POST", f"/campaigns/{campaign_id}/settings", settings)
|
|
|
|
| 190 |
|
| 191 |
return None
|
| 192 |
|
| 193 |
+
def get_message_history(self, campaign_id: str, lead_id: int) -> Dict:
|
| 194 |
+
"""Full email thread for a lead (SENT / REPLY). See Smartlead API docs."""
|
| 195 |
+
return self._make_request(
|
| 196 |
+
"GET",
|
| 197 |
+
f"/campaigns/{campaign_id}/leads/{lead_id}/message-history",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
def update_campaign_settings(self, campaign_id: str, settings: Dict) -> Dict:
|
| 201 |
"""Update campaign settings"""
|
| 202 |
return self._make_request("POST", f"/campaigns/{campaign_id}/settings", settings)
|
frontend/src/App.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import React from "react";
|
| 2 |
-
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
| 3 |
import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
|
| 4 |
-
import RunHistory from "./pages/RunHistory";
|
| 5 |
import Contacts from "./pages/Contacts";
|
|
|
|
| 6 |
import "./index.css";
|
| 7 |
|
| 8 |
export default function App() {
|
|
@@ -11,7 +11,8 @@ export default function App() {
|
|
| 11 |
<Routes>
|
| 12 |
<Route path="/" element={<EmailSequenceGenerator />} />
|
| 13 |
<Route path="/contacts" element={<Contacts />} />
|
| 14 |
-
<Route path="/
|
|
|
|
| 15 |
</Routes>
|
| 16 |
</BrowserRouter>
|
| 17 |
);
|
|
|
|
| 1 |
import React from "react";
|
| 2 |
+
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
| 3 |
import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
|
|
|
|
| 4 |
import Contacts from "./pages/Contacts";
|
| 5 |
+
import Leads from "./pages/Leads";
|
| 6 |
import "./index.css";
|
| 7 |
|
| 8 |
export default function App() {
|
|
|
|
| 11 |
<Routes>
|
| 12 |
<Route path="/" element={<EmailSequenceGenerator />} />
|
| 13 |
<Route path="/contacts" element={<Contacts />} />
|
| 14 |
+
<Route path="/leads" element={<Leads />} />
|
| 15 |
+
<Route path="/history" element={<Navigate to="/leads" replace />} />
|
| 16 |
</Routes>
|
| 17 |
</BrowserRouter>
|
| 18 |
);
|
frontend/src/components/layout/AppHeader.jsx
CHANGED
|
@@ -6,9 +6,14 @@ import { Button } from "@/components/ui/button";
|
|
| 6 |
const MENU_ITEMS = [
|
| 7 |
{ label: 'Generator', href: '/' },
|
| 8 |
{ label: 'Contacts', href: '/contacts' },
|
| 9 |
-
{ label: '
|
| 10 |
];
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export default function AppHeader({ rightContent }) {
|
| 13 |
const location = useLocation();
|
| 14 |
|
|
@@ -28,7 +33,7 @@ export default function AppHeader({ rightContent }) {
|
|
| 28 |
</div>
|
| 29 |
<nav className="hidden md:flex items-center gap-2">
|
| 30 |
{MENU_ITEMS.map((item) => {
|
| 31 |
-
const isActive = location.pathname
|
| 32 |
return (
|
| 33 |
<Button
|
| 34 |
key={item.href}
|
|
@@ -49,7 +54,7 @@ export default function AppHeader({ rightContent }) {
|
|
| 49 |
</div>
|
| 50 |
<nav className="md:hidden flex items-center gap-2 mt-3">
|
| 51 |
{MENU_ITEMS.map((item) => {
|
| 52 |
-
const isActive = location.pathname
|
| 53 |
return (
|
| 54 |
<Button
|
| 55 |
key={item.href}
|
|
|
|
| 6 |
const MENU_ITEMS = [
|
| 7 |
{ label: 'Generator', href: '/' },
|
| 8 |
{ label: 'Contacts', href: '/contacts' },
|
| 9 |
+
{ label: 'Leads', href: '/leads' },
|
| 10 |
];
|
| 11 |
|
| 12 |
+
function pathMatches(locationPath, href) {
|
| 13 |
+
if (href === '/') return locationPath === '/';
|
| 14 |
+
return locationPath === href || locationPath.startsWith(`${href}/`);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
export default function AppHeader({ rightContent }) {
|
| 18 |
const location = useLocation();
|
| 19 |
|
|
|
|
| 33 |
</div>
|
| 34 |
<nav className="hidden md:flex items-center gap-2">
|
| 35 |
{MENU_ITEMS.map((item) => {
|
| 36 |
+
const isActive = pathMatches(location.pathname, item.href);
|
| 37 |
return (
|
| 38 |
<Button
|
| 39 |
key={item.href}
|
|
|
|
| 54 |
</div>
|
| 55 |
<nav className="md:hidden flex items-center gap-2 mt-3">
|
| 56 |
{MENU_ITEMS.map((item) => {
|
| 57 |
+
const isActive = pathMatches(location.pathname, item.href);
|
| 58 |
return (
|
| 59 |
<Button
|
| 60 |
key={item.href}
|
frontend/src/components/layout/AppShell.jsx
CHANGED
|
@@ -1,14 +1,19 @@
|
|
| 1 |
import React from 'react';
|
| 2 |
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
-
import { Zap, LayoutDashboard, Users,
|
| 4 |
import { Button } from "@/components/ui/button";
|
| 5 |
|
| 6 |
const NAV_ITEMS = [
|
| 7 |
{ label: 'Generator', href: '/', icon: LayoutDashboard },
|
| 8 |
{ label: 'Contacts', href: '/contacts', icon: Users },
|
| 9 |
-
{ label: '
|
| 10 |
];
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export default function AppShell({ title, subtitle, rightContent, children }) {
|
| 13 |
const location = useLocation();
|
| 14 |
|
|
@@ -28,7 +33,7 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
|
|
| 28 |
<nav className="space-y-2">
|
| 29 |
{NAV_ITEMS.map((item) => {
|
| 30 |
const Icon = item.icon;
|
| 31 |
-
const active = location.pathname
|
| 32 |
return (
|
| 33 |
<Link
|
| 34 |
to={item.href}
|
|
@@ -69,7 +74,7 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
|
|
| 69 |
</div>
|
| 70 |
<nav className="md:hidden flex items-center gap-2 mt-3">
|
| 71 |
{NAV_ITEMS.map((item) => {
|
| 72 |
-
const active = location.pathname
|
| 73 |
return (
|
| 74 |
<Button
|
| 75 |
asChild
|
|
|
|
| 1 |
import React from 'react';
|
| 2 |
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
+
import { Zap, LayoutDashboard, Users, Inbox } from 'lucide-react';
|
| 4 |
import { Button } from "@/components/ui/button";
|
| 5 |
|
| 6 |
const NAV_ITEMS = [
|
| 7 |
{ label: 'Generator', href: '/', icon: LayoutDashboard },
|
| 8 |
{ label: 'Contacts', href: '/contacts', icon: Users },
|
| 9 |
+
{ label: 'Leads', href: '/leads', icon: Inbox },
|
| 10 |
];
|
| 11 |
|
| 12 |
+
function pathMatches(locationPath, href) {
|
| 13 |
+
if (href === '/') return locationPath === '/';
|
| 14 |
+
return locationPath === href || locationPath.startsWith(`${href}/`);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
export default function AppShell({ title, subtitle, rightContent, children }) {
|
| 18 |
const location = useLocation();
|
| 19 |
|
|
|
|
| 33 |
<nav className="space-y-2">
|
| 34 |
{NAV_ITEMS.map((item) => {
|
| 35 |
const Icon = item.icon;
|
| 36 |
+
const active = pathMatches(location.pathname, item.href);
|
| 37 |
return (
|
| 38 |
<Link
|
| 39 |
to={item.href}
|
|
|
|
| 74 |
</div>
|
| 75 |
<nav className="md:hidden flex items-center gap-2 mt-3">
|
| 76 |
{NAV_ITEMS.map((item) => {
|
| 77 |
+
const active = pathMatches(location.pathname, item.href);
|
| 78 |
return (
|
| 79 |
<Button
|
| 80 |
asChild
|
frontend/src/pages/Leads.jsx
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Inbox,
|
| 4 |
+
Search,
|
| 5 |
+
ChevronDown,
|
| 6 |
+
ChevronRight,
|
| 7 |
+
Building2,
|
| 8 |
+
Briefcase,
|
| 9 |
+
Mail,
|
| 10 |
+
ExternalLink,
|
| 11 |
+
Loader2,
|
| 12 |
+
} from 'lucide-react';
|
| 13 |
+
import { Input } from '@/components/ui/input';
|
| 14 |
+
import { Button } from '@/components/ui/button';
|
| 15 |
+
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 16 |
+
import AppShell from '@/components/layout/AppShell';
|
| 17 |
+
import { cn } from '@/lib/utils';
|
| 18 |
+
|
| 19 |
+
const CRM_STATUSES = [
|
| 20 |
+
{ value: 'none', label: 'β', className: 'bg-slate-300 text-slate-800' },
|
| 21 |
+
{ value: 'new_lead', label: 'New Lead', className: 'bg-amber-200 text-amber-950' },
|
| 22 |
+
{ value: 'attempted_to_contact', label: 'Attempted to contact', className: 'bg-rose-100 text-rose-800' },
|
| 23 |
+
{ value: 'contacted', label: 'Contacted', className: 'bg-orange-500 text-white' },
|
| 24 |
+
{ value: 'qualified', label: 'Qualified', className: 'bg-lime-500 text-white' },
|
| 25 |
+
{ value: 'unqualified', label: 'Unqualified', className: 'bg-fuchsia-900 text-white' },
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
function statusMeta(value) {
|
| 29 |
+
return CRM_STATUSES.find((s) => s.value === value) || CRM_STATUSES[1];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export default function Leads() {
|
| 33 |
+
const [leads, setLeads] = useState([]);
|
| 34 |
+
const [total, setTotal] = useState(0);
|
| 35 |
+
const [loading, setLoading] = useState(true);
|
| 36 |
+
const [search, setSearch] = useState('');
|
| 37 |
+
const [statusFilter, setStatusFilter] = useState('all');
|
| 38 |
+
const [sectionOpen, setSectionOpen] = useState(true);
|
| 39 |
+
const [selected, setSelected] = useState(null);
|
| 40 |
+
const [threadLoading, setThreadLoading] = useState(false);
|
| 41 |
+
const [threadData, setThreadData] = useState(null);
|
| 42 |
+
const [moveBusy, setMoveBusy] = useState(null);
|
| 43 |
+
|
| 44 |
+
const webhookUrl = useMemo(() => {
|
| 45 |
+
if (typeof window === 'undefined') return '';
|
| 46 |
+
return `${window.location.origin}/api/webhooks/smartlead`;
|
| 47 |
+
}, []);
|
| 48 |
+
|
| 49 |
+
const fetchLeads = useCallback(async () => {
|
| 50 |
+
setLoading(true);
|
| 51 |
+
try {
|
| 52 |
+
const params = new URLSearchParams();
|
| 53 |
+
params.set('limit', '100');
|
| 54 |
+
params.set('offset', '0');
|
| 55 |
+
params.set('sort_by', 'last_reply_at');
|
| 56 |
+
params.set('sort_dir', 'desc');
|
| 57 |
+
if (search.trim()) params.set('search', search.trim());
|
| 58 |
+
if (statusFilter && statusFilter !== 'all') params.set('status', statusFilter);
|
| 59 |
+
const res = await fetch(`/api/leads?${params.toString()}`);
|
| 60 |
+
if (res.ok) {
|
| 61 |
+
const data = await res.json();
|
| 62 |
+
setLeads(data.leads || []);
|
| 63 |
+
setTotal(data.total ?? 0);
|
| 64 |
+
}
|
| 65 |
+
} catch (e) {
|
| 66 |
+
console.error(e);
|
| 67 |
+
} finally {
|
| 68 |
+
setLoading(false);
|
| 69 |
+
}
|
| 70 |
+
}, [search, statusFilter]);
|
| 71 |
+
|
| 72 |
+
useEffect(() => {
|
| 73 |
+
const t = setTimeout(() => fetchLeads(), 250);
|
| 74 |
+
return () => clearTimeout(t);
|
| 75 |
+
}, [fetchLeads]);
|
| 76 |
+
|
| 77 |
+
const updateStatus = async (leadId, crmStatus) => {
|
| 78 |
+
try {
|
| 79 |
+
const res = await fetch(`/api/leads/${leadId}`, {
|
| 80 |
+
method: 'PATCH',
|
| 81 |
+
headers: { 'Content-Type': 'application/json' },
|
| 82 |
+
body: JSON.stringify({ crm_status: crmStatus }),
|
| 83 |
+
});
|
| 84 |
+
if (!res.ok) throw new Error(await res.text());
|
| 85 |
+
const updated = await res.json();
|
| 86 |
+
setLeads((prev) => prev.map((l) => (l.id === leadId ? { ...l, ...updated } : l)));
|
| 87 |
+
setSelected((s) => (s && s.id === leadId ? { ...s, ...updated } : s));
|
| 88 |
+
} catch (e) {
|
| 89 |
+
console.error(e);
|
| 90 |
+
alert('Could not update status');
|
| 91 |
+
}
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
const moveToContacts = async (leadId) => {
|
| 95 |
+
setMoveBusy(leadId);
|
| 96 |
+
try {
|
| 97 |
+
const res = await fetch(`/api/leads/${leadId}/move-to-contacts`, { method: 'POST' });
|
| 98 |
+
const data = await res.json().catch(() => ({}));
|
| 99 |
+
if (!res.ok) throw new Error(data.detail || res.statusText);
|
| 100 |
+
await fetchLeads();
|
| 101 |
+
setSelected((s) =>
|
| 102 |
+
s && s.id === leadId ? { ...s, contact_id: data.contact_id } : s
|
| 103 |
+
);
|
| 104 |
+
} catch (e) {
|
| 105 |
+
console.error(e);
|
| 106 |
+
alert(e.message || 'Move failed');
|
| 107 |
+
} finally {
|
| 108 |
+
setMoveBusy(null);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const loadThread = async (leadId) => {
|
| 113 |
+
setThreadLoading(true);
|
| 114 |
+
setThreadData(null);
|
| 115 |
+
try {
|
| 116 |
+
const res = await fetch(`/api/leads/${leadId}/smartlead-thread`);
|
| 117 |
+
const data = await res.json();
|
| 118 |
+
if (!res.ok) throw new Error(data.detail || 'Failed to load thread');
|
| 119 |
+
setThreadData(data.history);
|
| 120 |
+
} catch (e) {
|
| 121 |
+
console.error(e);
|
| 122 |
+
alert(e.message || 'Could not load Smartlead thread (check API key)');
|
| 123 |
+
} finally {
|
| 124 |
+
setThreadLoading(false);
|
| 125 |
+
}
|
| 126 |
+
};
|
| 127 |
+
|
| 128 |
+
const openDetail = async (lead) => {
|
| 129 |
+
setSelected(lead);
|
| 130 |
+
setThreadData(null);
|
| 131 |
+
try {
|
| 132 |
+
const res = await fetch(`/api/leads/${lead.id}`);
|
| 133 |
+
if (res.ok) {
|
| 134 |
+
const d = await res.json();
|
| 135 |
+
setSelected(d);
|
| 136 |
+
}
|
| 137 |
+
} catch (e) {
|
| 138 |
+
console.error(e);
|
| 139 |
+
}
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
return (
|
| 143 |
+
<AppShell
|
| 144 |
+
title="Leads"
|
| 145 |
+
subtitle={
|
| 146 |
+
<>
|
| 147 |
+
Replies from Smartlead campaigns appear here. Webhook URL:{' '}
|
| 148 |
+
<code className="text-xs bg-slate-100 px-1.5 py-0.5 rounded">{webhookUrl}</code>
|
| 149 |
+
</>
|
| 150 |
+
}
|
| 151 |
+
>
|
| 152 |
+
<div className="space-y-6">
|
| 153 |
+
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center justify-between">
|
| 154 |
+
<div className="relative flex-1 max-w-md">
|
| 155 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
| 156 |
+
<Input
|
| 157 |
+
className="pl-9"
|
| 158 |
+
placeholder="Search leadsβ¦"
|
| 159 |
+
value={search}
|
| 160 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 161 |
+
/>
|
| 162 |
+
</div>
|
| 163 |
+
<div className="flex gap-2 items-center">
|
| 164 |
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
| 165 |
+
<SelectTrigger className="w-[200px]">
|
| 166 |
+
<span>
|
| 167 |
+
{statusFilter === 'all'
|
| 168 |
+
? 'All statuses'
|
| 169 |
+
: statusMeta(statusFilter).label}
|
| 170 |
+
</span>
|
| 171 |
+
</SelectTrigger>
|
| 172 |
+
<SelectContent>
|
| 173 |
+
<SelectItem value="all">All statuses</SelectItem>
|
| 174 |
+
{CRM_STATUSES.map((s) => (
|
| 175 |
+
<SelectItem key={s.value} value={s.value}>
|
| 176 |
+
<span
|
| 177 |
+
className={cn(
|
| 178 |
+
'rounded-full px-2 py-0.5 text-xs font-medium',
|
| 179 |
+
s.className
|
| 180 |
+
)}
|
| 181 |
+
>
|
| 182 |
+
{s.label}
|
| 183 |
+
</span>
|
| 184 |
+
</SelectItem>
|
| 185 |
+
))}
|
| 186 |
+
</SelectContent>
|
| 187 |
+
</Select>
|
| 188 |
+
<Button variant="outline" size="sm" onClick={() => fetchLeads()}>
|
| 189 |
+
Refresh
|
| 190 |
+
</Button>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div className="border border-slate-200 rounded-xl bg-white shadow-sm overflow-hidden">
|
| 195 |
+
<button
|
| 196 |
+
type="button"
|
| 197 |
+
onClick={() => setSectionOpen(!sectionOpen)}
|
| 198 |
+
className="w-full flex items-center gap-2 px-4 py-3 text-left font-semibold text-slate-800 border-b border-slate-100 hover:bg-slate-50"
|
| 199 |
+
>
|
| 200 |
+
{sectionOpen ? (
|
| 201 |
+
<ChevronDown className="h-5 w-5 text-violet-600" />
|
| 202 |
+
) : (
|
| 203 |
+
<ChevronRight className="h-5 w-5 text-violet-600" />
|
| 204 |
+
)}
|
| 205 |
+
<Inbox className="h-5 w-5 text-slate-500" />
|
| 206 |
+
New Leads
|
| 207 |
+
<span className="text-slate-400 font-normal text-sm ml-2">
|
| 208 |
+
{total} total
|
| 209 |
+
</span>
|
| 210 |
+
</button>
|
| 211 |
+
|
| 212 |
+
{sectionOpen && (
|
| 213 |
+
<div className="overflow-x-auto">
|
| 214 |
+
{loading ? (
|
| 215 |
+
<div className="flex justify-center py-16 text-slate-500">
|
| 216 |
+
<Loader2 className="h-8 w-8 animate-spin" />
|
| 217 |
+
</div>
|
| 218 |
+
) : leads.length === 0 ? (
|
| 219 |
+
<p className="text-center py-16 text-slate-500">
|
| 220 |
+
No leads yet. When a prospect replies in Smartlead, they will show up
|
| 221 |
+
here via webhook.
|
| 222 |
+
</p>
|
| 223 |
+
) : (
|
| 224 |
+
<table className="w-full text-sm">
|
| 225 |
+
<thead>
|
| 226 |
+
<tr className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
| 227 |
+
<th className="px-3 py-2 font-medium w-10" />
|
| 228 |
+
<th className="px-3 py-2 font-medium">Lead</th>
|
| 229 |
+
<th className="px-3 py-2 font-medium">Status</th>
|
| 230 |
+
<th className="px-3 py-2 font-medium">Create a contact</th>
|
| 231 |
+
<th className="px-3 py-2 font-medium">Company</th>
|
| 232 |
+
<th className="px-3 py-2 font-medium">Title</th>
|
| 233 |
+
<th className="px-3 py-2 font-medium">Email</th>
|
| 234 |
+
<th className="px-3 py-2 font-medium">Last reply</th>
|
| 235 |
+
</tr>
|
| 236 |
+
</thead>
|
| 237 |
+
<tbody>
|
| 238 |
+
{leads.map((lead) => {
|
| 239 |
+
const meta = statusMeta(lead.crm_status);
|
| 240 |
+
const displayName =
|
| 241 |
+
[lead.first_name, lead.last_name].filter(Boolean).join(' ') ||
|
| 242 |
+
lead.email;
|
| 243 |
+
const busy = moveBusy === lead.id;
|
| 244 |
+
return (
|
| 245 |
+
<tr
|
| 246 |
+
key={lead.id}
|
| 247 |
+
className={cn(
|
| 248 |
+
'border-b border-slate-100 hover:bg-violet-50/40',
|
| 249 |
+
selected?.id === lead.id && 'bg-violet-50/60'
|
| 250 |
+
)}
|
| 251 |
+
>
|
| 252 |
+
<td className="px-3 py-2">
|
| 253 |
+
<input type="checkbox" className="rounded border-slate-300" />
|
| 254 |
+
</td>
|
| 255 |
+
<td className="px-3 py-2">
|
| 256 |
+
<button
|
| 257 |
+
type="button"
|
| 258 |
+
onClick={() => openDetail(lead)}
|
| 259 |
+
className="text-left text-violet-700 hover:underline font-medium"
|
| 260 |
+
>
|
| 261 |
+
{displayName}
|
| 262 |
+
</button>
|
| 263 |
+
</td>
|
| 264 |
+
<td className="px-3 py-2">
|
| 265 |
+
<Select
|
| 266 |
+
value={
|
| 267 |
+
CRM_STATUSES.some(
|
| 268 |
+
(s) => s.value === lead.crm_status
|
| 269 |
+
)
|
| 270 |
+
? lead.crm_status
|
| 271 |
+
: 'new_lead'
|
| 272 |
+
}
|
| 273 |
+
onValueChange={(v) => updateStatus(lead.id, v)}
|
| 274 |
+
>
|
| 275 |
+
<SelectTrigger
|
| 276 |
+
className={cn(
|
| 277 |
+
'h-9 w-[min(100%,200px)] border-slate-200',
|
| 278 |
+
'shadow-none'
|
| 279 |
+
)}
|
| 280 |
+
>
|
| 281 |
+
<span
|
| 282 |
+
className={cn(
|
| 283 |
+
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
| 284 |
+
meta.className
|
| 285 |
+
)}
|
| 286 |
+
>
|
| 287 |
+
{meta.label}
|
| 288 |
+
</span>
|
| 289 |
+
</SelectTrigger>
|
| 290 |
+
<SelectContent className="min-w-[240px]">
|
| 291 |
+
{CRM_STATUSES.map((s) => (
|
| 292 |
+
<SelectItem
|
| 293 |
+
key={s.value}
|
| 294 |
+
value={s.value}
|
| 295 |
+
className="cursor-pointer"
|
| 296 |
+
>
|
| 297 |
+
<span
|
| 298 |
+
className={cn(
|
| 299 |
+
'rounded-full px-2.5 py-0.5 text-xs font-medium inline-block',
|
| 300 |
+
s.className
|
| 301 |
+
)}
|
| 302 |
+
>
|
| 303 |
+
{s.label}
|
| 304 |
+
</span>
|
| 305 |
+
</SelectItem>
|
| 306 |
+
))}
|
| 307 |
+
</SelectContent>
|
| 308 |
+
</Select>
|
| 309 |
+
</td>
|
| 310 |
+
<td className="px-3 py-2">
|
| 311 |
+
{lead.contact_id ? (
|
| 312 |
+
<a
|
| 313 |
+
href={`/contacts`}
|
| 314 |
+
className="text-xs text-emerald-700 font-medium"
|
| 315 |
+
>
|
| 316 |
+
In Contacts (#{lead.contact_id})
|
| 317 |
+
</a>
|
| 318 |
+
) : (
|
| 319 |
+
<Button
|
| 320 |
+
size="sm"
|
| 321 |
+
className="bg-emerald-600 hover:bg-emerald-700 text-white h-8"
|
| 322 |
+
disabled={busy}
|
| 323 |
+
onClick={() => moveToContacts(lead.id)}
|
| 324 |
+
>
|
| 325 |
+
{busy ? (
|
| 326 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 327 |
+
) : (
|
| 328 |
+
'Move to Contacts'
|
| 329 |
+
)}
|
| 330 |
+
</Button>
|
| 331 |
+
)}
|
| 332 |
+
</td>
|
| 333 |
+
<td className="px-3 py-2 text-slate-700">
|
| 334 |
+
<span className="inline-flex items-center gap-1">
|
| 335 |
+
<Building2 className="h-3.5 w-3.5 text-slate-400" />
|
| 336 |
+
{lead.company_name || 'β'}
|
| 337 |
+
</span>
|
| 338 |
+
</td>
|
| 339 |
+
<td className="px-3 py-2 text-slate-700">
|
| 340 |
+
<span className="inline-flex items-center gap-1">
|
| 341 |
+
<Briefcase className="h-3.5 w-3.5 text-slate-400" />
|
| 342 |
+
{lead.title || 'β'}
|
| 343 |
+
</span>
|
| 344 |
+
</td>
|
| 345 |
+
<td className="px-3 py-2">
|
| 346 |
+
{lead.email ? (
|
| 347 |
+
<a
|
| 348 |
+
href={`mailto:${lead.email}`}
|
| 349 |
+
className="text-violet-600 hover:underline inline-flex items-center gap-1"
|
| 350 |
+
>
|
| 351 |
+
<Mail className="h-3.5 w-3.5" />
|
| 352 |
+
{lead.email}
|
| 353 |
+
</a>
|
| 354 |
+
) : (
|
| 355 |
+
'β'
|
| 356 |
+
)}
|
| 357 |
+
</td>
|
| 358 |
+
<td className="px-3 py-2 text-slate-600 max-w-[200px] truncate" title={lead.last_reply_body}>
|
| 359 |
+
{lead.last_reply_body
|
| 360 |
+
? lead.last_reply_body.slice(0, 80) +
|
| 361 |
+
(lead.last_reply_body.length > 80 ? 'β¦' : '')
|
| 362 |
+
: 'β'}
|
| 363 |
+
</td>
|
| 364 |
+
</tr>
|
| 365 |
+
);
|
| 366 |
+
})}
|
| 367 |
+
</tbody>
|
| 368 |
+
</table>
|
| 369 |
+
)}
|
| 370 |
+
</div>
|
| 371 |
+
)}
|
| 372 |
+
</div>
|
| 373 |
+
|
| 374 |
+
{selected && (
|
| 375 |
+
<div className="fixed inset-0 z-50 flex justify-end bg-black/30" role="dialog">
|
| 376 |
+
<button
|
| 377 |
+
type="button"
|
| 378 |
+
className="flex-1 cursor-default"
|
| 379 |
+
aria-label="Close"
|
| 380 |
+
onClick={() => setSelected(null)}
|
| 381 |
+
/>
|
| 382 |
+
<div className="w-full max-w-lg bg-white shadow-xl h-full overflow-y-auto border-l border-slate-200 p-6">
|
| 383 |
+
<div className="flex justify-between items-start gap-4 mb-6">
|
| 384 |
+
<div>
|
| 385 |
+
<h3 className="text-lg font-bold text-slate-900">Lead detail</h3>
|
| 386 |
+
<p className="text-sm text-slate-500 mt-1">
|
| 387 |
+
Campaign: {selected.campaign_name || selected.campaign_id || 'β'}
|
| 388 |
+
</p>
|
| 389 |
+
</div>
|
| 390 |
+
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
|
| 391 |
+
Close
|
| 392 |
+
</Button>
|
| 393 |
+
</div>
|
| 394 |
+
|
| 395 |
+
<dl className="space-y-3 text-sm">
|
| 396 |
+
<div>
|
| 397 |
+
<dt className="text-slate-500">Name</dt>
|
| 398 |
+
<dd className="font-medium">
|
| 399 |
+
{[selected.first_name, selected.last_name].filter(Boolean).join(' ') ||
|
| 400 |
+
'β'}
|
| 401 |
+
</dd>
|
| 402 |
+
</div>
|
| 403 |
+
<div>
|
| 404 |
+
<dt className="text-slate-500">Email</dt>
|
| 405 |
+
<dd>{selected.email || 'β'}</dd>
|
| 406 |
+
</div>
|
| 407 |
+
<div>
|
| 408 |
+
<dt className="text-slate-500">Company</dt>
|
| 409 |
+
<dd>{selected.company_name || 'β'}</dd>
|
| 410 |
+
</div>
|
| 411 |
+
<div>
|
| 412 |
+
<dt className="text-slate-500">Title</dt>
|
| 413 |
+
<dd>{selected.title || 'β'}</dd>
|
| 414 |
+
</div>
|
| 415 |
+
{selected.contact && (
|
| 416 |
+
<div>
|
| 417 |
+
<dt className="text-slate-500">Linked contact</dt>
|
| 418 |
+
<dd>
|
| 419 |
+
<a href="/contacts" className="text-violet-600 hover:underline">
|
| 420 |
+
View in Contacts
|
| 421 |
+
</a>
|
| 422 |
+
</dd>
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
</dl>
|
| 426 |
+
|
| 427 |
+
<div className="mt-6">
|
| 428 |
+
<div className="flex items-center justify-between mb-2">
|
| 429 |
+
<h4 className="font-semibold text-slate-800">Their reply</h4>
|
| 430 |
+
<Button
|
| 431 |
+
variant="outline"
|
| 432 |
+
size="sm"
|
| 433 |
+
className="gap-1"
|
| 434 |
+
onClick={() => loadThread(selected.id)}
|
| 435 |
+
disabled={threadLoading}
|
| 436 |
+
>
|
| 437 |
+
{threadLoading ? (
|
| 438 |
+
<Loader2 className="h-4 w-4 animate-spin" />
|
| 439 |
+
) : (
|
| 440 |
+
<ExternalLink className="h-4 w-4" />
|
| 441 |
+
)}
|
| 442 |
+
Full thread (Smartlead)
|
| 443 |
+
</Button>
|
| 444 |
+
</div>
|
| 445 |
+
{selected.last_reply_subject && (
|
| 446 |
+
<p className="text-xs text-slate-500 mb-1">
|
| 447 |
+
Subject: {selected.last_reply_subject}
|
| 448 |
+
</p>
|
| 449 |
+
)}
|
| 450 |
+
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-sm whitespace-pre-wrap text-slate-800">
|
| 451 |
+
{selected.last_reply_body || 'β'}
|
| 452 |
+
</div>
|
| 453 |
+
</div>
|
| 454 |
+
|
| 455 |
+
{threadData != null && (
|
| 456 |
+
<div className="mt-4 rounded-lg border border-slate-200 p-3 bg-white overflow-x-auto">
|
| 457 |
+
<pre className="text-xs text-slate-700 whitespace-pre-wrap break-all">
|
| 458 |
+
{typeof threadData === 'string'
|
| 459 |
+
? threadData
|
| 460 |
+
: JSON.stringify(threadData, null, 2)}
|
| 461 |
+
</pre>
|
| 462 |
+
</div>
|
| 463 |
+
)}
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
)}
|
| 467 |
+
</div>
|
| 468 |
+
</AppShell>
|
| 469 |
+
);
|
| 470 |
+
}
|