Seth commited on
Commit
e76cac2
·
1 Parent(s): cdd2ae9
backend/app/database.py CHANGED
@@ -129,7 +129,14 @@ class Contact(Base):
129
  title = Column(String)
130
  source = Column(String, default="apollo_csv")
131
  raw_data = Column(JSON)
132
- created_at = Column(DateTime, default=datetime.utcnow)
 
 
 
 
 
 
 
133
 
134
 
135
  class CrmLead(Base):
@@ -248,6 +255,7 @@ class LinkedinCampaign(Base):
248
  contact_count = Column(Integer, default=0)
249
  prompt_template = Column(Text, default="")
250
  execution_result = Column(JSON, nullable=True)
 
251
  created_at = Column(DateTime, default=datetime.utcnow)
252
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
253
  executed_at = Column(DateTime, nullable=True)
@@ -399,6 +407,31 @@ def run_migrations(connection_engine):
399
  lccols = [c["name"] for c in insp.get_columns("linkedin_campaigns")]
400
  if "user_id" not in lccols:
401
  conn.execute(text("ALTER TABLE linkedin_campaigns ADD COLUMN user_id INTEGER"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
403
 
404
  # Create tables then migrate legacy SQLite schemas
 
129
  title = Column(String)
130
  source = Column(String, default="apollo_csv")
131
  raw_data = Column(JSON)
132
+ # UniPile LinkedIn outreach (set when a campaign invite/DM is executed)
133
+ unipile_provider_id = Column(String, nullable=True, index=True)
134
+ linkedin_invite_sent_at = Column(DateTime, nullable=True)
135
+ linkedin_invite_pending = Column(Integer, default=0) # 1 = waiting on acceptance webhook
136
+ linkedin_connection_accepted_at = Column(DateTime, nullable=True)
137
+ linkedin_last_followup_sent_at = Column(DateTime, nullable=True)
138
+ linkedin_followup_next_email_number = Column(Integer, nullable=True)
139
+ created_at = Column(DateTime, default=datetime.utcnow())
140
 
141
 
142
  class CrmLead(Base):
 
255
  contact_count = Column(Integer, default=0)
256
  prompt_template = Column(Text, default="")
257
  execution_result = Column(JSON, nullable=True)
258
+ followup_interval_hours = Column(Integer, default=72)
259
  created_at = Column(DateTime, default=datetime.utcnow)
260
  updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
261
  executed_at = Column(DateTime, nullable=True)
 
407
  lccols = [c["name"] for c in insp.get_columns("linkedin_campaigns")]
408
  if "user_id" not in lccols:
409
  conn.execute(text("ALTER TABLE linkedin_campaigns ADD COLUMN user_id INTEGER"))
410
+ if "followup_interval_hours" not in lccols:
411
+ conn.execute(
412
+ text("ALTER TABLE linkedin_campaigns ADD COLUMN followup_interval_hours INTEGER DEFAULT 72")
413
+ )
414
+ conn.execute(
415
+ text(
416
+ "UPDATE linkedin_campaigns SET followup_interval_hours = 72 WHERE followup_interval_hours IS NULL"
417
+ )
418
+ )
419
+
420
+ insp = inspect(connection_engine)
421
+ if insp.has_table("contacts"):
422
+ ctcols = [c["name"] for c in insp.get_columns("contacts")]
423
+ if "unipile_provider_id" not in ctcols:
424
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN unipile_provider_id TEXT"))
425
+ if "linkedin_invite_sent_at" not in ctcols:
426
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_invite_sent_at DATETIME"))
427
+ if "linkedin_invite_pending" not in ctcols:
428
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_invite_pending INTEGER DEFAULT 0"))
429
+ if "linkedin_connection_accepted_at" not in ctcols:
430
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_connection_accepted_at DATETIME"))
431
+ if "linkedin_last_followup_sent_at" not in ctcols:
432
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_last_followup_sent_at DATETIME"))
433
+ if "linkedin_followup_next_email_number" not in ctcols:
434
+ conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_followup_next_email_number INTEGER"))
435
 
436
 
437
  # Create tables then migrate legacy SQLite schemas
backend/app/main.py CHANGED
@@ -60,6 +60,7 @@ from .models import (
60
  LinkedinCampaignCreateRequest,
61
  LinkedinCampaignGenerateRequest,
62
  LinkedinCampaignExecuteRequest,
 
63
  )
64
  from .gmail_invite import send_invite_email_via_gmail
65
  from .gpt_service import generate_email_sequence, generate_linkedin_sequence, enrich_manual_contact_profile
@@ -107,6 +108,7 @@ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
107
  UNIPILE_API_BASE = os.getenv("UNIPILE_API_BASE", "").rstrip("/")
108
  UNIPILE_API_KEY = os.getenv("UNIPILE_API_KEY", "")
109
  FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "").rstrip("/")
 
110
 
111
  # ---- API ----
112
  def _safe_str(value):
@@ -276,6 +278,23 @@ def _linkedin_invite_note(text: str) -> str:
276
  return t[:277].rstrip() + "..."
277
 
278
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  def _public_origin_from_request(request: Request) -> str:
280
  """
281
  Resolve browser-facing origin behind proxies (HF Spaces, ingress).
@@ -1248,6 +1267,73 @@ async def unipile_linkedin_hosted_callback(request: Request, db: Session = Depen
1248
  return {"ok": True}
1249
 
1250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1251
  @app.post("/api/unipile/linkedin/connect")
1252
  async def connect_unipile_linkedin(
1253
  body: UnipileConnectRequest,
@@ -1338,12 +1424,38 @@ async def list_linkedin_campaigns(t: TenantContext = Depends(get_tenant_context)
1338
  "created_at": c.created_at.isoformat() if c.created_at else None,
1339
  "executed_at": c.executed_at.isoformat() if c.executed_at else None,
1340
  "execution_result": c.execution_result,
 
1341
  }
1342
  for c, a in rows
1343
  ]
1344
  }
1345
 
1346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1347
  @app.post("/api/linkedin-campaigns")
1348
  async def create_linkedin_campaign(
1349
  body: LinkedinCampaignCreateRequest,
@@ -1782,6 +1894,11 @@ async def execute_linkedin_campaign(
1782
 
1783
  if st_inv < 400:
1784
  invite_sent += 1
 
 
 
 
 
1785
  attempts.append(
1786
  {
1787
  "contact_id": c.id,
@@ -1805,20 +1922,14 @@ async def execute_linkedin_campaign(
1805
  inv_err = inv_err or json.dumps(inv_res.get("errors"))[:400]
1806
 
1807
  try:
1808
- chat = _unipile_request(
1809
- "POST",
1810
- "/api/v1/chats",
1811
- {"account_id": acc_uid, "attendees": [provider_id]},
1812
- )
1813
- chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
1814
- if not chat_id:
1815
- raise ValueError("UniPile chat id not found")
1816
- _unipile_request(
1817
- "POST",
1818
- f"/api/v1/chats/{requests.utils.quote(chat_id, safe='')}/messages",
1819
- {"text": first_msg.email_content or ""},
1820
- )
1821
  dm_sent += 1
 
 
 
 
 
 
1822
  attempts.append(
1823
  {
1824
  "contact_id": c.id,
@@ -1867,7 +1978,11 @@ async def execute_linkedin_campaign(
1867
  "errors": errors[:200],
1868
  "attempts": attempts[:500],
1869
  "execution_kind": "linkedin_connection_invite",
1870
- "help": "Invites appear under LinkedIn → My Network → Manage invitations → Sent. Dry run performs zero UniPile calls.",
 
 
 
 
1871
  }
1872
  campaign.status = "completed" if failed == 0 else "failed"
1873
  campaign.execution_result = result
@@ -1877,6 +1992,155 @@ async def execute_linkedin_campaign(
1877
  return {"message": "Campaign execution finished", **result}
1878
 
1879
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1880
  @app.get("/api/contact-fields")
1881
  async def contact_fields(t: TenantContext = Depends(get_tenant_context)):
1882
  """Return all available contact field names from uploaded Apollo rows."""
 
60
  LinkedinCampaignCreateRequest,
61
  LinkedinCampaignGenerateRequest,
62
  LinkedinCampaignExecuteRequest,
63
+ LinkedinCampaignPatchRequest,
64
  )
65
  from .gmail_invite import send_invite_email_via_gmail
66
  from .gpt_service import generate_email_sequence, generate_linkedin_sequence, enrich_manual_contact_profile
 
108
  UNIPILE_API_BASE = os.getenv("UNIPILE_API_BASE", "").rstrip("/")
109
  UNIPILE_API_KEY = os.getenv("UNIPILE_API_KEY", "")
110
  FRONTEND_ORIGIN = os.getenv("FRONTEND_ORIGIN", "").rstrip("/")
111
+ UNIPILE_WEBHOOK_SECRET = os.getenv("UNIPILE_WEBHOOK_SECRET", "").strip()
112
 
113
  # ---- API ----
114
  def _safe_str(value):
 
278
  return t[:277].rstrip() + "..."
279
 
280
 
281
+ def _send_unipile_dm(account_id: str, provider_id: str, text: str):
282
+ """Open (or reuse) a 1:1 LinkedIn chat and send a message."""
283
+ chat = _unipile_request(
284
+ "POST",
285
+ "/api/v1/chats",
286
+ {"account_id": account_id, "attendees": [provider_id]},
287
+ )
288
+ chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
289
+ if not chat_id:
290
+ raise ValueError("UniPile chat id not found")
291
+ _unipile_request(
292
+ "POST",
293
+ f"/api/v1/chats/{requests.utils.quote(chat_id, safe='')}/messages",
294
+ {"text": text or ""},
295
+ )
296
+
297
+
298
  def _public_origin_from_request(request: Request) -> str:
299
  """
300
  Resolve browser-facing origin behind proxies (HF Spaces, ingress).
 
1267
  return {"ok": True}
1268
 
1269
 
1270
+ @app.post("/api/webhooks/unipile")
1271
+ async def unipile_users_webhook(request: Request, db: Session = Depends(get_db)):
1272
+ """
1273
+ Receive UniPile USERS webhook events (e.g. new_relation when an invitation is accepted).
1274
+ Register `request_url` in UniPile to this path (POST /api/v1/webhooks, source: users).
1275
+ See: https://developer.unipile.com/docs/detecting-accepted-invitations
1276
+ """
1277
+ if UNIPILE_WEBHOOK_SECRET:
1278
+ auth_h = request.headers.get("Unipile-Auth") or request.headers.get("X-Unipile-Auth") or ""
1279
+ if auth_h != UNIPILE_WEBHOOK_SECRET:
1280
+ raise HTTPException(status_code=401, detail="Invalid webhook secret")
1281
+
1282
+ try:
1283
+ body = await request.json()
1284
+ except Exception:
1285
+ raise HTTPException(status_code=400, detail="Expected JSON body")
1286
+
1287
+ event = _safe_str((body or {}).get("event"))
1288
+ if event != "new_relation":
1289
+ return {"ok": True, "ignored": True, "reason": "unsupported_event"}
1290
+
1291
+ account_id = _safe_str((body or {}).get("account_id"))
1292
+ provider_id = _safe_str((body or {}).get("user_provider_id"))
1293
+ if not account_id or not provider_id:
1294
+ return {"ok": True, "ignored": True, "reason": "missing_ids"}
1295
+
1296
+ ua = (
1297
+ db.query(UnipileAccount)
1298
+ .filter(UnipileAccount.unipile_account_id == account_id)
1299
+ .first()
1300
+ )
1301
+ if not ua:
1302
+ return {"ok": True, "ignored": True, "reason": "unknown_account"}
1303
+
1304
+ now = datetime.utcnow()
1305
+ contacts = (
1306
+ db.query(Contact)
1307
+ .filter(
1308
+ Contact.tenant_id == ua.tenant_id,
1309
+ Contact.unipile_provider_id == provider_id,
1310
+ Contact.source == "linkedin_campaign",
1311
+ )
1312
+ .all()
1313
+ )
1314
+ updated = 0
1315
+ for ct in contacts:
1316
+ if ct.linkedin_connection_accepted_at:
1317
+ continue
1318
+ ct.linkedin_invite_pending = 0
1319
+ ct.linkedin_connection_accepted_at = now
1320
+ updated += 1
1321
+ db.commit()
1322
+ return {"ok": True, "updated_contacts": updated}
1323
+
1324
+
1325
+ @app.get("/api/unipile/webhook-url-hint")
1326
+ async def unipile_webhook_url_hint(request: Request):
1327
+ """Public URL your UniPile webhook should POST to (same host as this API)."""
1328
+ root = _public_origin_from_request(request)
1329
+ return {
1330
+ "post_url": f"{root}/api/webhooks/unipile",
1331
+ "unipile_dashboard": "Create webhook source `users` → event new_relation (default for USERS webhook).",
1332
+ "optional_auth_header": "Unipile-Auth or X-Unipile-Auth matching UNIPILE_WEBHOOK_SECRET",
1333
+ "latency_note": "UniPile may deliver new_relation many hours after acceptance; LinkedIn has no real-time connection API.",
1334
+ }
1335
+
1336
+
1337
  @app.post("/api/unipile/linkedin/connect")
1338
  async def connect_unipile_linkedin(
1339
  body: UnipileConnectRequest,
 
1424
  "created_at": c.created_at.isoformat() if c.created_at else None,
1425
  "executed_at": c.executed_at.isoformat() if c.executed_at else None,
1426
  "execution_result": c.execution_result,
1427
+ "followup_interval_hours": c.followup_interval_hours if c.followup_interval_hours is not None else 72,
1428
  }
1429
  for c, a in rows
1430
  ]
