Seth commited on
Commit
84ca4b1
·
1 Parent(s): 713b4f3
backend/app/database.py CHANGED
@@ -219,6 +219,38 @@ class SmartleadRun(Base):
219
  completed_at = Column(DateTime, nullable=True)
220
 
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  def run_migrations(connection_engine):
223
  """Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
224
  from sqlalchemy import inspect, text
@@ -232,6 +264,8 @@ def run_migrations(connection_engine):
232
  "crm_leads",
233
  "crm_deals",
234
  "smartlead_runs",
 
 
235
  )
236
 
237
  with connection_engine.begin() as conn:
 
219
  completed_at = Column(DateTime, nullable=True)
220
 
221
 
222
+ class UnipileAccount(Base):
223
+ __tablename__ = "unipile_accounts"
224
+
225
+ id = Column(Integer, primary_key=True, index=True)
226
+ tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
227
+ label = Column(String, default="")
228
+ provider = Column(String, default="LINKEDIN")
229
+ unipile_account_id = Column(String, index=True)
230
+ status = Column(String, default="pending")
231
+ auth_mode = Column(String, default="hosted")
232
+ metadata_json = Column(JSON, nullable=True)
233
+ created_at = Column(DateTime, default=datetime.utcnow)
234
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
235
+
236
+
237
+ class LinkedinCampaign(Base):
238
+ __tablename__ = "linkedin_campaigns"
239
+
240
+ id = Column(Integer, primary_key=True, index=True)
241
+ tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
242
+ name = Column(String, index=True)
243
+ status = Column(String, default="draft") # draft | generated | running | completed | failed
244
+ unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=False, index=True)
245
+ file_id = Column(String, index=True, nullable=True)
246
+ contact_count = Column(Integer, default=0)
247
+ prompt_template = Column(Text, default="")
248
+ execution_result = Column(JSON, nullable=True)
249
+ created_at = Column(DateTime, default=datetime.utcnow)
250
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
251
+ executed_at = Column(DateTime, nullable=True)
252
+
253
+
254
  def run_migrations(connection_engine):
255
  """Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
256
  from sqlalchemy import inspect, text
 
264
  "crm_leads",
265
  "crm_deals",
266
  "smartlead_runs",
267
+ "unipile_accounts",
268
+ "linkedin_campaigns",
269
  )
270
 
271
  with connection_engine.begin() as conn:
backend/app/main.py CHANGED
@@ -17,6 +17,7 @@ import json
17
  import asyncio
18
  import math
19
  import re
 
20
  from datetime import datetime, timedelta
21
  from calendar import monthrange
22
 
@@ -33,6 +34,8 @@ from .database import (
33
  Contact,
34
  CrmLead,
35
  CrmDeal,
 
 
36
  )
37
  from pydantic import ValidationError
38
 
@@ -50,6 +53,10 @@ from .models import (
50
  ContactCreateRequest,
51
  ContactPatchRequest,
52
  WonBillingPayload,
 
 
 
 
53
  )
54
  from .gmail_invite import send_invite_email_via_gmail
55
  from .gpt_service import generate_email_sequence, generate_linkedin_sequence, enrich_manual_contact_profile
@@ -94,6 +101,8 @@ app.include_router(tenant_router)
94
  # Create uploads directory
95
  UPLOAD_DIR = Path("/data/uploads")
96
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
 
 
97
 
98
  # ---- API ----
99
  def _safe_str(value):
@@ -139,6 +148,63 @@ def _pick_from_raw(raw_data: Dict, aliases: List[str]) -> str:
139
  return ""
140
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  def _contact_value(contact: Contact, field: str):
143
  if field == "first_name":
144
  return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
@@ -914,6 +980,459 @@ async def upload_csv(file: UploadFile = File(...), t: TenantContext = Depends(ge
914
  raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
915
 
916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
917
  @app.get("/api/contact-fields")
918
  async def contact_fields(t: TenantContext = Depends(get_tenant_context)):
919
  """Return all available contact field names from uploaded Apollo rows."""
 
17
  import asyncio
18
  import math
19
  import re
20
+ import requests
21
  from datetime import datetime, timedelta
22
  from calendar import monthrange
23
 
 
34
  Contact,
35
  CrmLead,
36
  CrmDeal,
37
+ UnipileAccount,
38
+ LinkedinCampaign,
39
  )
40
  from pydantic import ValidationError
41
 
 
53
  ContactCreateRequest,
54
  ContactPatchRequest,
55
  WonBillingPayload,
56
+ UnipileConnectRequest,
57
+ LinkedinCampaignCreateRequest,
58
+ LinkedinCampaignGenerateRequest,
59
+ LinkedinCampaignExecuteRequest,
60
  )
61
  from .gmail_invite import send_invite_email_via_gmail
62
  from .gpt_service import generate_email_sequence, generate_linkedin_sequence, enrich_manual_contact_profile
 
101
  # Create uploads directory
102
  UPLOAD_DIR = Path("/data/uploads")
103
  UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
104
+ UNIPILE_API_BASE = os.getenv("UNIPILE_API_BASE", "").rstrip("/")
105
+ UNIPILE_API_KEY = os.getenv("UNIPILE_API_KEY", "")
106
 
107
  # ---- API ----
108
  def _safe_str(value):
 
148
  return ""
149
 
150
 
151
+ def _pick_linkedin_url(raw_data: Dict) -> str:
152
+ return _pick_from_raw(
153
+ raw_data,
154
+ [
155
+ "linkedin",
156
+ "linkedin url",
157
+ "linkedin profile",
158
+ "person linkedin url",
159
+ "linkedin profile url",
160
+ "linkedin_url",
161
+ ],
162
+ )
163
+
164
+
165
+ def _require_unipile_config():
166
+ if not UNIPILE_API_BASE or not UNIPILE_API_KEY:
167
+ raise HTTPException(
168
+ status_code=400,
169
+ detail="UniPile is not configured. Set UNIPILE_API_BASE and UNIPILE_API_KEY in backend env.",
170
+ )
171
+
172
+
173
+ def _unipile_headers():
174
+ return {
175
+ "X-API-KEY": UNIPILE_API_KEY,
176
+ "accept": "application/json",
177
+ "content-type": "application/json",
178
+ }
179
+
180
+
181
+ def _unipile_request(method: str, path: str, payload: Optional[dict] = None):
182
+ _require_unipile_config()
183
+ url = f"{UNIPILE_API_BASE}{path}"
184
+ try:
185
+ resp = requests.request(
186
+ method=method.upper(),
187
+ url=url,
188
+ headers=_unipile_headers(),
189
+ json=payload,
190
+ timeout=45,
191
+ )
192
+ except requests.RequestException as e:
193
+ raise HTTPException(status_code=502, detail=f"UniPile request failed: {str(e)}")
194
+ data = None
195
+ try:
196
+ data = resp.json()
197
+ except Exception:
198
+ data = {"raw": resp.text}
199
+ if resp.status_code >= 400:
200
+ msg = data.get("message") if isinstance(data, dict) else None
201
+ raise HTTPException(
202
+ status_code=resp.status_code,
203
+ detail=msg or f"UniPile error ({resp.status_code})",
204
+ )
205
+ return data
206
+
207
+
208
  def _contact_value(contact: Contact, field: str):
209
  if field == "first_name":
210
  return contact.first_name or _pick_from_raw(contact.raw_data, ["first name", "firstname", "first_name"])
 
980
  raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
981
 
982
 
