Seth commited on
Commit ·
e76cac2
1
Parent(s): cdd2ae9
update
Browse files- backend/app/database.py +34 -1
- backend/app/main.py +278 -14
- backend/app/models.py +6 -0
- frontend/src/components/campaigns/CampaignsDashboardTab.jsx +364 -0
- frontend/src/components/campaigns/CreateCampaignWizard.jsx +361 -0
- frontend/src/components/campaigns/LinkedinCampaignsTab.jsx +99 -2
- frontend/src/pages/EmailSequenceGenerator.jsx +7 -2
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "Email",
|
| 227 |
+
"First Name", and "Company".
|
| 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 |
-
|
| 439 |
-
|
| 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/{id}/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 |
-
) :
|
| 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">
|