Seth commited on
Commit
480db82
Β·
1 Parent(s): 05ce0ac
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 UploadResponse, PromptSaveRequest, SequenceResponse, SmartleadPushRequest, SmartleadRunResponse
 
 
 
 
 
 
 
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="/history" element={<RunHistory />} />
 
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: 'Run History', href: '/history' },
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 === item.href;
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 === item.href;
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, History } 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: 'Run History', href: '/history', icon: History },
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 === item.href;
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 === item.href;
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
+ }