1431
  }
1432
 
1433
 
1434
+ @app.patch("/api/linkedin-campaigns/{campaign_id}")
1435
+ async def patch_linkedin_campaign(
1436
+ campaign_id: int,
1437
+ body: LinkedinCampaignPatchRequest,
1438
+ t: TenantContext = Depends(get_tenant_context),
1439
+ ):
1440
+ db = t.db
1441
+ row = (
1442
+ db.query(LinkedinCampaign)
1443
+ .filter(
1444
+ LinkedinCampaign.tenant_id == t.tenant_id,
1445
+ LinkedinCampaign.user_id == t.user_id,
1446
+ LinkedinCampaign.id == campaign_id,
1447
+ )
1448
+ .first()
1449
+ )
1450
+ if not row:
1451
+ raise HTTPException(status_code=404, detail="Campaign not found")
1452
+ if body.followup_interval_hours is not None:
1453
+ row.followup_interval_hours = body.followup_interval_hours
1454
+ row.updated_at = datetime.utcnow()
1455
+ db.commit()
1456
+ return {"message": "Campaign updated", "followup_interval_hours": row.followup_interval_hours}
1457
+
1458
+
1459
  @app.post("/api/linkedin-campaigns")
1460
  async def create_linkedin_campaign(
1461
  body: LinkedinCampaignCreateRequest,
 
1894
 
1895
  if st_inv < 400:
1896
  invite_sent += 1
1897
+ ts = datetime.utcnow()
1898
+ c.unipile_provider_id = provider_id
1899
+ c.linkedin_invite_pending = 1
1900
+ c.linkedin_invite_sent_at = ts
1901
+ c.linkedin_followup_next_email_number = 2
1902
  attempts.append(
1903
  {
1904
  "contact_id": c.id,
 
1922
  inv_err = inv_err or json.dumps(inv_res.get("errors"))[:400]
1923
 
1924
  try:
1925
+ _send_unipile_dm(acc_uid, provider_id, first_msg.email_content or "")
 
 
 
 
 
 
 
 
 
 
 
 
1926
  dm_sent += 1
1927
+ ts = datetime.utcnow()
1928
+ c.unipile_provider_id = provider_id
1929
+ c.linkedin_invite_pending = 0
1930
+ c.linkedin_connection_accepted_at = ts
1931
+ c.linkedin_last_followup_sent_at = ts
1932
+ c.linkedin_followup_next_email_number = 2
1933
  attempts.append(
1934
  {
1935
  "contact_id": c.id,
 
1978
  "errors": errors[:200],
1979
  "attempts": attempts[:500],
1980
  "execution_kind": "linkedin_connection_invite",
1981
+ "help": (
1982
+ "Invites appear under LinkedIn → My Network → Manage invitations → Sent. "
1983
+ "Follow-up steps are sent via POST /api/linkedin-campaigns/{id}/process-followups after acceptance "
1984
+ "(UniPile users webhook new_relation marks acceptance; can lag hours)."
1985
+ ),
1986
  }
1987
  campaign.status = "completed" if failed == 0 else "failed"
1988
  campaign.execution_result = result
 
1992
  return {"message": "Campaign execution finished", **result}
1993
 
1994
 
1995
+ @app.post("/api/linkedin-campaigns/{campaign_id}/process-followups")
1996
+ async def process_linkedin_campaign_followups(
1997
+ campaign_id: int,
1998
+ t: TenantContext = Depends(get_tenant_context),
1999
+ ):
2000
+ """
2001
+ Deliver generated sequence steps 2+ as LinkedIn DMs when due.
2002
+
2003
+ * Invite flow: waits for `linkedin_connection_accepted_at` (set by UniPile `new_relation` webhook or DM fallback).
2004
+ * First follow-up (step 2) after invite: `accepted_at + followup_interval_hours`.
2005
+ * If execute fell back to DM for step 1: step 2 waits `last_followup_sent_at + interval`.
2006
+
2007
+ Run this endpoint on a schedule (e.g. hourly cron); it only sends when the interval has elapsed.
2008
+ """
2009
+ db = t.db
2010
+ campaign = (
2011
+ db.query(LinkedinCampaign)
2012
+ .filter(
2013
+ LinkedinCampaign.tenant_id == t.tenant_id,
2014
+ LinkedinCampaign.user_id == t.user_id,
2015
+ LinkedinCampaign.id == campaign_id,
2016
+ )
2017
+ .first()
2018
+ )
2019
+ if not campaign:
2020
+ raise HTTPException(status_code=404, detail="Campaign not found")
2021
+ account = (
2022
+ db.query(UnipileAccount)
2023
+ .filter(
2024
+ UnipileAccount.tenant_id == t.tenant_id,
2025
+ UnipileAccount.user_id == t.user_id,
2026
+ UnipileAccount.id == campaign.unipile_account_ref_id,
2027
+ )
2028
+ .first()
2029
+ )
2030
+ if not account:
2031
+ raise HTTPException(status_code=404, detail="Connected LinkedIn account not found")
2032
+ if not campaign.file_id:
2033
+ raise HTTPException(status_code=400, detail="Campaign has no uploaded CSV")
2034
+
2035
+ interval_h = campaign.followup_interval_hours if campaign.followup_interval_hours else 72
2036
+ now = datetime.utcnow()
2037
+ contacts = (
2038
+ db.query(Contact)
2039
+ .filter(
2040
+ Contact.tenant_id == t.tenant_id,
2041
+ Contact.file_id == campaign.file_id,
2042
+ Contact.source == "linkedin_campaign",
2043
+ )
2044
+ .order_by(Contact.row_index.asc(), Contact.id.asc())
2045
+ .all()
2046
+ )
2047
+
2048
+ sent = 0
2049
+ skipped = 0
2050
+ failed = 0
2051
+ details = []
2052
+
2053
+ for c in contacts:
2054
+ n = c.linkedin_followup_next_email_number
2055
+ if not n or not c.unipile_provider_id:
2056
+ skipped += 1
2057
+ continue
2058
+
2059
+ max_step = (
2060
+ db.query(func.max(GeneratedSequence.email_number))
2061
+ .filter(
2062
+ GeneratedSequence.tenant_id == t.tenant_id,
2063
+ GeneratedSequence.file_id == campaign.file_id,
2064
+ GeneratedSequence.channel == "linkedin",
2065
+ GeneratedSequence.sequence_id == c.row_index,
2066
+ )
2067
+ .scalar()
2068
+ ) or 0
2069
+
2070
+ if max_step < 2:
2071
+ skipped += 1
2072
+ continue
2073
+
2074
+ if n > max_step:
2075
+ c.linkedin_followup_next_email_number = None
2076
+ skipped += 1
2077
+ continue
2078
+
2079
+ if c.linkedin_invite_pending and not c.linkedin_connection_accepted_at:
2080
+ skipped += 1
2081
+ continue
2082
+
2083
+ if n == 2:
2084
+ if c.linkedin_last_followup_sent_at is None:
2085
+ if not c.linkedin_connection_accepted_at:
2086
+ skipped += 1
2087
+ continue
2088
+ if now < c.linkedin_connection_accepted_at + timedelta(hours=interval_h):
2089
+ skipped += 1
2090
+ continue
2091
+ else:
2092
+ if now < c.linkedin_last_followup_sent_at + timedelta(hours=interval_h):
2093
+ skipped += 1
2094
+ continue
2095
+ else:
2096
+ if not c.linkedin_last_followup_sent_at:
2097
+ skipped += 1
2098
+ continue
2099
+ if now < c.linkedin_last_followup_sent_at + timedelta(hours=interval_h):
2100
+ skipped += 1
2101
+ continue
2102
+
2103
+ msg_row = (
2104
+ db.query(GeneratedSequence)
2105
+ .filter(
2106
+ GeneratedSequence.tenant_id == t.tenant_id,
2107
+ GeneratedSequence.file_id == campaign.file_id,
2108
+ GeneratedSequence.channel == "linkedin",
2109
+ GeneratedSequence.sequence_id == c.row_index,
2110
+ GeneratedSequence.email_number == n,
2111
+ )
2112
+ .first()
2113
+ )
2114
+ if not msg_row:
2115
+ skipped += 1
2116
+ continue
2117
+
2118
+ try:
2119
+ _send_unipile_dm(
2120
+ account.unipile_account_id,
2121
+ c.unipile_provider_id,
2122
+ msg_row.email_content or "",
2123
+ )
2124
+ c.linkedin_last_followup_sent_at = now
2125
+ c.linkedin_followup_next_email_number = n + 1 if n < max_step else None
2126
+ sent += 1
2127
+ details.append({"contact_id": c.id, "step": n, "status": "sent"})
2128
+ except Exception as e:
2129
+ failed += 1
2130
+ details.append({"contact_id": c.id, "step": n, "status": "failed", "error": str(e)})
2131
+
2132
+ campaign.updated_at = datetime.utcnow()
2133
+ db.commit()
2134
+ return {
2135
+ "message": "Follow-up pass finished",
2136
+ "sent": sent,
2137
+ "skipped": skipped,
2138
+ "failed": failed,
2139
+ "followup_interval_hours": interval_h,
2140
+ "details": details[:200],
2141
+ }
2142
+
2143
+
2144
  @app.get("/api/contact-fields")
2145
  async def contact_fields(t: TenantContext = Depends(get_tenant_context)):
2146
  """Return all available contact field names from uploaded Apollo rows."""
backend/app/models.py CHANGED
@@ -258,3 +258,9 @@ class LinkedinCampaignGenerateRequest(BaseModel):
258
 
259
  class LinkedinCampaignExecuteRequest(BaseModel):
260
  dry_run: bool = False
 
 
 
 
 
 
 
258
 
259
  class LinkedinCampaignExecuteRequest(BaseModel):
260
  dry_run: bool = False
261
+
262
+
263
+ class LinkedinCampaignPatchRequest(BaseModel):
264
+ """Update LinkedIn campaign automation settings."""
265
+
266
+ followup_interval_hours: Optional[int] = Field(None, ge=1, le=720)
frontend/src/components/campaigns/CampaignsDashboardTab.jsx ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Plus,
4
+ MoreVertical,
5
+ Play,
6
+ Pause,
7
+ Pencil,
8
+ Trash2,
9
+ TrendingUp,
10
+ } from 'lucide-react';
11
+ import { Button } from '@/components/ui/button';
12
+ import CreateCampaignWizard from '@/components/campaigns/CreateCampaignWizard';
13
+ import { cn } from '@/lib/utils';
14
+
15
+ const STORAGE_KEY = 'emailout_campaigns_dashboard_v1';
16
+
17
+ function uid() {
18
+ return typeof crypto !== 'undefined' && crypto.randomUUID
19
+ ? crypto.randomUUID()
20
+ : `c_${Date.now()}_${Math.random().toString(16).slice(2)}`;
21
+ }
22
+
23
+ const DEMO_CAMPAIGNS = [
24
+ {
25
+ id: 'demo_1',
26
+ name: 'Q4 Enterprise SaaS Reachout',
27
+ status: 'running',
28
+ contacts: 2450,
29
+ openRate: 58,
30
+ replyRate: 12.4,
31
+ teamExtra: 12,
32
+ lastEditedLabel: null,
33
+ },
34
+ {
35
+ id: 'demo_2',
36
+ name: 'Inbound Demo Follow-up',
37
+ status: 'paused',
38
+ contacts: 840,
39
+ openRate: 42,
40
+ replyRate: 5.1,
41
+ teamExtra: 4,
42
+ lastEditedLabel: null,
43
+ },
44
+ {
45
+ id: 'demo_3',
46
+ name: 'Holiday Special Offer',
47
+ status: 'draft',
48
+ contacts: 5000,
49
+ openRate: null,
50
+ replyRate: null,
51
+ teamExtra: 0,
52
+ lastEditedLabel: 'Last edited 2 hours ago',
53
+ },
54
+ ];
55
+
56
+ function loadCampaigns() {
57
+ try {
58
+ const raw = localStorage.getItem(STORAGE_KEY);
59
+ if (raw) {
60
+ const parsed = JSON.parse(raw);
61
+ if (Array.isArray(parsed) && parsed.length) return parsed;
62
+ }
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ return DEMO_CAMPAIGNS;
67
+ }
68
+
69
+ function saveCampaigns(list) {
70
+ try {
71
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
72
+ } catch {
73
+ /* ignore */
74
+ }
75
+ }
76
+
77
+ function StatusDot({ status }) {
78
+ const map = {
79
+ running: 'bg-emerald-500',
80
+ paused: 'bg-amber-500',
81
+ draft: 'bg-slate-400',
82
+ };
83
+ return <span className={cn('inline-block h-2 w-2 rounded-full', map[status] || map.draft)} />;
84
+ }
85
+
86
+ function MetricBar({ value, colorClass }) {
87
+ const v = Math.min(100, Math.max(0, value));
88
+ return (
89
+ <div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-100">
90
+ <div className={cn('h-full rounded-full transition-all', colorClass)} style={{ width: `${v}%` }} />
91
+ </div>
92
+ );
93
+ }
94
+
95
+ export default function CampaignsDashboardTab() {
96
+ const [campaigns, setCampaigns] = useState(() => loadCampaigns());
97
+ const [wizardOpen, setWizardOpen] = useState(false);
98
+ const [menuOpenId, setMenuOpenId] = useState(null);
99
+
100
+ useEffect(() => {
101
+ saveCampaigns(campaigns);
102
+ }, [campaigns]);
103
+
104
+ const metrics = useMemo(() => {
105
+ const withOpen = campaigns.filter((c) => c.openRate != null);
106
+ const avgOpen =
107
+ withOpen.length > 0
108
+ ? withOpen.reduce((s, c) => s + (c.openRate || 0), 0) / withOpen.length
109
+ : 0;
110
+ const withReply = campaigns.filter((c) => c.replyRate != null);
111
+ const avgReply =
112
+ withReply.length > 0
113
+ ? withReply.reduce((s, c) => s + (c.replyRate || 0), 0) / withReply.length
114
+ : 0;
115
+ const totalReach = campaigns.reduce((s, c) => s + (c.contacts || 0), 0);
116
+ return {
117
+ totalReach,
118
+ avgOpen,
119
+ avgReply,
120
+ };
121
+ }, [campaigns]);
122
+
123
+ const toggleStatus = useCallback((id) => {
124
+ setCampaigns((prev) =>
125
+ prev.map((c) => {
126
+ if (c.id !== id) return c;
127
+ if (c.status === 'running') return { ...c, status: 'paused' };
128
+ if (c.status === 'paused') return { ...c, status: 'running' };
129
+ return c;
130
+ })
131
+ );
132
+ }, []);
133
+
134
+ const deleteCampaign = useCallback((id) => {
135
+ setCampaigns((prev) => prev.filter((c) => c.id !== id));
136
+ setMenuOpenId(null);
137
+ }, []);
138
+
139
+ const onWizardComplete = useCallback((payload) => {
140
+ const contacts = payload.contacts || 0;
141
+ setCampaigns((prev) => [
142
+ {
143
+ id: uid(),
144
+ name: payload.name,
145
+ status: 'running',
146
+ contacts,
147
+ openRate: null,
148
+ replyRate: null,
149
+ teamExtra: 0,
150
+ lastEditedLabel: 'Just now',
151
+ },
152
+ ...prev,
153
+ ]);
154
+ }, []);
155
+
156
+ return (
157
+ <div className="space-y-8">
158
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
159
+ <p className="text-sm text-slate-600 sm:max-w-xl">
160
+ Manage and monitor your automated outreach performance. Open the wizard to name a campaign, upload
161
+ your list, and continue through the next steps as you define them.
162
+ </p>
163
+ <Button
164
+ type="button"
165
+ className="shrink-0 bg-violet-600 text-white hover:bg-violet-700"
166
+ onClick={() => setWizardOpen(true)}
167
+ >
168
+ <Plus className="mr-2 h-4 w-4" />
169
+ Create New Campaign
170
+ </Button>
171
+ </div>
172
+
173
+ {/* Top metrics */}
174
+ <div className="grid gap-4 md:grid-cols-3">
175
+ <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
176
+ <div className="pointer-events-none absolute -right-4 -top-4 opacity-[0.07]">
177
+ <TrendingUp className="h-24 w-24 text-violet-600" />
178
+ </div>
179
+ <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Active reach</p>
180
+ <p className="mt-2 text-3xl font-bold tabular-nums text-violet-600">
181
+ {metrics.totalReach.toLocaleString()}
182
+ </p>
183
+ <span className="mt-2 inline-flex rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700">
184
+ ↑ 14% vs last month
185
+ </span>
186
+ </div>
187
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
188
+ <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Avg. open rate</p>
189
+ <p className="mt-2 text-3xl font-bold tabular-nums text-slate-900">
190
+ {metrics.avgOpen > 0 ? `${metrics.avgOpen.toFixed(1)}%` : '—'}
191
+ </p>
192
+ <MetricBar value={metrics.avgOpen} colorClass="bg-violet-500" />
193
+ </div>
194
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
195
+ <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Response goal</p>
196
+ <p className="mt-2 text-3xl font-bold tabular-nums text-slate-900">
197
+ {metrics.avgReply > 0 ? `${metrics.avgReply.toFixed(1)}%` : '—'}
198
+ </p>
199
+ <MetricBar value={metrics.avgReply} colorClass="bg-amber-700/80" />
200
+ </div>
201
+ </div>
202
+
203
+ {/* Campaign cards */}
204
+ {campaigns.length === 0 ? (
205
+ <div className="rounded-2xl border border-dashed border-slate-200 bg-white/60 py-16 text-center">
206
+ <p className="text-slate-600">No campaigns yet. Create your first campaign to get started.</p>
207
+ <Button
208
+ type="button"
209
+ className="mt-4 bg-violet-600 text-white hover:bg-violet-700"
210
+ onClick={() => setWizardOpen(true)}
211
+ >
212
+ <Plus className="mr-2 h-4 w-4" />
213
+ Create New Campaign
214
+ </Button>
215
+ </div>
216
+ ) : (
217
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
218
+ {campaigns.map((c) => (
219
+ <article
220
+ key={c.id}
221
+ className="relative flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-slate-300 hover:shadow-md"
222
+ >
223
+ <div className="flex items-start justify-between gap-2">
224
+ <div className="flex items-center gap-2">
225
+ <StatusDot status={c.status} />
226
+ <span className="text-[11px] font-semibold uppercase tracking-wide text-slate-600">
227
+ {c.status === 'running'
228
+ ? 'Running'
229
+ : c.status === 'paused'
230
+ ? 'Paused'
231
+ : 'Draft'}
232
+ </span>
233
+ </div>
234
+ <div className="relative">
235
+ <button
236
+ type="button"
237
+ className="rounded-md p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
238
+ aria-label="Campaign menu"
239
+ onClick={() => setMenuOpenId((id) => (id === c.id ? null : c.id))}
240
+ >
241
+ <MoreVertical className="h-4 w-4" />
242
+ </button>
243
+ {menuOpenId === c.id ? (
244
+ <>
245
+ <button
246
+ type="button"
247
+ className="fixed inset-0 z-10 cursor-default"
248
+ aria-label="Close menu"
249
+ onClick={() => setMenuOpenId(null)}
250
+ />
251
+ <div className="absolute right-0 z-20 mt-1 w-40 rounded-lg border border-slate-200 bg-white py-1 text-sm shadow-lg">
252
+ <button
253
+ type="button"
254
+ className="block w-full px-3 py-2 text-left hover:bg-slate-50"
255
+ onClick={() => setMenuOpenId(null)}
256
+ >
257
+ Rename (soon)
258
+ </button>
259
+ <button
260
+ type="button"
261
+ className="block w-full px-3 py-2 text-left text-red-600 hover:bg-red-50"
262
+ onClick={() => deleteCampaign(c.id)}
263
+ >
264
+ Delete
265
+ </button>
266
+ </div>
267
+ </>
268
+ ) : null}
269
+ </div>
270
+ </div>
271
+
272
+ <h3 className="mt-3 text-lg font-semibold text-slate-900">{c.name}</h3>
273
+
274
+ <div className="mt-4 grid grid-cols-3 gap-2 text-center">
275
+ <div>
276
+ <p className="text-[11px] font-medium uppercase text-slate-500">Contacts</p>
277
+ <p className="mt-0.5 font-semibold text-slate-900 tabular-nums">
278
+ {(c.contacts || 0).toLocaleString()}
279
+ </p>
280
+ </div>
281
+ <div>
282
+ <p className="text-[11px] font-medium uppercase text-slate-500">Open rate</p>
283
+ <p className="mt-0.5 font-semibold text-slate-900 tabular-nums">
284
+ {c.openRate != null ? `${c.openRate}%` : '—'}
285
+ </p>
286
+ </div>
287
+ <div>
288
+ <p className="text-[11px] font-medium uppercase text-slate-500">Reply</p>
289
+ <p className="mt-0.5 font-semibold text-slate-900 tabular-nums">
290
+ {c.replyRate != null ? `${c.replyRate}%` : '—'}
291
+ </p>
292
+ </div>
293
+ </div>
294
+
295
+ <div className="mt-5 flex items-center justify-between border-t border-slate-100 pt-4">
296
+ <div className="flex items-center gap-1">
297
+ {c.lastEditedLabel ? (
298
+ <span className="text-xs text-slate-500">{c.lastEditedLabel}</span>
299
+ ) : (
300
+ <>
301
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-violet-100 text-xs font-semibold text-violet-800">
302
+ Y
303
+ </span>
304
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-slate-200 text-xs font-semibold text-slate-700">
305
+ T
306
+ </span>
307
+ {c.teamExtra > 0 ? (
308
+ <span className="ml-1 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">
309
+ +{c.teamExtra}
310
+ </span>
311
+ ) : null}
312
+ </>
313
+ )}
314
+ </div>
315
+ <div className="flex items-center gap-1">
316
+ {c.status === 'running' ? (
317
+ <button
318
+ type="button"
319
+ onClick={() => toggleStatus(c.id)}
320
+ className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
321
+ title="Pause"
322
+ >
323
+ <Pause className="h-4 w-4" />
324
+ </button>
325
+ ) : c.status === 'paused' ? (
326
+ <button
327
+ type="button"
328
+ onClick={() => toggleStatus(c.id)}
329
+ className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
330
+ title="Resume"
331
+ >
332
+ <Play className="h-4 w-4" />
333
+ </button>
334
+ ) : null}
335
+ <button
336
+ type="button"
337
+ className="rounded-lg p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-800"
338
+ title="Edit"
339
+ >
340
+ <Pencil className="h-4 w-4" />
341
+ </button>
342
+ <button
343
+ type="button"
344
+ onClick={() => deleteCampaign(c.id)}
345
+ className="rounded-lg p-2 text-slate-500 hover:bg-red-50 hover:text-red-600"
346
+ title="Delete"
347
+ >
348
+ <Trash2 className="h-4 w-4" />
349
+ </button>
350
+ </div>
351
+ </div>
352
+ </article>
353
+ ))}
354
+ </div>
355
+ )}
356
+
357
+ <CreateCampaignWizard
358
+ open={wizardOpen}
359
+ onOpenChange={setWizardOpen}
360
+ onComplete={onWizardComplete}
361
+ />
362
+ </div>
363
+ );
364
+ }
frontend/src/components/campaigns/CreateCampaignWizard.jsx ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { X, CloudUpload, ArrowRight, ArrowLeft } from 'lucide-react';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { cn } from '@/lib/utils';
7
+
8
+ const STEPS = [
9
+ { id: 1, label: 'Upload & Select' },
10
+ { id: 2, label: 'Configure Sequence' },
11
+ { id: 3, label: 'Generate Contents' },
12
+ { id: 4, label: 'Review & Launch' },
13
+ ];
14
+
15
+ function estimateCsvRows(file) {
16
+ if (!file) return 0;
17
+ return new Promise((resolve) => {
18
+ const reader = new FileReader();
19
+ reader.onload = (e) => {
20
+ const text = String(e.target?.result || '');
21
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
22
+ resolve(Math.max(0, lines.length - 1));
23
+ };
24
+ reader.onerror = () => resolve(0);
25
+ reader.readAsText(file.slice(0, Math.min(file.size, 512 * 1024)));
26
+ });
27
+ }
28
+
29
+ export default function CreateCampaignWizard({ open, onOpenChange, onComplete }) {
30
+ const [step, setStep] = useState(1);
31
+ const [campaignName, setCampaignName] = useState('');
32
+ const [prospectFile, setProspectFile] = useState(null);
33
+ const [dragOver, setDragOver] = useState(false);
34
+ const [estimatedContacts, setEstimatedContacts] = useState(0);
35
+
36
+ const reset = useCallback(() => {
37
+ setStep(1);
38
+ setCampaignName('');
39
+ setProspectFile(null);
40
+ setEstimatedContacts(0);
41
+ setDragOver(false);
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ if (!open) reset();
46
+ }, [open, reset]);
47
+
48
+ useEffect(() => {
49
+ if (!prospectFile) {
50
+ setEstimatedContacts(0);
51
+ return;
52
+ }
53
+ let cancelled = false;
54
+ estimateCsvRows(prospectFile).then((n) => {
55
+ if (!cancelled) setEstimatedContacts(n);
56
+ });
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [prospectFile]);
61
+
62
+ useEffect(() => {
63
+ if (!open) return;
64
+ const onKey = (e) => {
65
+ if (e.key === 'Escape') onOpenChange(false);
66
+ };
67
+ window.addEventListener('keydown', onKey);
68
+ return () => window.removeEventListener('keydown', onKey);
69
+ }, [open, onOpenChange]);
70
+
71
+ const pickFile = (file) => {
72
+ if (!file) return;
73
+ const ok =
74
+ file.name.toLowerCase().endsWith('.csv') || file.name.toLowerCase().endsWith('.xlsx');
75
+ if (!ok) return;
76
+ setProspectFile(file);
77
+ };
78
+
79
+ const canContinueStep1 = campaignName.trim().length > 0 && prospectFile;
80
+
81
+ const handleContinue = () => {
82
+ if (step === 1 && !canContinueStep1) return;
83
+ if (step < 4) setStep((s) => s + 1);
84
+ };
85
+
86
+ const handleBack = () => {
87
+ if (step > 1) setStep((s) => s - 1);
88
+ };
89
+
90
+ const handleLaunch = () => {
91
+ if (!onComplete) {
92
+ onOpenChange(false);
93
+ return;
94
+ }
95
+ onComplete({
96
+ name: campaignName.trim(),
97
+ contacts: estimatedContacts || 0,
98
+ prospectFileName: prospectFile?.name || '',
99
+ });
100
+ onOpenChange(false);
101
+ reset();
102
+ };
103
+
104
+ if (!open) return null;
105
+
106
+ const modal = (
107
+ <div
108
+ className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
109
+ role="dialog"
110
+ aria-modal="true"
111
+ aria-labelledby="create-campaign-title"
112
+ >
113
+ <button
114
+ type="button"
115
+ className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
116
+ aria-label="Close"
117
+ onClick={() => onOpenChange(false)}
118
+ />
119
+ <div className="relative z-[101] flex max-h-[min(92vh,880px)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-violet-200/30">
120
+ <div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
121
+ <h2 id="create-campaign-title" className="text-lg font-semibold text-slate-900">
122
+ Create Campaign
123
+ </h2>
124
+ <button
125
+ type="button"
126
+ onClick={() => onOpenChange(false)}
127
+ className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
128
+ >
129
+ <X className="h-5 w-5" />
130
+ </button>
131
+ </div>
132
+
133
+ {/* Stepper */}
134
+ <div className="border-b border-slate-100 bg-slate-50/80 px-4 py-4 sm:px-6">
135
+ <div className="flex flex-wrap items-start justify-between gap-4">
136
+ {STEPS.map((s) => {
137
+ const active = step === s.id;
138
+ const done = step > s.id;
139
+ return (
140
+ <div key={s.id} className="flex min-w-[120px] flex-1 flex-col items-center gap-2">
141
+ <div
142
+ className={cn(
143
+ 'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
144
+ active &&
145
+ 'bg-violet-600 text-white ring-4 ring-violet-100',
146
+ done && !active && 'bg-violet-100 text-violet-800',
147
+ !active && !done && 'border-2 border-slate-200 bg-white text-slate-400'
148
+ )}
149
+ >
150
+ {s.id}
151
+ </div>
152
+ <span
153
+ className={cn(
154
+ 'text-center text-xs font-medium sm:text-sm',
155
+ active ? 'text-violet-700' : 'text-slate-500'
156
+ )}
157
+ >
158
+ {s.label}
159
+ </span>
160
+ </div>
161
+ );
162
+ })}
163
+ </div>
164
+ </div>
165
+
166
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-8">
167
+ {step === 1 ? (
168
+ <div className="space-y-6">
169
+ <div>
170
+ <span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500">
171
+ Step 1
172
+ </span>
173
+ <p className="mt-2 text-sm text-slate-600">
174
+ Provide a name for your outreach campaign and upload your prospect list to begin.
175
+ </p>
176
+ </div>
177
+ <div>
178
+ <label className="mb-1.5 block text-sm font-medium text-slate-800">
179
+ Campaign Name
180
+ </label>
181
+ <Input
182
+ placeholder="e.g., Q4 Enterprise Tech Outreach"
183
+ value={campaignName}
184
+ onChange={(e) => setCampaignName(e.target.value)}
185
+ className="max-w-lg"
186
+ />
187
+ </div>
188
+ <div>
189
+ <label className="mb-1.5 block text-sm font-medium text-slate-800">
190
+ Prospect List (Apollo CSV)
191
+ </label>
192
+ <div
193
+ className={cn(
194
+ 'relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-4 py-8 transition',
195
+ dragOver
196
+ ? 'border-violet-400 bg-violet-50/50'
197
+ : 'border-slate-200 bg-slate-50/50 hover:border-violet-300'
198
+ )}
199
+ onDragOver={(e) => {
200
+ e.preventDefault();
201
+ setDragOver(true);
202
+ }}
203
+ onDragLeave={() => setDragOver(false)}
204
+ onDrop={(e) => {
205
+ e.preventDefault();
206
+ setDragOver(false);
207
+ const f = e.dataTransfer.files?.[0];
208
+ pickFile(f);
209
+ }}
210
+ onClick={() => document.getElementById('wizard-csv-input')?.click()}
211
+ >
212
+ <input
213
+ id="wizard-csv-input"
214
+ type="file"
215
+ accept=".csv,.xlsx"
216
+ className="hidden"
217
+ onChange={(e) => pickFile(e.target.files?.[0])}
218
+ />
219
+ <div className="flex h-12 w-12 items-center justify-center rounded-full bg-violet-100 text-violet-600">
220
+ <CloudUpload className="h-6 w-6" />
221
+ </div>
222
+ <p className="mt-3 text-center text-sm font-semibold text-slate-800">
223
+ Drag and drop your CSV here
224
+ </p>
225
+ <p className="mt-1 max-w-md text-center text-xs text-slate-500">
226
+ Supported formats: .csv, .xlsx. Ensure your file contains headers for &quot;Email&quot;,
227
+ &quot;First Name&quot;, and &quot;Company&quot;.
228
+ </p>
229
+ <Button
230
+ type="button"
231
+ variant="outline"
232
+ className="mt-4"
233
+ onClick={(e) => {
234
+ e.stopPropagation();
235
+ document.getElementById('wizard-csv-input')?.click();
236
+ }}
237
+ >
238
+ Browse Files
239
+ </Button>
240
+ {prospectFile ? (
241
+ <p className="mt-3 text-xs font-medium text-violet-700">
242
+ {prospectFile.name}
243
+ {estimatedContacts > 0
244
+ ? ` · ~${estimatedContacts.toLocaleString()} rows`
245
+ : ''}
246
+ </p>
247
+ ) : null}
248
+ </div>
249
+ </div>
250
+ <div className="flex flex-col gap-3 rounded-xl border border-violet-100 bg-violet-50/60 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
251
+ <p className="text-sm text-slate-700">
252
+ <span className="font-medium text-violet-900">Direct Apollo Sync.</span>{' '}
253
+ Want to skip the export? Connect your Apollo account in settings for direct lead importing.
254
+ </p>
255
+ <button
256
+ type="button"
257
+ className="shrink-0 text-sm font-semibold text-violet-700 hover:text-violet-900"
258
+ >
259
+ Configure Settings
260
+ </button>
261
+ </div>
262
+ </div>
263
+ ) : step === 2 ? (
264
+ <PlaceholderStep
265
+ title="Configure Sequence"
266
+ body="Define steps, delays, and channels for this campaign. You’ll set this up in the next iteration."
267
+ />
268
+ ) : step === 3 ? (
269
+ <PlaceholderStep
270
+ title="Generate Contents"
271
+ body="AI-generated messages and personalization will live here. Details coming soon."
272
+ />
273
+ ) : (
274
+ <div className="space-y-6">
275
+ <PlaceholderStep
276
+ title="Review & Launch"
277
+ body="Confirm prospect counts and delivery settings before going live."
278
+ />
279
+ <div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm">
280
+ <div className="grid gap-2 sm:grid-cols-2">
281
+ <div>
282
+ <span className="text-slate-500">Campaign</span>
283
+ <p className="font-semibold text-slate-900">{campaignName || '—'}</p>
284
+ </div>
285
+ <div>
286
+ <span className="text-slate-500">Prospects (estimate)</span>
287
+ <p className="font-semibold text-slate-900">
288
+ {estimatedContacts > 0
289
+ ? estimatedContacts.toLocaleString()
290
+ : prospectFile
291
+ ? 'Calculating…'
292
+ : '—'}
293
+ </p>
294
+ </div>
295
+ <div className="sm:col-span-2">
296
+ <span className="text-slate-500">File</span>
297
+ <p className="font-medium text-slate-800">{prospectFile?.name || '—'}</p>
298
+ </div>
299
+ </div>
300
+ </div>
301
+ <div className="flex justify-end">
302
+ <Button
303
+ type="button"
304
+ className="bg-violet-600 text-white hover:bg-violet-700"
305
+ onClick={handleLaunch}
306
+ >
307
+ Launch campaign
308
+ </Button>
309
+ </div>
310
+ </div>
311
+ )}
312
+ </div>
313
+
314
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 bg-white px-5 py-4 sm:px-8">
315
+ <button
316
+ type="button"
317
+ onClick={() => {
318
+ onOpenChange(false);
319
+ reset();
320
+ }}
321
+ className="text-sm font-medium text-slate-600 hover:text-slate-900"
322
+ >
323
+ Cancel
324
+ </button>
325
+ <div className="flex flex-wrap items-center gap-2">
326
+ {step > 1 ? (
327
+ <Button type="button" variant="outline" onClick={handleBack}>
328
+ <ArrowLeft className="mr-2 h-4 w-4" />
329
+ Back
330
+ </Button>
331
+ ) : null}
332
+ {step < 4 ? (
333
+ <Button
334
+ type="button"
335
+ disabled={step === 1 && !canContinueStep1}
336
+ className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40"
337
+ onClick={handleContinue}
338
+ >
339
+ {step === 1
340
+ ? 'Continue to Sequence'
341
+ : `Continue to ${STEPS[step]?.label ?? 'next'}`}
342
+ <ArrowRight className="ml-2 h-4 w-4" />
343
+ </Button>
344
+ ) : null}
345
+ </div>
346
+ </div>
347
+ </div>
348
+ </div>
349
+ );
350
+
351
+ return typeof document !== 'undefined' ? createPortal(modal, document.body) : null;
352
+ }
353
+
354
+ function PlaceholderStep({ title, body }) {
355
+ return (
356
+ <div className="flex min-h-[240px] flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 bg-slate-50/80 px-6 py-12 text-center">
357
+ <p className="text-base font-semibold text-slate-800">{title}</p>
358
+ <p className="mt-2 max-w-md text-sm text-slate-600">{body}</p>
359
+ </div>
360
+ );
361
+ }
frontend/src/components/campaigns/LinkedinCampaignsTab.jsx CHANGED
@@ -23,6 +23,8 @@ export default function LinkedinCampaignsTab() {
23
  );
24
  /** When true, the backend never calls UniPile — use once to validate rows, then turn off to send invites. */
25
  const [dryRun, setDryRun] = useState(false);
 
 
26
 
27
  const selectedCampaign = useMemo(
28
  () => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
@@ -48,6 +50,19 @@ export default function LinkedinCampaignsTab() {
48
  refreshAll().catch((e) => console.error(e));
49
  }, []);
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  useEffect(() => {
52
  if (!selectedCampaignId) {
53
  setSequences([]);
@@ -154,6 +169,45 @@ export default function LinkedinCampaignsTab() {
154
  }
155
  };
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  const executeCampaign = async () => {
158
  if (!selectedCampaignId) return;
159
  setBusy(true);
@@ -435,12 +489,55 @@ export default function LinkedinCampaignsTab() {
435
  </table>
436
  </div>
437
  <div className="mt-2 text-xs text-slate-500">
438
- LinkedIn does not expose “accepted” here in real time; check My Network for new connections. UniPile
439
- may include network distance on the profile response when available.
440
  </div>
441
  </div>
442
  ) : null}
443
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  </div>
445
  );
446
  }
 
23
  );
24
  /** When true, the backend never calls UniPile — use once to validate rows, then turn off to send invites. */
25
  const [dryRun, setDryRun] = useState(false);
26
+ const [webhookPostUrl, setWebhookPostUrl] = useState('');
27
+ const [followupHours, setFollowupHours] = useState(72);
28
 
29
  const selectedCampaign = useMemo(
30
  () => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
 
50
  refreshAll().catch((e) => console.error(e));
51
  }, []);
52
 
53
+ useEffect(() => {
54
+ apiFetch('/api/unipile/webhook-url-hint')
55
+ .then((r) => r.json())
56
+ .then((j) => setWebhookPostUrl(j.post_url || ''))
57
+ .catch(() => {});
58
+ }, []);
59
+
60
+ useEffect(() => {
61
+ if (selectedCampaign?.followup_interval_hours != null) {
62
+ setFollowupHours(selectedCampaign.followup_interval_hours);
63
+ }
64
+ }, [selectedCampaign]);
65
+
66
  useEffect(() => {
67
  if (!selectedCampaignId) {
68
  setSequences([]);
 
169
  }
170
  };
171
 
172
+ const saveFollowupInterval = async () => {
173
+ if (!selectedCampaignId) return;
174
+ setBusy(true);
175
+ try {
176
+ const res = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}`, {
177
+ method: 'PATCH',
178
+ headers: { 'Content-Type': 'application/json' },
179
+ body: JSON.stringify({ followup_interval_hours: Number(followupHours) || 72 }),
180
+ });
181
+ const data = await res.json().catch(() => ({}));
182
+ if (!res.ok) throw new Error(data.detail || 'Could not save interval');
183
+ await refreshAll();
184
+ } catch (e) {
185
+ alert(e.message || 'Save failed');
186
+ } finally {
187
+ setBusy(false);
188
+ }
189
+ };
190
+
191
+ const runFollowups = async () => {
192
+ if (!selectedCampaignId) return;
193
+ setBusy(true);
194
+ try {
195
+ const res = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/process-followups`, {
196
+ method: 'POST',
197
+ });
198
+ const data = await res.json().catch(() => ({}));
199
+ if (!res.ok) throw new Error(data.detail || 'Follow-up run failed');
200
+ await refreshAll();
201
+ alert(
202
+ `Follow-ups: sent ${data.sent ?? 0}, skipped ${data.skipped ?? 0}, failed ${data.failed ?? 0} (interval ${data.followup_interval_hours ?? '?'}h)`
203
+ );
204
+ } catch (e) {
205
+ alert(e.message || 'Follow-up run failed');
206
+ } finally {
207
+ setBusy(false);
208
+ }
209
+ };
210
+
211
  const executeCampaign = async () => {
212
  if (!selectedCampaignId) return;
213
  setBusy(true);
 
489
  </table>
490
  </div>
491
  <div className="mt-2 text-xs text-slate-500">
492
+ Acceptance timing: register the webhook URL below so UniPile can POST when someone becomes a
493
+ connection (often delayed hours). Until then, step 2+ messages stay queued.
494
  </div>
495
  </div>
496
  ) : null}
497
  </div>
498
+
499
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
500
+ <h3 className="mb-3 text-lg font-semibold text-slate-800">6) Follow-up messages</h3>
501
+ <p className="mb-3 text-sm text-slate-600">
502
+ After a connection request is accepted, UniPile can notify this app via the{' '}
503
+ <strong>users / new_relation</strong> webhook (not instant — LinkedIn does not stream acceptances). Step 2+
504
+ of your generated sequence is sent as LinkedIn DMs when you run the processor below, respecting the delay
505
+ between steps.
506
+ </p>
507
+ <div className="mb-3 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700">
508
+ <div className="font-medium text-slate-800">Register this URL in UniPile</div>
509
+ <code className="mt-1 block break-all text-slate-600">{webhookPostUrl || '…loading…'}</code>
510
+ <div className="mt-1 text-slate-500">
511
+ Create a USERS webhook pointing here; optionally set header Unipile-Auth ={' '}
512
+ <code className="rounded bg-white px-1">UNIPILE_WEBHOOK_SECRET</code> on your server.
513
+ </div>
514
+ </div>
515
+ <div className="mb-3 flex flex-wrap items-end gap-3">
516
+ <div>
517
+ <label className="mb-1 block text-xs font-medium text-slate-600">Hours between follow-up steps</label>
518
+ <Input
519
+ type="number"
520
+ min={1}
521
+ max={720}
522
+ className="w-28"
523
+ value={followupHours}
524
+ onChange={(e) => setFollowupHours(Number(e.target.value))}
525
+ />
526
+ </div>
527
+ <Button type="button" variant="outline" onClick={saveFollowupInterval} disabled={!selectedCampaignId || busy}>
528
+ Save interval
529
+ </Button>
530
+ <Button type="button" onClick={runFollowups} disabled={!selectedCampaignId || busy}>
531
+ Send due follow-ups now
532
+ </Button>
533
+ </div>
534
+ <p className="text-xs text-slate-500">
535
+ For production, call{' '}
536
+ <code className="rounded bg-slate-100 px-1">POST /api/linkedin-campaigns/&#123;id&#125;/process-followups</code>{' '}
537
+ on a schedule (e.g. hourly). Invites with a note also create a chat on accept — UniPile documents using the
538
+ new-message webhook as a faster signal in some cases.
539
+ </p>
540
+ </div>
541
  </div>