983
+ @app.get("/api/unipile/linkedin/accounts")
984
+ async def list_unipile_linkedin_accounts(t: TenantContext = Depends(get_tenant_context)):
985
+ rows = (
986
+ t.db.query(UnipileAccount)
987
+ .filter(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.provider == "LINKEDIN")
988
+ .order_by(UnipileAccount.created_at.desc(), UnipileAccount.id.desc())
989
+ .all()
990
+ )
991
+ return {
992
+ "accounts": [
993
+ {
994
+ "id": r.id,
995
+ "label": r.label or "LinkedIn account",
996
+ "provider": r.provider,
997
+ "unipile_account_id": r.unipile_account_id,
998
+ "status": r.status or "unknown",
999
+ "auth_mode": r.auth_mode or "hosted",
1000
+ "created_at": r.created_at.isoformat() if r.created_at else None,
1001
+ }
1002
+ for r in rows
1003
+ ]
1004
+ }
1005
+
1006
+
1007
+ @app.post("/api/unipile/linkedin/connect")
1008
+ async def connect_unipile_linkedin(
1009
+ body: UnipileConnectRequest,
1010
+ t: TenantContext = Depends(get_tenant_context),
1011
+ ):
1012
+ payload = {"provider": "LINKEDIN"}
1013
+ if body.auth_mode == "credentials":
1014
+ if not (body.username and body.password):
1015
+ raise HTTPException(status_code=400, detail="username/password are required for credentials auth")
1016
+ payload["username"] = body.username
1017
+ payload["password"] = body.password
1018
+ else:
1019
+ if not body.access_token:
1020
+ raise HTTPException(status_code=400, detail="access_token is required for cookie auth")
1021
+ payload["access_token"] = body.access_token
1022
+ if body.user_agent:
1023
+ payload["user_agent"] = body.user_agent
1024
+ if body.country:
1025
+ payload["country"] = body.country
1026
+ if body.ip:
1027
+ payload["ip"] = body.ip
1028
+
1029
+ response = _unipile_request("POST", "/api/v1/accounts", payload)
1030
+ account_id = _safe_str(response.get("account_id") if isinstance(response, dict) else "") or _safe_str(
1031
+ response.get("id") if isinstance(response, dict) else ""
1032
+ )
1033
+ if not account_id:
1034
+ # For checkpoint or alt response structures, still persist and expose raw response.
1035
+ account_id = _safe_str((response or {}).get("account", {}).get("id") if isinstance(response, dict) else "")
1036
+ if not account_id:
1037
+ raise HTTPException(status_code=400, detail="UniPile did not return an account_id")
1038
+
1039
+ db_row = UnipileAccount(
1040
+ tenant_id=t.tenant_id,
1041
+ label=(body.label or "LinkedIn account").strip(),
1042
+ provider="LINKEDIN",
1043
+ unipile_account_id=account_id,
1044
+ status=_safe_str(response.get("status") if isinstance(response, dict) else "") or "connected",
1045
+ auth_mode=body.auth_mode,
1046
+ metadata_json=response if isinstance(response, dict) else {"raw": str(response)},
1047
+ )
1048
+ t.db.add(db_row)
1049
+ t.db.commit()
1050
+ t.db.refresh(db_row)
1051
+
1052
+ return {
1053
+ "message": "LinkedIn account connected",
1054
+ "account": {
1055
+ "id": db_row.id,
1056
+ "label": db_row.label,
1057
+ "unipile_account_id": db_row.unipile_account_id,
1058
+ "status": db_row.status,
1059
+ },
1060
+ "unipile_response": response,
1061
+ }
1062
+
1063
+
1064
+ @app.get("/api/linkedin-campaigns")
1065
+ async def list_linkedin_campaigns(t: TenantContext = Depends(get_tenant_context)):
1066
+ rows = (
1067
+ t.db.query(LinkedinCampaign, UnipileAccount)
1068
+ .join(UnipileAccount, UnipileAccount.id == LinkedinCampaign.unipile_account_ref_id)
1069
+ .filter(LinkedinCampaign.tenant_id == t.tenant_id)
1070
+ .order_by(LinkedinCampaign.created_at.desc(), LinkedinCampaign.id.desc())
1071
+ .all()
1072
+ )
1073
+ return {
1074
+ "campaigns": [
1075
+ {
1076
+ "id": c.id,
1077
+ "name": c.name,
1078
+ "status": c.status,
1079
+ "file_id": c.file_id,
1080
+ "contact_count": c.contact_count or 0,
1081
+ "prompt_template": c.prompt_template or "",
1082
+ "unipile_account_ref_id": c.unipile_account_ref_id,
1083
+ "unipile_account_label": a.label if a else "",
1084
+ "created_at": c.created_at.isoformat() if c.created_at else None,
1085
+ "executed_at": c.executed_at.isoformat() if c.executed_at else None,
1086
+ }
1087
+ for c, a in rows
1088
+ ]
1089
+ }
1090
+
1091
+
1092
+ @app.post("/api/linkedin-campaigns")
1093
+ async def create_linkedin_campaign(
1094
+ body: LinkedinCampaignCreateRequest,
1095
+ t: TenantContext = Depends(get_tenant_context),
1096
+ ):
1097
+ account = (
1098
+ t.db.query(UnipileAccount)
1099
+ .filter(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.id == body.unipile_account_ref_id)
1100
+ .first()
1101
+ )
1102
+ if not account:
1103
+ raise HTTPException(status_code=404, detail="LinkedIn account not found")
1104
+ name = (body.name or "").strip()
1105
+ if not name:
1106
+ raise HTTPException(status_code=400, detail="Campaign name is required")
1107
+ row = LinkedinCampaign(
1108
+ tenant_id=t.tenant_id,
1109
+ name=name,
1110
+ status="draft",
1111
+ unipile_account_ref_id=body.unipile_account_ref_id,
1112
+ )
1113
+ t.db.add(row)
1114
+ t.db.commit()
1115
+ t.db.refresh(row)
1116
+ return {"campaign_id": row.id, "message": "Campaign created"}
1117
+
1118
+
1119
+ @app.post("/api/linkedin-campaigns/{campaign_id}/upload-csv")
1120
+ async def upload_linkedin_campaign_csv(
1121
+ campaign_id: int,
1122
+ file: UploadFile = File(...),
1123
+ t: TenantContext = Depends(get_tenant_context),
1124
+ ):
1125
+ db = t.db
1126
+ campaign = (
1127
+ db.query(LinkedinCampaign)
1128
+ .filter(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
1129
+ .first()
1130
+ )
1131
+ if not campaign:
1132
+ raise HTTPException(status_code=404, detail="Campaign not found")
1133
+
1134
+ file_id = str(uuid.uuid4())
1135
+ file_path = UPLOAD_DIR / f"{file_id}.csv"
1136
+ content = await file.read()
1137
+ with open(file_path, "wb") as f:
1138
+ f.write(content)
1139
+ df = pd.read_csv(file_path)
1140
+ contact_count = len(df)
1141
+
1142
+ db_file = UploadedFile(
1143
+ tenant_id=t.tenant_id,
1144
+ file_id=file_id,
1145
+ filename=file.filename,
1146
+ contact_count=contact_count,
1147
+ file_path=str(file_path),
1148
+ )
1149
+ db.add(db_file)
1150
+
1151
+ # Replace prior campaign contacts from old file, if any.
1152
+ if campaign.file_id:
1153
+ db.query(Contact).filter(
1154
+ Contact.tenant_id == t.tenant_id,
1155
+ Contact.file_id == campaign.file_id,
1156
+ Contact.source == "linkedin_campaign",
1157
+ ).delete()
1158
+ db.query(GeneratedSequence).filter(
1159
+ GeneratedSequence.tenant_id == t.tenant_id,
1160
+ GeneratedSequence.file_id == campaign.file_id,
1161
+ GeneratedSequence.channel == "linkedin",
1162
+ GeneratedSequence.product == campaign.name,
1163
+ ).delete()
1164
+
1165
+ for idx, row in df.iterrows():
1166
+ row_dict = row.to_dict()
1167
+ sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
1168
+ contact = Contact(
1169
+ tenant_id=t.tenant_id,
1170
+ file_id=file_id,
1171
+ row_index=idx + 1,
1172
+ first_name=_pick_field(row_dict, ["first name", "firstname", "first_name"]),
1173
+ last_name=_pick_field(row_dict, ["last name", "lastname", "last_name"]),
1174
+ email=_pick_field(row_dict, ["email", "work email", "email address"]),
1175
+ company=_pick_field(row_dict, ["company", "company name", "organization name", "account name"]),
1176
+ title=_pick_field(row_dict, ["title", "job title"]),
1177
+ source="linkedin_campaign",
1178
+ raw_data=sanitized_raw_data,
1179
+ )
1180
+ db.add(contact)
1181
+
1182
+ campaign.file_id = file_id
1183
+ campaign.contact_count = contact_count
1184
+ campaign.status = "draft"
1185
+ campaign.updated_at = datetime.utcnow()
1186
+ db.commit()
1187
+ return {"file_id": file_id, "contact_count": contact_count, "message": "Campaign CSV uploaded"}
1188
+
1189
+
1190
+ @app.post("/api/linkedin-campaigns/{campaign_id}/generate")
1191
+ async def generate_linkedin_campaign_sequences(
1192
+ campaign_id: int,
1193
+ body: LinkedinCampaignGenerateRequest,
1194
+ t: TenantContext = Depends(get_tenant_context),
1195
+ ):
1196
+ db = t.db
1197
+ campaign = (
1198
+ db.query(LinkedinCampaign)
1199
+ .filter(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
1200
+ .first()
1201
+ )
1202
+ if not campaign:
1203
+ raise HTTPException(status_code=404, detail="Campaign not found")
1204
+ if not campaign.file_id:
1205
+ raise HTTPException(status_code=400, detail="Upload a campaign CSV first")
1206
+
1207
+ db_file = (
1208
+ db.query(UploadedFile)
1209
+ .filter(UploadedFile.tenant_id == t.tenant_id, UploadedFile.file_id == campaign.file_id)
1210
+ .first()
1211
+ )
1212
+ if not db_file:
1213
+ raise HTTPException(status_code=404, detail="Campaign file not found")
1214
+
1215
+ df = pd.read_csv(db_file.file_path)
1216
+ db.query(GeneratedSequence).filter(
1217
+ GeneratedSequence.tenant_id == t.tenant_id,
1218
+ GeneratedSequence.file_id == campaign.file_id,
1219
+ GeneratedSequence.channel == "linkedin",
1220
+ ).delete()
1221
+
1222
+ sequence_id = 1
1223
+ generated_rows = 0
1224
+ for _, row in df.iterrows():
1225
+ contact = row.to_dict()
1226
+ li_list = generate_linkedin_sequence(contact, body.prompt_template, campaign.name)
1227
+ for seq_data in li_list:
1228
+ db.add(
1229
+ GeneratedSequence(
1230
+ tenant_id=t.tenant_id,
1231
+ file_id=campaign.file_id,
1232
+ sequence_id=sequence_id,
1233
+ email_number=seq_data["email_number"],
1234
+ channel="linkedin",
1235
+ first_name=seq_data["first_name"],
1236
+ last_name=seq_data["last_name"],
1237
+ email=seq_data["email"],
1238
+ company=seq_data["company"],
1239
+ title=seq_data.get("title", ""),
1240
+ product=campaign.name,
1241
+ subject=seq_data.get("subject") or "",
1242
+ email_content=seq_data["email_content"],
1243
+ )
1244
+ )
1245
+ generated_rows += 1
1246
+ sequence_id += 1
1247
+
1248
+ campaign.prompt_template = body.prompt_template
1249
+ campaign.status = "generated"
1250
+ campaign.updated_at = datetime.utcnow()
1251
+ db.commit()
1252
+ return {"message": "LinkedIn sequences generated", "rows": generated_rows, "contacts": len(df)}
1253
+
1254
+
1255
+ @app.get("/api/linkedin-campaigns/{campaign_id}/sequences")
1256
+ async def get_linkedin_campaign_sequences(campaign_id: int, t: TenantContext = Depends(get_tenant_context)):
1257
+ db = t.db
1258
+ campaign = (
1259
+ db.query(LinkedinCampaign)
1260
+ .filter(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
1261
+ .first()
1262
+ )
1263
+ if not campaign:
1264
+ raise HTTPException(status_code=404, detail="Campaign not found")
1265
+ if not campaign.file_id:
1266
+ return {"sequences": []}
1267
+
1268
+ rows = (
1269
+ db.query(GeneratedSequence)
1270
+ .filter(
1271
+ GeneratedSequence.tenant_id == t.tenant_id,
1272
+ GeneratedSequence.file_id == campaign.file_id,
1273
+ GeneratedSequence.channel == "linkedin",
1274
+ )
1275
+ .order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
1276
+ .all()
1277
+ )
1278
+ return {
1279
+ "sequences": [
1280
+ {
1281
+ "id": r.id,
1282
+ "sequence_id": r.sequence_id,
1283
+ "email_number": r.email_number,
1284
+ "first_name": r.first_name or "",
1285
+ "last_name": r.last_name or "",
1286
+ "email": r.email or "",
1287
+ "company": r.company or "",
1288
+ "title": r.title or "",
1289
+ "subject": r.subject or "",
1290
+ "message": r.email_content or "",
1291
+ }
1292
+ for r in rows
1293
+ ]
1294
+ }
1295
+
1296
+
1297
+ @app.post("/api/linkedin-campaigns/{campaign_id}/execute")
1298
+ async def execute_linkedin_campaign(
1299
+ campaign_id: int,
1300
+ body: LinkedinCampaignExecuteRequest,
1301
+ t: TenantContext = Depends(get_tenant_context),
1302
+ ):
1303
+ """
1304
+ Execute generated LinkedIn campaign via UniPile.
1305
+ This sends the first generated message per contact using UniPile chat-message route.
1306
+ """
1307
+ db = t.db
1308
+ campaign = (
1309
+ db.query(LinkedinCampaign)
1310
+ .filter(LinkedinCampaign.tenant_id == t.tenant_id, LinkedinCampaign.id == campaign_id)
1311
+ .first()
1312
+ )
1313
+ if not campaign:
1314
+ raise HTTPException(status_code=404, detail="Campaign not found")
1315
+ account = (
1316
+ db.query(UnipileAccount)
1317
+ .filter(UnipileAccount.tenant_id == t.tenant_id, UnipileAccount.id == campaign.unipile_account_ref_id)
1318
+ .first()
1319
+ )
1320
+ if not account:
1321
+ raise HTTPException(status_code=404, detail="Connected LinkedIn account not found")
1322
+ if not campaign.file_id:
1323
+ raise HTTPException(status_code=400, detail="Campaign has no uploaded CSV")
1324
+
1325
+ contacts = (
1326
+ db.query(Contact)
1327
+ .filter(
1328
+ Contact.tenant_id == t.tenant_id,
1329
+ Contact.file_id == campaign.file_id,
1330
+ Contact.source == "linkedin_campaign",
1331
+ )
1332
+ .order_by(Contact.row_index.asc(), Contact.id.asc())
1333
+ .all()
1334
+ )
1335
+ seq_rows = (
1336
+ db.query(GeneratedSequence)
1337
+ .filter(
1338
+ GeneratedSequence.tenant_id == t.tenant_id,
1339
+ GeneratedSequence.file_id == campaign.file_id,
1340
+ GeneratedSequence.channel == "linkedin",
1341
+ GeneratedSequence.email_number == 1,
1342
+ )
1343
+ .order_by(GeneratedSequence.sequence_id.asc())
1344
+ .all()
1345
+ )
1346
+ msg_by_seq = {r.sequence_id: r for r in seq_rows}
1347
+
1348
+ sent = 0
1349
+ skipped = 0
1350
+ failed = 0
1351
+ errors = []
1352
+ attempts = []
1353
+
1354
+ for idx, c in enumerate(contacts, start=1):
1355
+ first_msg = msg_by_seq.get(idx)
1356
+ if not first_msg:
1357
+ skipped += 1
1358
+ continue
1359
+ linkedin_url = _pick_linkedin_url(c.raw_data or {})
1360
+ if not linkedin_url:
1361
+ skipped += 1
1362
+ errors.append(
1363
+ {
1364
+ "contact_id": c.id,
1365
+ "reason": "missing_linkedin_url",
1366
+ "message": "No LinkedIn profile URL column found for this contact.",
1367
+ }
1368
+ )
1369
+ continue
1370
+ if body.dry_run:
1371
+ sent += 1
1372
+ attempts.append({"contact_id": c.id, "linkedin_url": linkedin_url, "status": "dry_run"})
1373
+ continue
1374
+ try:
1375
+ # Best-effort execution path:
1376
+ # 1) resolve profile from identifier
1377
+ profile = _unipile_request(
1378
+ "GET",
1379
+ f"/api/v1/users/{requests.utils.quote(linkedin_url, safe='')}",
1380
+ )
1381
+ attendee_id = _safe_str(
1382
+ profile.get("id") if isinstance(profile, dict) else ""
1383
+ ) or _safe_str(profile.get("provider_id") if isinstance(profile, dict) else "")
1384
+ if not attendee_id:
1385
+ raise ValueError("UniPile profile id not found")
1386
+
1387
+ # 2) start or reuse 1:1 chat
1388
+ chat = _unipile_request(
1389
+ "POST",
1390
+ "/api/v1/chats",
1391
+ {
1392
+ "account_id": account.unipile_account_id,
1393
+ "attendees": [attendee_id],
1394
+ },
1395
+ )
1396
+ chat_id = _safe_str(chat.get("id") if isinstance(chat, dict) else "")
1397
+ if not chat_id:
1398
+ raise ValueError("UniPile chat id not found")
1399
+
1400
+ # 3) send first generated LinkedIn message
1401
+ _unipile_request(
1402
+ "POST",
1403
+ f"/api/v1/chats/{chat_id}/messages",
1404
+ {
1405
+ "text": first_msg.email_content or "",
1406
+ },
1407
+ )
1408
+ sent += 1
1409
+ attempts.append({"contact_id": c.id, "linkedin_url": linkedin_url, "status": "sent"})
1410
+ except Exception as e:
1411
+ failed += 1
1412
+ errors.append(
1413
+ {
1414
+ "contact_id": c.id,
1415
+ "reason": "send_failed",
1416
+ "message": str(e),
1417
+ }
1418
+ )
1419
+
1420
+ result = {
1421
+ "sent": sent,
1422
+ "skipped": skipped,
1423
+ "failed": failed,
1424
+ "errors": errors[:100],
1425
+ "attempts": attempts[:100],
1426
+ "dry_run": body.dry_run,
1427
+ }
1428
+ campaign.status = "completed" if failed == 0 else "failed"
1429
+ campaign.execution_result = result
1430
+ campaign.executed_at = datetime.utcnow()
1431
+ campaign.updated_at = datetime.utcnow()
1432
+ db.commit()
1433
+ return {"message": "Campaign execution finished", **result}
1434
+
1435
+
1436
  @app.get("/api/contact-fields")
1437
  async def contact_fields(t: TenantContext = Depends(get_tenant_context)):
1438
  """Return all available contact field names from uploaded Apollo rows."""
backend/app/models.py CHANGED
@@ -230,3 +230,27 @@ class SmartleadRunResponse(BaseModel):
230
  failed: int
231
  errors: List[Dict] = []
232
  status: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  failed: int
231
  errors: List[Dict] = []
232
  status: str
233
+
234
+
235
+ class UnipileConnectRequest(BaseModel):
236
+ label: str = "LinkedIn Account"
237
+ auth_mode: Literal["credentials", "cookie"] = "cookie"
238
+ username: Optional[str] = None
239
+ password: Optional[str] = None
240
+ access_token: Optional[str] = None
241
+ user_agent: Optional[str] = None
242
+ country: Optional[str] = None
243
+ ip: Optional[str] = None
244
+
245
+
246
+ class LinkedinCampaignCreateRequest(BaseModel):
247
+ name: str
248
+ unipile_account_ref_id: int
249
+
250
+
251
+ class LinkedinCampaignGenerateRequest(BaseModel):
252
+ prompt_template: str = Field(..., min_length=20)
253
+
254
+
255
+ class LinkedinCampaignExecuteRequest(BaseModel):
256
+ dry_run: bool = False
frontend/src/components/campaigns/EmailGeneratorTab.jsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useRef } from 'react';
2
+ import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import UploadStep from '@/components/upload/UploadStep';
6
+ import ProductSelector from '@/components/products/ProductSelector';
7
+ import PromptEditor from '@/components/prompts/PromptEditor';
8
+ import SequenceViewer from '@/components/sequences/SequenceViewer';
9
+ import { apiFetch } from '@/lib/api';
10
+ import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
11
+
12
+ export default function EmailGeneratorTab() {
13
+ const {
14
+ step,
15
+ setStep,
16
+ uploadedFile,
17
+ setUploadedFile,
18
+ selectedProducts,
19
+ setSelectedProducts,
20
+ prompts,
21
+ setPrompts,
22
+ linkedinPrompts,
23
+ setLinkedinPrompts,
24
+ includeLinkedin,
25
+ setIncludeLinkedin,
26
+ isGenerating,
27
+ generationComplete,
28
+ beginGeneration,
29
+ } = useGeneratorWorkflow();
30
+
31
+ const generateButtonRef = useRef(null);
32
+
33
+ const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
34
+ const canProceedToStep3 = useMemo(() => {
35
+ const emailOk = selectedProducts.length > 0 && selectedProducts.every((p) => (prompts[p.name] || '').trim());
36
+ if (!emailOk) return false;
37
+ if (!includeLinkedin) return true;
38
+ return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim());
39
+ }, [selectedProducts, prompts, linkedinPrompts, includeLinkedin]);
40
+
41
+ const scrollToGenerateButton = () => {
42
+ if (generateButtonRef.current) {
43
+ generateButtonRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
44
+ }
45
+ };
46
+
47
+ const handleGenerate = async () => {
48
+ if (!uploadedFile?.fileId || !canProceedToStep3) {
49
+ alert('Please complete all steps before generating sequences.');
50
+ return;
51
+ }
52
+ try {
53
+ const res = await apiFetch('/api/save-prompts', {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ file_id: uploadedFile.fileId,
58
+ prompts,
59
+ products: selectedProducts.map((p) => p.name),
60
+ linkedin_prompts: includeLinkedin ? linkedinPrompts : {},
61
+ }),
62
+ });
63
+ if (!res.ok) {
64
+ const err = await res.json().catch(() => ({}));
65
+ throw new Error(err.detail || res.statusText);
66
+ }
67
+ } catch (error) {
68
+ console.error('Error saving prompts:', error);
69
+ alert('Failed to save templates. Please try again.');
70
+ return;
71
+ }
72
+ setStep(3);
73
+ beginGeneration(true);
74
+ };
75
+
76
+ return (
77
+ <div>
78
+ <div className="mb-10">
79
+ <div className="flex items-center justify-center gap-4">
80
+ {[
81
+ { num: 1, label: 'Upload & Select' },
82
+ { num: 2, label: 'Configure Prompts' },
83
+ { num: 3, label: 'Generate & Export' },
84
+ ].map((s, idx) => (
85
+ <React.Fragment key={s.num}>
86
+ <div className="flex items-center gap-2">
87
+ <div
88
+ className={`h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300 ${
89
+ step >= s.num
90
+ ? 'bg-violet-600 text-white shadow-lg shadow-violet-200'
91
+ : 'bg-slate-100 text-slate-400'
92
+ }`}
93
+ >
94
+ {s.num}
95
+ </div>
96
+ <span
97
+ className={`text-sm font-medium hidden sm:block ${
98
+ step >= s.num ? 'text-slate-800' : 'text-slate-400'
99
+ }`}
100
+ >
101
+ {s.label}
102
+ </span>
103
+ </div>
104
+ {idx < 2 && (
105
+ <div
106
+ className={`h-0.5 w-12 rounded-full transition-colors duration-300 ${
107
+ step > s.num ? 'bg-violet-600' : 'bg-slate-200'
108
+ }`}
109
+ />
110
+ )}
111
+ </React.Fragment>
112
+ ))}
113
+ </div>
114
+ </div>
115
+
116
+ <AnimatePresence mode="wait">
117
+ {step === 1 && (
118
+ <motion.div
119
+ key="step1"
120
+ initial={{ opacity: 0, x: -20 }}
121
+ animate={{ opacity: 1, x: 0 }}
122
+ exit={{ opacity: 0, x: 20 }}
123
+ transition={{ duration: 0.3 }}
124
+ className="space-y-8"
125
+ >
126
+ <div className="text-center mb-8">
127
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">Upload Your Contacts</h2>
128
+ <p className="text-slate-500">
129
+ Import your Apollo CSV and select products for your campaign
130
+ </p>
131
+ </div>
132
+ <UploadStep
133
+ onFileUploaded={setUploadedFile}
134
+ uploadedFile={uploadedFile}
135
+ onRemoveFile={() => setUploadedFile(null)}
136
+ />
137
+ {uploadedFile && (
138
+ <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
139
+ <ProductSelector
140
+ selectedProducts={selectedProducts}
141
+ onProductsChange={setSelectedProducts}
142
+ />
143
+ </motion.div>
144
+ )}
145
+ <div className="flex justify-end pt-4">
146
+ <Button
147
+ onClick={() => setStep(2)}
148
+ disabled={!canProceedToStep2}
149
+ className="bg-violet-600 hover:bg-violet-700 px-6"
150
+ >
151
+ Continue to Prompts
152
+ <ArrowRight className="h-4 w-4 ml-2" />
153
+ </Button>
154
+ </div>
155
+ </motion.div>
156
+ )}
157
+
158
+ {step === 2 && (
159
+ <motion.div
160
+ key="step2"
161
+ initial={{ opacity: 0, x: -20 }}
162
+ animate={{ opacity: 1, x: 0 }}
163
+ exit={{ opacity: 0, x: 20 }}
164
+ transition={{ duration: 0.3 }}
165
+ className="space-y-8"
166
+ >
167
+ <div className="text-center mb-8">
168
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">Customize Your Prompts</h2>
169
+ <p className="text-slate-500">Edit templates before generation.</p>
170
+ </div>
171
+ <PromptEditor
172
+ selectedProducts={selectedProducts}
173
+ prompts={prompts}
174
+ onPromptsChange={setPrompts}
175
+ onSaveComplete={scrollToGenerateButton}
176
+ variant="email"
177
+ />
178
+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
179
+ <input
180
+ type="checkbox"
181
+ className="mt-1 h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
182
+ checked={includeLinkedin}
183
+ onChange={(e) => setIncludeLinkedin(e.target.checked)}
184
+ />
185
+ <span>
186
+ <span className="font-medium text-slate-800">
187
+ Also generate LinkedIn sequences for the same contacts
188
+ </span>
189
+ </span>
190
+ </label>
191
+ {includeLinkedin ? (
192
+ <div className="space-y-2">
193
+ <h3 className="text-lg font-semibold text-slate-800">LinkedIn prompts</h3>
194
+ <PromptEditor
195
+ selectedProducts={selectedProducts}
196
+ prompts={linkedinPrompts}
197
+ onPromptsChange={setLinkedinPrompts}
198
+ onSaveComplete={scrollToGenerateButton}
199
+ variant="linkedin"
200
+ />
201
+ </div>
202
+ ) : null}
203
+ <div className="flex justify-between pt-4">
204
+ <Button variant="outline" onClick={() => setStep(1)} className="px-6">
205
+ <ArrowLeft className="h-4 w-4 mr-2" />
206
+ Back
207
+ </Button>
208
+ <Button
209
+ ref={generateButtonRef}
210
+ onClick={handleGenerate}
211
+ disabled={!canProceedToStep3}
212
+ className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700 hover:to-purple-700 px-8 shadow-lg shadow-violet-200"
213
+ >
214
+ <Sparkles className="h-4 w-4 mr-2" />
215
+ Generate Sequences
216
+ </Button>
217
+ </div>
218
+ </motion.div>
219
+ )}
220
+
221
+ {step === 3 && (
222
+ <motion.div
223
+ key="step3"
224
+ initial={{ opacity: 0, x: -20 }}
225
+ animate={{ opacity: 1, x: 0 }}
226
+ exit={{ opacity: 0, x: 20 }}
227
+ transition={{ duration: 0.3 }}
228
+ className="space-y-8"
229
+ >
230
+ <div className="text-center mb-8">
231
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
232
+ {generationComplete ? 'Your Sequences Are Ready!' : 'Generating…'}
233
+ </h2>
234
+ <p className="text-slate-500">
235
+ {generationComplete
236
+ ? 'Review and export below.'
237
+ : 'You can navigate away; generation continues in background.'}
238
+ </p>
239
+ </div>
240
+ <SequenceViewer />
241
+ {!isGenerating && (
242
+ <div className="flex justify-start pt-4">
243
+ <Button variant="outline" onClick={() => setStep(2)} className="px-6">
244
+ <ArrowLeft className="h-4 w-4 mr-2" />
245
+ Edit Templates
246
+ </Button>
247
+ </div>
248
+ )}
249
+ </motion.div>
250
+ )}
251
+ </AnimatePresence>
252
+ </div>
253
+ );
254
+ }
frontend/src/components/campaigns/LinkedinCampaignsTab.jsx ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Link2, Play, Upload, Sparkles, PlusCircle, RefreshCw } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { apiFetch } from '@/lib/api';
6
+
7
+ export default function LinkedinCampaignsTab() {
8
+ const [accounts, setAccounts] = useState([]);
9
+ const [campaigns, setCampaigns] = useState([]);
10
+ const [selectedCampaignId, setSelectedCampaignId] = useState('');
11
+ const [sequences, setSequences] = useState([]);
12
+ const [busy, setBusy] = useState(false);
13
+
14
+ const [connectForm, setConnectForm] = useState({
15
+ label: 'LinkedIn Account',
16
+ auth_mode: 'cookie',
17
+ access_token: '',
18
+ user_agent: '',
19
+ username: '',
20
+ password: '',
21
+ });
22
+ const [createForm, setCreateForm] = useState({
23
+ name: '',
24
+ unipile_account_ref_id: '',
25
+ });
26
+ const [promptTemplate, setPromptTemplate] = useState(
27
+ 'Write a concise LinkedIn message sequence (3 steps) personalized using first name, company, and title. Keep each message under 500 characters, human and non-spammy.'
28
+ );
29
+ const [dryRun, setDryRun] = useState(true);
30
+
31
+ const selectedCampaign = useMemo(
32
+ () => campaigns.find((c) => String(c.id) === String(selectedCampaignId)) || null,
33
+ [campaigns, selectedCampaignId]
34
+ );
35
+
36
+ const refreshAll = async () => {
37
+ const [accRes, cmpRes] = await Promise.all([
38
+ apiFetch('/api/unipile/linkedin/accounts'),
39
+ apiFetch('/api/linkedin-campaigns'),
40
+ ]);
41
+ const accJson = await accRes.json().catch(() => ({ accounts: [] }));
42
+ const cmpJson = await cmpRes.json().catch(() => ({ campaigns: [] }));
43
+ if (accRes.ok) setAccounts(accJson.accounts || []);
44
+ if (cmpRes.ok) setCampaigns(cmpJson.campaigns || []);
45
+ };
46
+
47
+ useEffect(() => {
48
+ refreshAll().catch((e) => console.error(e));
49
+ }, []);
50
+
51
+ useEffect(() => {
52
+ if (!selectedCampaignId) {
53
+ setSequences([]);
54
+ return;
55
+ }
56
+ apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/sequences`)
57
+ .then((r) => r.json())
58
+ .then((j) => setSequences(j.sequences || []))
59
+ .catch(() => setSequences([]));
60
+ }, [selectedCampaignId]);
61
+
62
+ const connectAccount = async () => {
63
+ setBusy(true);
64
+ try {
65
+ const payload =
66
+ connectForm.auth_mode === 'cookie'
67
+ ? {
68
+ label: connectForm.label,
69
+ auth_mode: 'cookie',
70
+ access_token: connectForm.access_token,
71
+ user_agent: connectForm.user_agent,
72
+ }
73
+ : {
74
+ label: connectForm.label,
75
+ auth_mode: 'credentials',
76
+ username: connectForm.username,
77
+ password: connectForm.password,
78
+ };
79
+ const res = await apiFetch('/api/unipile/linkedin/connect', {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/json' },
82
+ body: JSON.stringify(payload),
83
+ });
84
+ const data = await res.json().catch(() => ({}));
85
+ if (!res.ok) throw new Error(data.detail || 'Could not connect LinkedIn account');
86
+ await refreshAll();
87
+ alert('LinkedIn account connected.');
88
+ } catch (e) {
89
+ alert(e.message || 'Could not connect account');
90
+ } finally {
91
+ setBusy(false);
92
+ }
93
+ };
94
+
95
+ const createCampaign = async () => {
96
+ if (!createForm.name.trim() || !createForm.unipile_account_ref_id) return;
97
+ setBusy(true);
98
+ try {
99
+ const res = await apiFetch('/api/linkedin-campaigns', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({
103
+ name: createForm.name.trim(),
104
+ unipile_account_ref_id: Number(createForm.unipile_account_ref_id),
105
+ }),
106
+ });
107
+ const data = await res.json().catch(() => ({}));
108
+ if (!res.ok) throw new Error(data.detail || 'Could not create campaign');
109
+ setCreateForm((s) => ({ ...s, name: '' }));
110
+ await refreshAll();
111
+ setSelectedCampaignId(String(data.campaign_id));
112
+ } catch (e) {
113
+ alert(e.message || 'Could not create campaign');
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ };
118
+
119
+ const uploadCampaignCsv = async (file) => {
120
+ if (!selectedCampaignId) {
121
+ alert('Select a campaign first.');
122
+ return;
123
+ }
124
+ const fd = new FormData();
125
+ fd.append('file', file);
126
+ setBusy(true);
127
+ try {
128
+ const res = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/upload-csv`, {
129
+ method: 'POST',
130
+ body: fd,
131
+ });
132
+ const data = await res.json().catch(() => ({}));
133
+ if (!res.ok) throw new Error(data.detail || 'CSV upload failed');
134
+ await refreshAll();
135
+ alert(`Uploaded ${data.contact_count || 0} contacts.`);
136
+ } catch (e) {
137
+ alert(e.message || 'CSV upload failed');
138
+ } finally {
139
+ setBusy(false);
140
+ }
141
+ };
142
+
143
+ const generateMessages = async () => {
144
+ if (!selectedCampaignId) return;
145
+ setBusy(true);
146
+ try {
147
+ const res = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/generate`, {
148
+ method: 'POST',
149
+ headers: { 'Content-Type': 'application/json' },
150
+ body: JSON.stringify({ prompt_template: promptTemplate }),
151
+ });
152
+ const data = await res.json().catch(() => ({}));
153
+ if (!res.ok) throw new Error(data.detail || 'Generation failed');
154
+ const seqRes = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/sequences`);
155
+ const seqData = await seqRes.json().catch(() => ({ sequences: [] }));
156
+ setSequences(seqData.sequences || []);
157
+ await refreshAll();
158
+ alert(`Generated ${data.rows || 0} rows.`);
159
+ } catch (e) {
160
+ alert(e.message || 'Generation failed');
161
+ } finally {
162
+ setBusy(false);
163
+ }
164
+ };
165
+
166
+ const executeCampaign = async () => {
167
+ if (!selectedCampaignId) return;
168
+ setBusy(true);
169
+ try {
170
+ const res = await apiFetch(`/api/linkedin-campaigns/${selectedCampaignId}/execute`, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({ dry_run: dryRun }),
174
+ });
175
+ const data = await res.json().catch(() => ({}));
176
+ if (!res.ok) throw new Error(data.detail || 'Execution failed');
177
+ await refreshAll();
178
+ alert(`Execution finished: sent=${data.sent}, skipped=${data.skipped}, failed=${data.failed}`);
179
+ } catch (e) {
180
+ alert(e.message || 'Execution failed');
181
+ } finally {
182
+ setBusy(false);
183
+ }
184
+ };
185
+
186
+ return (
187
+ <div className="space-y-6">
188
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
189
+ <div className="mb-3 flex items-center justify-between">
190
+ <h3 className="text-lg font-semibold text-slate-800">1) Connect LinkedIn (UniPile)</h3>
191
+ <Button variant="outline" size="sm" onClick={refreshAll}>
192
+ <RefreshCw className="mr-2 h-4 w-4" />
193
+ Refresh
194
+ </Button>
195
+ </div>
196
+ <div className="grid gap-3 md:grid-cols-2">
197
+ <Input
198
+ placeholder="Account label"
199
+ value={connectForm.label}
200
+ onChange={(e) => setConnectForm((s) => ({ ...s, label: e.target.value }))}
201
+ />
202
+ <select
203
+ className="h-10 rounded-md border border-slate-200 px-3 text-sm"
204
+ value={connectForm.auth_mode}
205
+ onChange={(e) => setConnectForm((s) => ({ ...s, auth_mode: e.target.value }))}
206
+ >
207
+ <option value="cookie">Cookie (li_at)</option>
208
+ <option value="credentials">Username / Password</option>
209
+ </select>
210
+ {connectForm.auth_mode === 'cookie' ? (
211
+ <>
212
+ <Input
213
+ placeholder="li_at access token"
214
+ value={connectForm.access_token}
215
+ onChange={(e) => setConnectForm((s) => ({ ...s, access_token: e.target.value }))}
216
+ />
217
+ <Input
218
+ placeholder="User-Agent (recommended)"
219
+ value={connectForm.user_agent}
220
+ onChange={(e) => setConnectForm((s) => ({ ...s, user_agent: e.target.value }))}
221
+ />
222
+ </>
223
+ ) : (
224
+ <>
225
+ <Input
226
+ placeholder="LinkedIn username/email"
227
+ value={connectForm.username}
228
+ onChange={(e) => setConnectForm((s) => ({ ...s, username: e.target.value }))}
229
+ />
230
+ <Input
231
+ type="password"
232
+ placeholder="LinkedIn password"
233
+ value={connectForm.password}
234
+ onChange={(e) => setConnectForm((s) => ({ ...s, password: e.target.value }))}
235
+ />
236
+ </>
237
+ )}
238
+ </div>
239
+ <div className="mt-4">
240
+ <Button onClick={connectAccount} disabled={busy}>
241
+ <Link2 className="mr-2 h-4 w-4" />
242
+ Connect Account
243
+ </Button>
244
+ </div>
245
+ <div className="mt-3 text-sm text-slate-600">
246
+ Connected accounts: {accounts.length}
247
+ </div>
248
+ </div>
249
+
250
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
251
+ <h3 className="mb-3 text-lg font-semibold text-slate-800">2) Create New LinkedIn Campaign</h3>
252
+ <div className="grid gap-3 md:grid-cols-3">
253
+ <Input
254
+ placeholder="Campaign name"
255
+ value={createForm.name}
256
+ onChange={(e) => setCreateForm((s) => ({ ...s, name: e.target.value }))}
257
+ />
258
+ <select
259
+ className="h-10 rounded-md border border-slate-200 px-3 text-sm"
260
+ value={createForm.unipile_account_ref_id}
261
+ onChange={(e) =>
262
+ setCreateForm((s) => ({ ...s, unipile_account_ref_id: e.target.value }))
263
+ }
264
+ >
265
+ <option value="">Select LinkedIn account</option>
266
+ {accounts.map((a) => (
267
+ <option key={a.id} value={a.id}>
268
+ {a.label} ({a.status || 'unknown'})
269
+ </option>
270
+ ))}
271
+ </select>
272
+ <Button onClick={createCampaign} disabled={busy}>
273
+ <PlusCircle className="mr-2 h-4 w-4" />
274
+ Create New
275
+ </Button>
276
+ </div>
277
+ <div className="mt-4">
278
+ <select
279
+ className="h-10 w-full rounded-md border border-slate-200 px-3 text-sm"
280
+ value={selectedCampaignId}
281
+ onChange={(e) => setSelectedCampaignId(e.target.value)}
282
+ >
283
+ <option value="">Select campaign</option>
284
+ {campaigns.map((c) => (
285
+ <option key={c.id} value={c.id}>
286
+ {c.name} · {c.status} · {c.contact_count || 0} contacts
287
+ </option>
288
+ ))}
289
+ </select>
290
+ </div>
291
+ </div>
292
+
293
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
294
+ <h3 className="mb-3 text-lg font-semibold text-slate-800">3) Upload Apollo CSV</h3>
295
+ <div className="flex items-center gap-3">
296
+ <Input
297
+ type="file"
298
+ accept=".csv"
299
+ onChange={(e) => {
300
+ const f = e.target.files?.[0];
301
+ if (f) uploadCampaignCsv(f);
302
+ }}
303
+ />
304
+ <Button variant="outline" disabled={!selectedCampaignId || busy}>
305
+ <Upload className="mr-2 h-4 w-4" />
306
+ Campaign CSV
307
+ </Button>
308
+ </div>
309
+ {selectedCampaign ? (
310
+ <p className="mt-2 text-sm text-slate-600">
311
+ Active campaign: <strong>{selectedCampaign.name}</strong> · {selectedCampaign.contact_count || 0}{' '}
312
+ contacts
313
+ </p>
314
+ ) : null}
315
+ </div>
316
+
317
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
318
+ <h3 className="mb-3 text-lg font-semibold text-slate-800">4) Generate LinkedIn Sequences</h3>
319
+ <textarea
320
+ value={promptTemplate}
321
+ onChange={(e) => setPromptTemplate(e.target.value)}
322
+ className="min-h-[140px] w-full rounded-md border border-slate-200 px-3 py-2 text-sm"
323
+ />
324
+ <div className="mt-3">
325
+ <Button onClick={generateMessages} disabled={!selectedCampaignId || busy}>
326
+ <Sparkles className="mr-2 h-4 w-4" />
327
+ Generate LinkedIn Messages
328
+ </Button>
329
+ </div>
330
+ {sequences.length > 0 ? (
331
+ <div className="mt-4 rounded-md border border-slate-200 p-3 text-sm">
332
+ <p className="mb-2 font-medium text-slate-700">
333
+ Generated rows: {sequences.length}
334
+ </p>
335
+ <div className="max-h-56 space-y-2 overflow-auto">
336
+ {sequences.slice(0, 12).map((s) => (
337
+ <div key={s.id} className="rounded border border-slate-100 bg-slate-50 p-2">
338
+ <div className="font-medium">
339
+ {s.first_name} {s.last_name}
340
+ </div>
341
+ <div className="line-clamp-2 text-slate-600">{s.message}</div>
342
+ </div>
343
+ ))}
344
+ </div>
345
+ </div>
346
+ ) : null}
347
+ </div>
348
+
349
+ <div className="rounded-2xl border border-slate-200 bg-white p-5">
350
+ <h3 className="mb-3 text-lg font-semibold text-slate-800">5) Execute Campaign via UniPile API</h3>
351
+ <label className="mb-3 flex items-center gap-2 text-sm text-slate-700">
352
+ <input
353
+ type="checkbox"
354
+ checked={dryRun}
355
+ onChange={(e) => setDryRun(e.target.checked)}
356
+ />
357
+ Dry run (recommended first)
358
+ </label>
359
+ <Button onClick={executeCampaign} disabled={!selectedCampaignId || busy}>
360
+ <Play className="mr-2 h-4 w-4" />
361
+ Execute Campaign
362
+ </Button>
363
+ </div>
364
+ </div>
365
+ );
366
+ }
frontend/src/components/layout/AppHeader.jsx CHANGED
@@ -4,7 +4,7 @@ import { Zap } from 'lucide-react';
4
  import { Button } from "@/components/ui/button";
5
 
6
  const MENU_ITEMS = [
7
- { label: 'Generator', href: '/' },
8
  { label: 'Contacts', href: '/contacts' },
9
  { label: 'Leads', href: '/leads' },
10
  { label: 'Deals', href: '/deals' },
 
4
  import { Button } from "@/components/ui/button";
5
 
6
  const MENU_ITEMS = [
7
+ { label: 'Campaigns', href: '/' },
8
  { label: 'Contacts', href: '/contacts' },
9
  { label: 'Leads', href: '/leads' },
10
  { label: 'Deals', href: '/deals' },
frontend/src/components/layout/AppShell.jsx CHANGED
@@ -15,7 +15,7 @@ import { cn } from '@/lib/utils';
15
  import GoogleAuthBar from '@/components/layout/GoogleAuthBar';
16
 
17
  const NAV_ITEMS = [
18
- { label: 'Generator', href: '/', icon: LayoutDashboard },
19
  { label: 'Contacts', href: '/contacts', icon: Users },
20
  { label: 'Leads', href: '/leads', icon: Inbox },
21
  { label: 'Deals', href: '/deals', icon: Handshake },
 
15
  import GoogleAuthBar from '@/components/layout/GoogleAuthBar';
16
 
17
  const NAV_ITEMS = [
18
+ { label: 'Campaigns', href: '/', icon: LayoutDashboard },
19
  { label: 'Contacts', href: '/contacts', icon: Users },
20
  { label: 'Leads', href: '/leads', icon: Inbox },
21
  { label: 'Deals', href: '/deals', icon: Handshake },
frontend/src/pages/EmailSequenceGenerator.jsx CHANGED
@@ -1,92 +1,21 @@
1
- import React, { useRef, useMemo } from 'react';
2
- import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react';
3
  import { Button } from '@/components/ui/button';
4
- import { motion, AnimatePresence } from 'framer-motion';
5
-
6
- import UploadStep from '@/components/upload/UploadStep';
7
- import ProductSelector from '@/components/products/ProductSelector';
8
- import PromptEditor from '@/components/prompts/PromptEditor';
9
- import SequenceViewer from '@/components/sequences/SequenceViewer';
10
  import AppShell from '@/components/layout/AppShell';
11
- import { apiFetch } from '@/lib/api';
 
12
  import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
13
 
14
  export default function EmailSequenceGenerator() {
15
- const {
16
- step,
17
- setStep,
18
- uploadedFile,
19
- setUploadedFile,
20
- selectedProducts,
21
- setSelectedProducts,
22
- prompts,
23
- setPrompts,
24
- linkedinPrompts,
25
- setLinkedinPrompts,
26
- includeLinkedin,
27
- setIncludeLinkedin,
28
- isGenerating,
29
- generationComplete,
30
- beginGeneration,
31
- resetWorkflow,
32
- } = useGeneratorWorkflow();
33
-
34
- const generateButtonRef = useRef(null);
35
-
36
- const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
37
- const canProceedToStep3 = useMemo(() => {
38
- const emailOk = selectedProducts.length > 0 && selectedProducts.every((p) => (prompts[p.name] || '').trim());
39
- if (!emailOk) return false;
40
- if (!includeLinkedin) return true;
41
- return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim());
42
- }, [selectedProducts, prompts, linkedinPrompts, includeLinkedin]);
43
-
44
- const scrollToGenerateButton = () => {
45
- if (generateButtonRef.current) {
46
- generateButtonRef.current.scrollIntoView({
47
- behavior: 'smooth',
48
- block: 'center',
49
- });
50
- }
51
- };
52
-
53
- const handleGenerate = async () => {
54
- if (!uploadedFile?.fileId || !canProceedToStep3) {
55
- alert('Please complete all steps before generating sequences.');
56
- return;
57
- }
58
-
59
- try {
60
- const res = await apiFetch('/api/save-prompts', {
61
- method: 'POST',
62
- headers: { 'Content-Type': 'application/json' },
63
- body: JSON.stringify({
64
- file_id: uploadedFile.fileId,
65
- prompts,
66
- products: selectedProducts.map((p) => p.name),
67
- linkedin_prompts: includeLinkedin ? linkedinPrompts : {},
68
- }),
69
- });
70
- if (!res.ok) {
71
- const err = await res.json().catch(() => ({}));
72
- throw new Error(err.detail || res.statusText);
73
- }
74
- } catch (error) {
75
- console.error('Error saving prompts:', error);
76
- alert('Failed to save templates. Please try again.');
77
- return;
78
- }
79
-
80
- setStep(3);
81
- beginGeneration(true);
82
- };
83
 
84
  return (
85
  <AppShell
86
- title="Sequence Generator"
87
- subtitle="Import Apollo contacts, configure prompts, and generate sequences."
88
  rightContent={
89
- step > 1 ? (
90
  <Button
91
  variant="ghost"
92
  onClick={resetWorkflow}
@@ -97,221 +26,26 @@ export default function EmailSequenceGenerator() {
97
  ) : null
98
  }
99
  >
100
- <div className="mb-10">
101
- <div className="flex items-center justify-center gap-4">
102
- {[
103
- { num: 1, label: 'Upload & Select' },
104
- { num: 2, label: 'Configure Prompts' },
105
- { num: 3, label: 'Generate & Export' },
106
- ].map((s, idx) => (
107
- <React.Fragment key={s.num}>
108
- <div className="flex items-center gap-2">
109
- <div
110
- className={`
111
- h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold
112
- transition-all duration-300
113
- ${
114
- step >= s.num
115
- ? 'bg-violet-600 text-white shadow-lg shadow-violet-200'
116
- : 'bg-slate-100 text-slate-400'
117
- }
118
- `}
119
- >
120
- {s.num}
121
- </div>
122
- <span
123
- className={`text-sm font-medium hidden sm:block ${
124
- step >= s.num ? 'text-slate-800' : 'text-slate-400'
125
- }`}
126
- >
127
- {s.label}
128
- </span>
129
- </div>
130
- {idx < 2 && (
131
- <div
132
- className={`h-0.5 w-12 rounded-full transition-colors duration-300 ${
133
- step > s.num ? 'bg-violet-600' : 'bg-slate-200'
134
- }`}
135
- />
136
- )}
137
- </React.Fragment>
138
- ))}
139
- </div>
140
- </div>
141
-
142
- <AnimatePresence mode="wait">
143
- {step === 1 && (
144
- <motion.div
145
- key="step1"
146
- initial={{ opacity: 0, x: -20 }}
147
- animate={{ opacity: 1, x: 0 }}
148
- exit={{ opacity: 0, x: 20 }}
149
- transition={{ duration: 0.3 }}
150
- className="space-y-8"
151
- >
152
- <div className="text-center mb-8">
153
- <h2 className="text-2xl font-bold text-slate-800 mb-2">Upload Your Contacts</h2>
154
- <p className="text-slate-500">
155
- Import your Apollo CSV and select the products for your outreach campaign
156
- </p>
157
- </div>
158
-
159
- <UploadStep
160
- onFileUploaded={setUploadedFile}
161
- uploadedFile={uploadedFile}
162
- onRemoveFile={() => setUploadedFile(null)}
163
- />
164
-
165
- {uploadedFile && (
166
- <motion.div
167
- initial={{ opacity: 0, y: 20 }}
168
- animate={{ opacity: 1, y: 0 }}
169
- transition={{ delay: 0.2 }}
170
- >
171
- <ProductSelector
172
- selectedProducts={selectedProducts}
173
- onProductsChange={setSelectedProducts}
174
- />
175
- </motion.div>
176
- )}
177
-
178
- <div className="flex justify-end pt-4">
179
- <Button
180
- onClick={() => setStep(2)}
181
- disabled={!canProceedToStep2}
182
- className="bg-violet-600 hover:bg-violet-700 px-6"
183
- >
184
- Continue to Prompts
185
- <ArrowRight className="h-4 w-4 ml-2" />
186
- </Button>
187
- </div>
188
- </motion.div>
189
- )}
190
-
191
- {step === 2 && (
192
- <motion.div
193
- key="step2"
194
- initial={{ opacity: 0, x: -20 }}
195
- animate={{ opacity: 1, x: 0 }}
196
- exit={{ opacity: 0, x: 20 }}
197
- transition={{ duration: 0.3 }}
198
- className="space-y-8"
199
- >
200
- <div className="text-center mb-8">
201
- <h2 className="text-2xl font-bold text-slate-800 mb-2">
202
- Customize Your Email Templates
203
- </h2>
204
- <p className="text-slate-500">
205
- Edit the prompt templates for each product. The AI will personalize these for each
206
- contact.
207
- </p>
208
- </div>
209
-
210
- <PromptEditor
211
- selectedProducts={selectedProducts}
212
- prompts={prompts}
213
- onPromptsChange={setPrompts}
214
- onSaveComplete={scrollToGenerateButton}
215
- variant="email"
216
- />
217
-
218
- <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
219
- <input
220
- type="checkbox"
221
- className="mt-1 h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
222
- checked={includeLinkedin}
223
- onChange={(e) => setIncludeLinkedin(e.target.checked)}
224
- />
225
- <span>
226
- <span className="font-medium text-slate-800">
227
- Also generate LinkedIn sequences for the same contacts
228
- </span>
229
- <span className="mt-1 block text-sm text-slate-500">
230
- Runs after email generation. Configure LinkedIn system prompts below — same edit /
231
- save flow as email.
232
- </span>
233
- </span>
234
- </label>
235
-
236
- {includeLinkedin ? (
237
- <div className="space-y-2">
238
- <h3 className="text-lg font-semibold text-slate-800">LinkedIn prompts</h3>
239
- <PromptEditor
240
- selectedProducts={selectedProducts}
241
- prompts={linkedinPrompts}
242
- onPromptsChange={setLinkedinPrompts}
243
- onSaveComplete={scrollToGenerateButton}
244
- variant="linkedin"
245
- />
246
- </div>
247
- ) : null}
248
-
249
- <div className="flex justify-between pt-4">
250
- <Button variant="outline" onClick={() => setStep(1)} className="px-6">
251
- <ArrowLeft className="h-4 w-4 mr-2" />
252
- Back
253
- </Button>
254
- <Button
255
- ref={generateButtonRef}
256
- onClick={handleGenerate}
257
- disabled={!canProceedToStep3}
258
- className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700 hover:to-purple-700 px-8 shadow-lg shadow-violet-200"
259
- >
260
- <Sparkles className="h-4 w-4 mr-2" />
261
- Generate Sequences
262
- </Button>
263
- </div>
264
- </motion.div>
265
- )}
266
-
267
- {step === 3 && (
268
- <motion.div
269
- key="step3"
270
- initial={{ opacity: 0, x: -20 }}
271
- animate={{ opacity: 1, x: 0 }}
272
- exit={{ opacity: 0, x: 20 }}
273
- transition={{ duration: 0.3 }}
274
- className="space-y-8"
275
- >
276
- <div className="text-center mb-8">
277
- <h2 className="text-2xl font-bold text-slate-800 mb-2">
278
- {generationComplete ? 'Your Sequences Are Ready!' : 'Generating…'}
279
- </h2>
280
- <p className="text-slate-500">
281
- {generationComplete
282
- ? 'Review your sequences below and download when ready'
283
- : 'You can navigate to other pages — generation continues in the background.'}
284
- </p>
285
- </div>
286
-
287
- <SequenceViewer />
288
-
289
- {!isGenerating && (
290
- <div className="flex justify-start pt-4">
291
- <Button variant="outline" onClick={() => setStep(2)} className="px-6">
292
- <ArrowLeft className="h-4 w-4 mr-2" />
293
- Edit Templates
294
- </Button>
295
- </div>
296
- )}
297
- </motion.div>
298
- )}
299
- </AnimatePresence>
300
 
301
  <footer className="border-t border-slate-100 mt-16">
302
  <div className="w-full px-4 sm:px-5 md:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6">
303
  <p className="text-center text-sm text-slate-400">
304
- Powered by AI • Export ready for Outreaches, Smartlead, and more
305
  </p>
306
  </div>
307
  </footer>
308
-
309
- <style>{`
310
- .custom-scrollbar::-webkit-scrollbar { width: 6px; }
311
- .custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; }
312
- .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
313
- .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
314
- `}</style>
315
  </AppShell>
316
  );
317
  }
 
1
+ import React, { useState } from 'react';
 
2
  import { Button } from '@/components/ui/button';
3
+ 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() {
10
+ const { resetWorkflow } = useGeneratorWorkflow();
11
+ const [activeTab, setActiveTab] = useState('generator');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  return (
14
  <AppShell
15
+ title="Campaigns"
16
+ subtitle="Create and run email + LinkedIn campaigns from one workspace."
17
  rightContent={
18
+ activeTab === 'generator' ? (
19
  <Button
20
  variant="ghost"
21
  onClick={resetWorkflow}
 
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 />
36
+ </TabsContent>
37
+ <TabsContent value="linkedin">
38
+ <LinkedinCampaignsTab />
39
+ </TabsContent>
40
+ </Tabs>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  <footer className="border-t border-slate-100 mt-16">
43
  <div className="w-full px-4 sm:px-5 md:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6">
44
  <p className="text-center text-sm text-slate-400">
45
+ Powered by AI • Campaign orchestration for Email and LinkedIn
46
  </p>
47
  </div>
48
  </footer>
 
 
 
 
 
 
 
49
  </AppShell>
50
  );
51
  }