542
  );
543
  }
frontend/src/pages/EmailSequenceGenerator.jsx CHANGED
@@ -4,6 +4,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
4
  import AppShell from '@/components/layout/AppShell';
5
  import EmailGeneratorTab from '@/components/campaigns/EmailGeneratorTab';
6
  import LinkedinCampaignsTab from '@/components/campaigns/LinkedinCampaignsTab';
 
7
  import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
8
 
9
  export default function EmailSequenceGenerator() {
@@ -23,13 +24,14 @@ export default function EmailSequenceGenerator() {
23
  >
24
  Start Over
25
  </Button>
26
- ) : null
27
  }
28
  >
29
  <Tabs value={activeTab} onValueChange={setActiveTab}>
30
- <TabsList className="mb-6">
31
  <TabsTrigger value="generator">Email/AI Generator</TabsTrigger>
32
  <TabsTrigger value="linkedin">LinkedIn Campaigns (UniPile)</TabsTrigger>
 
33
  </TabsList>
34
  <TabsContent value="generator">
35
  <EmailGeneratorTab />
@@ -37,6 +39,9 @@ export default function EmailSequenceGenerator() {
37
  <TabsContent value="linkedin">
38
  <LinkedinCampaignsTab />
39
  </TabsContent>
 
 
 
40
  </Tabs>
41
 
42
  <footer className="border-t border-slate-100 mt-16">
 
4
  import AppShell from '@/components/layout/AppShell';
5
  import EmailGeneratorTab from '@/components/campaigns/EmailGeneratorTab';
6
  import LinkedinCampaignsTab from '@/components/campaigns/LinkedinCampaignsTab';
7
+ import CampaignsDashboardTab from '@/components/campaigns/CampaignsDashboardTab';
8
  import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
9
 
10
  export default function EmailSequenceGenerator() {
 
24
  >
25
  Start Over
26
  </Button>
27
+ ) : undefined
28
  }
29
  >
30
  <Tabs value={activeTab} onValueChange={setActiveTab}>
31
+ <TabsList className="mb-6 flex-wrap gap-1">
32
  <TabsTrigger value="generator">Email/AI Generator</TabsTrigger>
33
  <TabsTrigger value="linkedin">LinkedIn Campaigns (UniPile)</TabsTrigger>
34
+ <TabsTrigger value="campaigns">Campaigns</TabsTrigger>
35
  </TabsList>
36
  <TabsContent value="generator">
37
  <EmailGeneratorTab />
 
39
  <TabsContent value="linkedin">
40
  <LinkedinCampaignsTab />
41
  </TabsContent>
42
+ <TabsContent value="campaigns">
43
+ <CampaignsDashboardTab />
44
+ </TabsContent>
45
  </Tabs>
46
 
47
  <footer className="border-t border-slate-100 mt-16">