Seth commited on
Commit
c356b87
·
1 Parent(s): 6f3bb09
README.md CHANGED
@@ -17,34 +17,60 @@ A Hugging Face Space application for generating personalized email outreach sequ
17
  - 📤 Upload CSV files from Apollo with contact information
18
  - 🎯 Select and customize products for outreach campaigns
19
  - ✏️ Edit email prompt templates for each product
20
- - 🤖 AI-powered sequence generation using GPT
21
  - 📊 Real-time streaming of generated sequences
22
  - 💾 Download sequences as CSV for use in Klenty and other email tools
 
 
23
 
24
  ## Setup
25
 
26
- 1. Set your OpenAI API key as a secret in Hugging Face Spaces:
27
  - Go to Settings → Secrets
28
- - Add `OPENAI_API_KEY` with your API key value
 
29
 
30
  2. The app will automatically:
31
  - Create SQLite database in `/data/emailout.db`
32
  - Store uploaded CSV files in `/data/uploads/`
33
  - Generate and store email sequences
 
34
 
35
  ## Usage
36
 
37
  1. **Upload CSV**: Upload your Apollo CSV file with contacts
38
  2. **Select Products**: Choose which products to focus on for outreach
39
  3. **Configure Prompts**: Customize email templates for each product
40
- 4. **Generate**: Let AI create personalized sequences for each contact
41
- 5. **Download**: Export sequences as CSV for your email tools
 
 
 
42
 
43
  ## Tech Stack
44
 
45
- - **Frontend**: React + Vite + Tailwind CSS + Framer Motion
46
  - **Backend**: FastAPI + Python
47
  - **AI**: OpenAI GPT API
48
  - **Database**: SQLite (Hugging Face Spaces persistent storage)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
17
  - 📤 Upload CSV files from Apollo with contact information
18
  - 🎯 Select and customize products for outreach campaigns
19
  - ✏️ Edit email prompt templates for each product
20
+ - 🤖 AI-powered sequence generation using GPT (up to 10 emails per contact)
21
  - 📊 Real-time streaming of generated sequences
22
  - 💾 Download sequences as CSV for use in Klenty and other email tools
23
+ - 🚀 Smartlead integration for direct campaign creation and lead pushing
24
+ - 📈 Run history tracking for all Smartlead pushes
25
 
26
  ## Setup
27
 
28
+ 1. Set your API keys as secrets in Hugging Face Spaces:
29
  - Go to Settings → Secrets
30
+ - Add `OPENAI_API_KEY` with your OpenAI API key value
31
+ - Add `SMARTLEAD_API_KEY` with your Smartlead API key value (optional, for Smartlead integration)
32
 
33
  2. The app will automatically:
34
  - Create SQLite database in `/data/emailout.db`
35
  - Store uploaded CSV files in `/data/uploads/`
36
  - Generate and store email sequences
37
+ - Track Smartlead run history
38
 
39
  ## Usage
40
 
41
  1. **Upload CSV**: Upload your Apollo CSV file with contacts
42
  2. **Select Products**: Choose which products to focus on for outreach
43
  3. **Configure Prompts**: Customize email templates for each product
44
+ 4. **Generate**: Let AI create personalized sequences for each contact (up to 10 emails per contact)
45
+ 5. **Export Options**:
46
+ - **Download CSV**: Export sequences as CSV for Klenty, Outreach, etc.
47
+ - **Push to Smartlead**: Directly create campaigns and push leads to Smartlead
48
+ 6. **View History**: Check run history for all Smartlead pushes
49
 
50
  ## Tech Stack
51
 
52
+ - **Frontend**: React + Vite + Tailwind CSS + Framer Motion + React Router
53
  - **Backend**: FastAPI + Python
54
  - **AI**: OpenAI GPT API
55
  - **Database**: SQLite (Hugging Face Spaces persistent storage)
56
+ - **Integration**: Smartlead API for campaign management
57
+
58
+ ## API Endpoints
59
+
60
+ - `POST /api/upload-csv` - Upload CSV file
61
+ - `POST /api/save-prompts` - Save prompt templates
62
+ - `GET /api/generate-sequences` - Generate sequences (SSE stream)
63
+ - `GET /api/download-sequences` - Download sequences as CSV (with all subject/body columns)
64
+ - `POST /api/push-to-smartlead` - Push sequences to Smartlead campaign
65
+ - `GET /api/smartlead-runs` - Get Smartlead run history
66
+
67
+ ## Smartlead Integration
68
+
69
+ The app supports direct integration with Smartlead:
70
+ - Create new campaigns or use existing ones
71
+ - Automatically build sequence templates with variables ({{subject_1}}..{{subject_10}}, {{body_1}}..{{body_10}})
72
+ - Push leads with custom variables for personalized sequences
73
+ - Track all runs in the Run History page
74
+ - Dry run mode for testing without sending to Smartlead
75
 
76
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
backend/app/database.py CHANGED
@@ -54,6 +54,27 @@ class GeneratedSequence(Base):
54
  created_at = Column(DateTime, default=datetime.utcnow)
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  # Create tables
58
  Base.metadata.create_all(bind=engine)
59
 
 
54
  created_at = Column(DateTime, default=datetime.utcnow)
55
 
56
 
57
+ class SmartleadRun(Base):
58
+ __tablename__ = "smartlead_runs"
59
+
60
+ id = Column(Integer, primary_key=True, index=True)
61
+ file_id = Column(String, index=True)
62
+ run_id = Column(String, unique=True, index=True)
63
+ campaign_id = Column(String, index=True)
64
+ campaign_name = Column(String)
65
+ mode = Column(String) # 'existing' or 'new'
66
+ steps_count = Column(Integer)
67
+ dry_run = Column(Integer, default=0) # 0 = false, 1 = true
68
+ total_leads = Column(Integer)
69
+ added_leads = Column(Integer, default=0)
70
+ skipped_leads = Column(Integer, default=0)
71
+ failed_leads = Column(Integer, default=0)
72
+ error_details = Column(Text) # JSON string of errors
73
+ status = Column(String) # 'pending', 'completed', 'failed'
74
+ created_at = Column(DateTime, default=datetime.utcnow)
75
+ completed_at = Column(DateTime, nullable=True)
76
+
77
+
78
  # Create tables
79
  Base.metadata.create_all(bind=engine)
80
 
backend/app/main.py CHANGED
@@ -13,10 +13,12 @@ import concurrent.futures
13
  from typing import Dict, List
14
  import json
15
  import asyncio
 
16
 
17
- from .database import get_db, UploadedFile, Prompt, GeneratedSequence
18
- from .models import UploadResponse, PromptSaveRequest, SequenceResponse
19
  from .gpt_service import generate_email_sequence
 
20
 
21
  app = FastAPI()
22
 
@@ -212,7 +214,7 @@ async def generate_sequences(
212
 
213
  @app.get("/api/download-sequences")
214
  async def download_sequences(file_id: str = Query(...), db: Session = Depends(get_db)):
215
- """Download generated sequences as CSV"""
216
  try:
217
  # Get all sequences for this file, grouped by contact
218
  sequences = db.query(GeneratedSequence).filter(
@@ -222,29 +224,48 @@ async def download_sequences(file_id: str = Query(...), db: Session = Depends(ge
222
  if not sequences:
223
  raise HTTPException(status_code=404, detail="No sequences found")
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  # Create CSV in memory
226
  output = io.StringIO()
227
  writer = csv.writer(output)
228
 
229
- # Write header
230
- writer.writerow([
231
- 'First Name', 'Last Name', 'Email', 'Company', 'Title',
232
- 'Product', 'Email Number', 'Subject', 'Email Content'
233
- ])
234
 
235
- # Write rows (one row per email in sequence)
236
- for seq in sequences:
237
- writer.writerow([
238
- seq.first_name,
239
- seq.last_name,
240
- seq.email,
241
- seq.company,
242
- seq.title or '',
243
- seq.product,
244
- seq.email_number,
245
- seq.subject,
246
- seq.email_content.replace('\n', ' ').replace('\r', '')
247
- ])
 
248
 
249
  output.seek(0)
250
 
@@ -261,6 +282,217 @@ async def download_sequences(file_id: str = Query(...), db: Session = Depends(ge
261
  raise HTTPException(status_code=500, detail=f"Error downloading sequences: {str(e)}")
262
 
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  # ---- Frontend static serving ----
265
  FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
266
  INDEX_FILE = FRONTEND_DIST / "index.html"
 
13
  from typing import Dict, List
14
  import json
15
  import asyncio
16
+ from datetime import datetime
17
 
18
+ from .database import get_db, UploadedFile, Prompt, GeneratedSequence, SmartleadRun
19
+ from .models import UploadResponse, PromptSaveRequest, SequenceResponse, SmartleadPushRequest, SmartleadRunResponse
20
  from .gpt_service import generate_email_sequence
21
+ from .smartlead_client import SmartleadClient
22
 
23
  app = FastAPI()
24
 
 
214
 
215
  @app.get("/api/download-sequences")
216
  async def download_sequences(file_id: str = Query(...), db: Session = Depends(get_db)):
217
+ """Download generated sequences as CSV with all subject/body fields"""
218
  try:
219
  # Get all sequences for this file, grouped by contact
220
  sequences = db.query(GeneratedSequence).filter(
 
224
  if not sequences:
225
  raise HTTPException(status_code=404, detail="No sequences found")
226
 
227
+ # Group sequences by contact
228
+ contacts = {}
229
+ for seq in sequences:
230
+ contact_key = f"{seq.sequence_id}"
231
+ if contact_key not in contacts:
232
+ contacts[contact_key] = {
233
+ 'first_name': seq.first_name,
234
+ 'last_name': seq.last_name,
235
+ 'email': seq.email,
236
+ 'company': seq.company,
237
+ 'title': seq.title or '',
238
+ 'product': seq.product,
239
+ 'subjects': {},
240
+ 'bodies': {}
241
+ }
242
+ contacts[contact_key]['subjects'][seq.email_number] = seq.subject
243
+ contacts[contact_key]['bodies'][seq.email_number] = seq.email_content
244
+
245
  # Create CSV in memory
246
  output = io.StringIO()
247
  writer = csv.writer(output)
248
 
249
+ # Write header with all subject/body columns
250
+ header = ['First Name', 'Last Name', 'Email', 'Company', 'Title', 'Product']
251
+ for i in range(1, 11):
252
+ header.extend([f'Subject {i}', f'Body {i}'])
253
+ writer.writerow(header)
254
 
255
+ # Write rows (one row per contact with all subjects/bodies)
256
+ for contact in contacts.values():
257
+ row = [
258
+ contact['first_name'],
259
+ contact['last_name'],
260
+ contact['email'],
261
+ contact['company'],
262
+ contact['title'],
263
+ contact['product']
264
+ ]
265
+ for i in range(1, 11):
266
+ row.append(contact['subjects'].get(i, ''))
267
+ row.append(contact['bodies'].get(i, '').replace('\n', ' ').replace('\r', ''))
268
+ writer.writerow(row)
269
 
270
  output.seek(0)
271
 
 
282
  raise HTTPException(status_code=500, detail=f"Error downloading sequences: {str(e)}")
283
 
284
 
285
+ @app.post("/api/push-to-smartlead")
286
+ async def push_to_smartlead(request: SmartleadPushRequest, db: Session = Depends(get_db)):
287
+ """Push generated sequences to Smartlead campaign"""
288
+ import uuid
289
+
290
+ try:
291
+ # Get all sequences for this file
292
+ sequences = db.query(GeneratedSequence).filter(
293
+ GeneratedSequence.file_id == request.file_id
294
+ ).order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number).all()
295
+
296
+ if not sequences:
297
+ raise HTTPException(status_code=404, detail="No sequences found")
298
+
299
+ # Group sequences by contact
300
+ contacts = {}
301
+ for seq in sequences:
302
+ contact_key = f"{seq.sequence_id}"
303
+ if contact_key not in contacts:
304
+ contacts[contact_key] = {
305
+ 'first_name': seq.first_name,
306
+ 'last_name': seq.last_name,
307
+ 'email': seq.email,
308
+ 'company': seq.company,
309
+ 'title': seq.title or '',
310
+ 'subjects': {},
311
+ 'bodies': {}
312
+ }
313
+ contacts[contact_key]['subjects'][seq.email_number] = seq.subject
314
+ contacts[contact_key]['bodies'][seq.email_number] = seq.email_content
315
+
316
+ # Create run record
317
+ run_id = str(uuid.uuid4())
318
+ run = SmartleadRun(
319
+ run_id=run_id,
320
+ file_id=request.file_id,
321
+ mode=request.mode,
322
+ campaign_id=request.campaign_id or '',
323
+ campaign_name=request.campaign_name or '',
324
+ steps_count=request.steps_count,
325
+ dry_run=1 if request.dry_run else 0,
326
+ total_leads=len(contacts),
327
+ status='pending'
328
+ )
329
+ db.add(run)
330
+ db.commit()
331
+
332
+ if request.dry_run:
333
+ # Dry run - just return what would be sent
334
+ return {
335
+ "run_id": run_id,
336
+ "campaign_id": request.campaign_id,
337
+ "campaign_name": request.campaign_name,
338
+ "steps_count": request.steps_count,
339
+ "total": len(contacts),
340
+ "added": 0,
341
+ "skipped": 0,
342
+ "failed": 0,
343
+ "errors": [],
344
+ "status": "dry_run_completed",
345
+ "message": "Dry run completed. No leads were sent to Smartlead."
346
+ }
347
+
348
+ # Initialize Smartlead client
349
+ try:
350
+ client = SmartleadClient()
351
+ except ValueError as e:
352
+ run.status = 'failed'
353
+ run.error_details = str(e)
354
+ db.commit()
355
+ raise HTTPException(status_code=400, detail=str(e))
356
+
357
+ campaign_id = request.campaign_id
358
+
359
+ # Create campaign if mode is 'new'
360
+ if request.mode == 'new':
361
+ try:
362
+ campaign_response = client.create_campaign(request.campaign_name)
363
+ campaign_id = campaign_response.get('campaign_id') or campaign_response.get('id')
364
+ if not campaign_id:
365
+ raise Exception("Campaign creation failed: No campaign_id returned")
366
+
367
+ run.campaign_id = campaign_id
368
+ db.commit()
369
+
370
+ # Build and save sequences
371
+ sequences_data = client.build_sequences(request.steps_count)
372
+ client.save_campaign_sequence(campaign_id, sequences_data)
373
+
374
+ except Exception as e:
375
+ run.status = 'failed'
376
+ run.error_details = str(e)
377
+ db.commit()
378
+ raise HTTPException(status_code=500, detail=f"Failed to create campaign: {str(e)}")
379
+
380
+ # Prepare leads for Smartlead
381
+ leads = []
382
+ errors = []
383
+ added_count = 0
384
+ skipped_count = 0
385
+ failed_count = 0
386
+
387
+ for contact in contacts.values():
388
+ try:
389
+ # Build custom variables
390
+ custom_variables = {}
391
+ for i in range(1, 11):
392
+ if i in contact['subjects']:
393
+ custom_variables[f'subject_{i}'] = contact['subjects'][i]
394
+ if i in contact['bodies']:
395
+ custom_variables[f'body_{i}'] = contact['bodies'][i]
396
+
397
+ lead = {
398
+ "email": contact['email'],
399
+ "first_name": contact['first_name'],
400
+ "last_name": contact['last_name'],
401
+ "company": contact['company'],
402
+ "title": contact['title'],
403
+ "custom_variables": custom_variables
404
+ }
405
+ leads.append(lead)
406
+
407
+ except Exception as e:
408
+ errors.append({
409
+ "email": contact.get('email', 'unknown'),
410
+ "error": str(e)
411
+ })
412
+ failed_count += 1
413
+
414
+ # Batch add leads (chunk in batches of 50)
415
+ batch_size = 50
416
+ for i in range(0, len(leads), batch_size):
417
+ batch = leads[i:i + batch_size]
418
+ try:
419
+ response = client.add_leads_to_campaign(campaign_id, batch)
420
+ # TODO: Parse response to get added/skipped counts
421
+ added_count += len(batch)
422
+ except Exception as e:
423
+ # Mark batch as failed
424
+ for lead in batch:
425
+ errors.append({
426
+ "email": lead.get('email', 'unknown'),
427
+ "error": str(e)
428
+ })
429
+ failed_count += 1
430
+
431
+ # Update run record
432
+ run.status = 'completed'
433
+ run.added_leads = added_count
434
+ run.skipped_leads = skipped_count
435
+ run.failed_leads = failed_count
436
+ run.error_details = json.dumps(errors) if errors else None
437
+ run.completed_at = datetime.utcnow()
438
+ db.commit()
439
+
440
+ return {
441
+ "run_id": run_id,
442
+ "campaign_id": campaign_id,
443
+ "campaign_name": request.campaign_name or '',
444
+ "steps_count": request.steps_count,
445
+ "total": len(contacts),
446
+ "added": added_count,
447
+ "skipped": skipped_count,
448
+ "failed": failed_count,
449
+ "errors": errors[:10], # Return first 10 errors
450
+ "status": "completed"
451
+ }
452
+
453
+ except HTTPException:
454
+ raise
455
+ except Exception as e:
456
+ if 'run' in locals():
457
+ run.status = 'failed'
458
+ run.error_details = str(e)
459
+ db.commit()
460
+ raise HTTPException(status_code=500, detail=f"Error pushing to Smartlead: {str(e)}")
461
+
462
+
463
+ @app.get("/api/smartlead-runs")
464
+ async def get_smartlead_runs(file_id: str = Query(None), db: Session = Depends(get_db)):
465
+ """Get Smartlead run history"""
466
+ try:
467
+ query = db.query(SmartleadRun)
468
+ if file_id:
469
+ query = query.filter(SmartleadRun.file_id == file_id)
470
+
471
+ runs = query.order_by(SmartleadRun.created_at.desc()).limit(50).all()
472
+
473
+ return [
474
+ {
475
+ "run_id": run.run_id,
476
+ "file_id": run.file_id,
477
+ "campaign_id": run.campaign_id,
478
+ "campaign_name": run.campaign_name,
479
+ "mode": run.mode,
480
+ "steps_count": run.steps_count,
481
+ "dry_run": bool(run.dry_run),
482
+ "total_leads": run.total_leads,
483
+ "added_leads": run.added_leads,
484
+ "skipped_leads": run.skipped_leads,
485
+ "failed_leads": run.failed_leads,
486
+ "status": run.status,
487
+ "created_at": run.created_at.isoformat() if run.created_at else None,
488
+ "completed_at": run.completed_at.isoformat() if run.completed_at else None
489
+ }
490
+ for run in runs
491
+ ]
492
+ except Exception as e:
493
+ raise HTTPException(status_code=500, detail=f"Error fetching runs: {str(e)}")
494
+
495
+
496
  # ---- Frontend static serving ----
497
  FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
498
  INDEX_FILE = FRONTEND_DIST / "index.html"
backend/app/models.py CHANGED
@@ -25,3 +25,25 @@ class SequenceResponse(BaseModel):
25
  product: str
26
  subject: str
27
  emailContent: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  product: str
26
  subject: str
27
  emailContent: str
28
+
29
+
30
+ class SmartleadPushRequest(BaseModel):
31
+ file_id: str
32
+ mode: str # 'existing' or 'new'
33
+ campaign_id: Optional[str] = None
34
+ campaign_name: Optional[str] = None
35
+ steps_count: int = 4
36
+ dry_run: bool = False
37
+
38
+
39
+ class SmartleadRunResponse(BaseModel):
40
+ run_id: str
41
+ campaign_id: Optional[str] = None
42
+ campaign_name: Optional[str] = None
43
+ steps_count: int
44
+ total: int
45
+ added: int
46
+ skipped: int
47
+ failed: int
48
+ errors: List[Dict] = []
49
+ status: str
backend/app/smartlead_client.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import time
4
+ from typing import Dict, List, Optional
5
+ from datetime import datetime
6
+
7
+
8
+ class SmartleadClient:
9
+ """Client for Smartlead API integration"""
10
+
11
+ BASE_URL = "https://server.smartlead.ai/api/v1"
12
+
13
+ def __init__(self, api_key: Optional[str] = None):
14
+ self.api_key = api_key or os.getenv("SMARTLEAD_API_KEY")
15
+ if not self.api_key:
16
+ raise ValueError("SMARTLEAD_API_KEY environment variable is required")
17
+
18
+ self.headers = {
19
+ "Authorization": f"Bearer {self.api_key}",
20
+ "Content-Type": "application/json"
21
+ }
22
+
23
+ def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None, max_retries: int = 3) -> Dict:
24
+ """Make API request with retry logic for 429/5xx errors"""
25
+ url = f"{self.BASE_URL}{endpoint}"
26
+
27
+ for attempt in range(max_retries):
28
+ try:
29
+ response = requests.request(
30
+ method=method,
31
+ url=url,
32
+ headers=self.headers,
33
+ json=data,
34
+ timeout=30
35
+ )
36
+
37
+ # Handle rate limiting
38
+ if response.status_code == 429:
39
+ retry_after = int(response.headers.get("Retry-After", 60))
40
+ if attempt < max_retries - 1:
41
+ time.sleep(retry_after)
42
+ continue
43
+
44
+ # Handle server errors
45
+ if response.status_code >= 500:
46
+ if attempt < max_retries - 1:
47
+ time.sleep(2 ** attempt) # Exponential backoff
48
+ continue
49
+
50
+ response.raise_for_status()
51
+ return response.json() if response.content else {}
52
+
53
+ except requests.exceptions.RequestException as e:
54
+ if attempt == max_retries - 1:
55
+ raise Exception(f"Smartlead API error after {max_retries} attempts: {str(e)}")
56
+ time.sleep(2 ** attempt)
57
+
58
+ raise Exception("Failed to make request after retries")
59
+
60
+ def create_campaign(self, name: str) -> Dict:
61
+ """Create a new campaign"""
62
+ data = {
63
+ "campaign_name": name,
64
+ # TODO: Add other campaign settings if needed
65
+ }
66
+ return self._make_request("POST", "/campaigns/create", data)
67
+
68
+ def save_campaign_sequence(self, campaign_id: str, sequences: List[Dict]) -> Dict:
69
+ """Save campaign sequence steps"""
70
+ data = {
71
+ "sequences": sequences
72
+ }
73
+ return self._make_request("POST", f"/campaigns/{campaign_id}/sequences", data)
74
+
75
+ def add_leads_to_campaign(self, campaign_id: str, leads: List[Dict]) -> Dict:
76
+ """Add leads to a campaign"""
77
+ data = {
78
+ "leads": leads
79
+ }
80
+ return self._make_request("POST", f"/campaigns/{campaign_id}/leads", data)
81
+
82
+ def update_campaign_settings(self, campaign_id: str, settings: Dict) -> Dict:
83
+ """Update campaign settings"""
84
+ return self._make_request("POST", f"/campaigns/{campaign_id}/settings", settings)
85
+
86
+ def build_sequences(self, steps_count: int) -> List[Dict]:
87
+ """Build sequence templates for campaign"""
88
+ sequences = []
89
+ for i in range(1, steps_count + 1):
90
+ sequences.append({
91
+ "step": i,
92
+ "subject": f"{{{{subject_{i}}}}}",
93
+ "email_body": f"Hi {{{{first_name}}}},\n\n{{{{body_{i}}}}}\n\nAnna"
94
+ })
95
+ return sequences
backend/requirements.txt CHANGED
@@ -4,4 +4,5 @@ python-multipart
4
  openai
5
  pandas
6
  aiofiles
7
- sqlalchemy
 
 
4
  openai
5
  pandas
6
  aiofiles
7
+ sqlalchemy
8
+ requests
frontend/package.json CHANGED
@@ -11,6 +11,7 @@
11
  "dependencies": {
12
  "react": "^18.3.1",
13
  "react-dom": "^18.3.1",
 
14
  "framer-motion": "^11.0.0",
15
  "lucide-react": "^0.344.0",
16
  "class-variance-authority": "^0.7.0",
 
11
  "dependencies": {
12
  "react": "^18.3.1",
13
  "react-dom": "^18.3.1",
14
+ "react-router-dom": "^6.22.0",
15
  "framer-motion": "^11.0.0",
16
  "lucide-react": "^0.344.0",
17
  "class-variance-authority": "^0.7.0",
frontend/src/App.jsx CHANGED
@@ -1,7 +1,16 @@
1
  import React from "react";
 
2
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
 
3
  import "./index.css";
4
 
5
  export default function App() {
6
- return <EmailSequenceGenerator />;
 
 
 
 
 
 
 
7
  }
 
1
  import React from "react";
2
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
3
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
+ import RunHistory from "./pages/RunHistory";
5
  import "./index.css";
6
 
7
  export default function App() {
8
+ return (
9
+ <BrowserRouter>
10
+ <Routes>
11
+ <Route path="/" element={<EmailSequenceGenerator />} />
12
+ <Route path="/history" element={<RunHistory />} />
13
+ </Routes>
14
+ </BrowserRouter>
15
+ );
16
  }
frontend/src/components/smartlead/SmartleadPanel.jsx ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Send, Download, Loader2, CheckCircle2, AlertCircle, Settings } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { motion } from 'framer-motion';
8
+
9
+ export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushComplete }) {
10
+ const [mode, setMode] = useState('existing');
11
+ const [campaignId, setCampaignId] = useState('');
12
+ const [campaignName, setCampaignName] = useState('');
13
+ const [stepsCount, setStepsCount] = useState(4);
14
+ const [dryRun, setDryRun] = useState(false);
15
+ const [isPushing, setIsPushing] = useState(false);
16
+ const [pushResult, setPushResult] = useState(null);
17
+ const [error, setError] = useState(null);
18
+
19
+ const handlePushToSmartlead = async () => {
20
+ if (!uploadedFile?.fileId) {
21
+ setError('No file uploaded');
22
+ return;
23
+ }
24
+
25
+ if (mode === 'existing' && !campaignId.trim()) {
26
+ setError('Campaign ID is required');
27
+ return;
28
+ }
29
+
30
+ if (mode === 'new' && !campaignName.trim()) {
31
+ setError('Campaign name is required');
32
+ return;
33
+ }
34
+
35
+ setIsPushing(true);
36
+ setError(null);
37
+ setPushResult(null);
38
+
39
+ try {
40
+ const response = await fetch('/api/push-to-smartlead', {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify({
44
+ file_id: uploadedFile.fileId,
45
+ mode: mode,
46
+ campaign_id: mode === 'existing' ? campaignId.trim() : null,
47
+ campaign_name: mode === 'new' ? campaignName.trim() : null,
48
+ steps_count: stepsCount,
49
+ dry_run: dryRun
50
+ })
51
+ });
52
+
53
+ const data = await response.json();
54
+
55
+ if (response.ok) {
56
+ setPushResult(data);
57
+ onPushComplete?.(data);
58
+ } else {
59
+ setError(data.detail || 'Failed to push to Smartlead');
60
+ }
61
+ } catch (err) {
62
+ setError('Network error: ' + err.message);
63
+ } finally {
64
+ setIsPushing(false);
65
+ }
66
+ };
67
+
68
+ const handleDownloadCSV = async () => {
69
+ try {
70
+ const response = await fetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
71
+ if (response.ok) {
72
+ const blob = await response.blob();
73
+ const url = URL.createObjectURL(blob);
74
+ const a = document.createElement('a');
75
+ a.href = url;
76
+ a.download = 'email_sequences.csv';
77
+ a.click();
78
+ URL.revokeObjectURL(url);
79
+ } else {
80
+ alert('Failed to download CSV. Please try again.');
81
+ }
82
+ } catch (error) {
83
+ console.error('Download error:', error);
84
+ alert('Error downloading CSV. Please try again.');
85
+ }
86
+ };
87
+
88
+ return (
89
+ <div className="w-full">
90
+ <div className="rounded-2xl border border-slate-200 bg-white p-6">
91
+ <div className="flex items-center gap-3 mb-6">
92
+ <div className="rounded-xl bg-blue-100 p-3">
93
+ <Settings className="h-6 w-6 text-blue-600" />
94
+ </div>
95
+ <div>
96
+ <h3 className="font-semibold text-slate-800">Send via Smartlead</h3>
97
+ <p className="text-sm text-slate-500">Push sequences to Smartlead campaign</p>
98
+ </div>
99
+ </div>
100
+
101
+ {/* Mode Selector */}
102
+ <div className="mb-6">
103
+ <label className="text-sm font-medium text-slate-700 mb-2 block">
104
+ Campaign Mode
105
+ </label>
106
+ <Select value={mode} onValueChange={setMode}>
107
+ <SelectTrigger className="w-full">
108
+ <SelectValue />
109
+ </SelectTrigger>
110
+ <SelectContent>
111
+ <SelectItem value="existing">Use Existing Campaign ID</SelectItem>
112
+ <SelectItem value="new">Create New Campaign</SelectItem>
113
+ </SelectContent>
114
+ </Select>
115
+ </div>
116
+
117
+ {/* Campaign ID (Mode 1) */}
118
+ {mode === 'existing' && (
119
+ <div className="mb-4">
120
+ <label className="text-sm font-medium text-slate-700 mb-2 block">
121
+ Campaign ID
122
+ </label>
123
+ <Input
124
+ value={campaignId}
125
+ onChange={(e) => setCampaignId(e.target.value)}
126
+ placeholder="Enter existing campaign ID"
127
+ className="w-full"
128
+ />
129
+ </div>
130
+ )}
131
+
132
+ {/* Campaign Name (Mode 2) */}
133
+ {mode === 'new' && (
134
+ <div className="mb-4">
135
+ <label className="text-sm font-medium text-slate-700 mb-2 block">
136
+ Campaign Name
137
+ </label>
138
+ <Input
139
+ value={campaignName}
140
+ onChange={(e) => setCampaignName(e.target.value)}
141
+ placeholder="Enter campaign name"
142
+ className="w-full"
143
+ />
144
+ </div>
145
+ )}
146
+
147
+ {/* Steps Count */}
148
+ <div className="mb-4">
149
+ <label className="text-sm font-medium text-slate-700 mb-2 block">
150
+ Steps to Use
151
+ </label>
152
+ <Select value={stepsCount.toString()} onValueChange={(v) => setStepsCount(parseInt(v))}>
153
+ <SelectTrigger className="w-full">
154
+ <SelectValue />
155
+ </SelectTrigger>
156
+ <SelectContent>
157
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(num => (
158
+ <SelectItem key={num} value={num.toString()}>
159
+ {num} {num === 1 ? 'step' : 'steps'}
160
+ </SelectItem>
161
+ ))}
162
+ </SelectContent>
163
+ </Select>
164
+ <p className="text-xs text-slate-500 mt-1">
165
+ Must align with generated sequences (max {sequencesCount || 10})
166
+ </p>
167
+ </div>
168
+
169
+ {/* Dry Run Toggle */}
170
+ <div className="mb-6 flex items-center gap-2">
171
+ <input
172
+ type="checkbox"
173
+ id="dry-run"
174
+ checked={dryRun}
175
+ onChange={(e) => setDryRun(e.target.checked)}
176
+ className="rounded border-slate-300"
177
+ />
178
+ <label htmlFor="dry-run" className="text-sm text-slate-700">
179
+ Dry run (generate but do not call Smartlead)
180
+ </label>
181
+ </div>
182
+
183
+ {/* Error Display */}
184
+ {error && (
185
+ <motion.div
186
+ initial={{ opacity: 0, y: -10 }}
187
+ animate={{ opacity: 1, y: 0 }}
188
+ className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-2"
189
+ >
190
+ <AlertCircle className="h-5 w-5 text-red-600 mt-0.5" />
191
+ <div className="flex-1">
192
+ <p className="text-sm font-medium text-red-800">Error</p>
193
+ <p className="text-sm text-red-600">{error}</p>
194
+ </div>
195
+ </motion.div>
196
+ )}
197
+
198
+ {/* Success Result */}
199
+ {pushResult && (
200
+ <motion.div
201
+ initial={{ opacity: 0, y: -10 }}
202
+ animate={{ opacity: 1, y: 0 }}
203
+ className="mb-4 p-4 rounded-lg bg-green-50 border border-green-200"
204
+ >
205
+ <div className="flex items-start gap-2 mb-3">
206
+ <CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5" />
207
+ <div className="flex-1">
208
+ <p className="text-sm font-medium text-green-800 mb-2">
209
+ {pushResult.status === 'dry_run_completed' ? 'Dry Run Completed' : 'Push Successful'}
210
+ </p>
211
+ {pushResult.campaign_id && (
212
+ <p className="text-xs text-green-700 mb-1">
213
+ Campaign ID: <span className="font-mono">{pushResult.campaign_id}</span>
214
+ </p>
215
+ )}
216
+ {pushResult.campaign_name && (
217
+ <p className="text-xs text-green-700 mb-1">
218
+ Campaign: {pushResult.campaign_name}
219
+ </p>
220
+ )}
221
+ </div>
222
+ </div>
223
+ <div className="grid grid-cols-2 gap-2 text-xs">
224
+ <div>
225
+ <span className="text-green-600">Total:</span> {pushResult.total}
226
+ </div>
227
+ <div>
228
+ <span className="text-green-600">Added:</span> {pushResult.added}
229
+ </div>
230
+ {pushResult.skipped > 0 && (
231
+ <div>
232
+ <span className="text-green-600">Skipped:</span> {pushResult.skipped}
233
+ </div>
234
+ )}
235
+ {pushResult.failed > 0 && (
236
+ <div>
237
+ <span className="text-red-600">Failed:</span> {pushResult.failed}
238
+ </div>
239
+ )}
240
+ </div>
241
+ {pushResult.errors && pushResult.errors.length > 0 && (
242
+ <div className="mt-2 pt-2 border-t border-green-200">
243
+ <p className="text-xs font-medium text-green-800 mb-1">Errors:</p>
244
+ <div className="max-h-32 overflow-y-auto">
245
+ {pushResult.errors.slice(0, 5).map((err, idx) => (
246
+ <p key={idx} className="text-xs text-red-600">
247
+ {err.email}: {err.error}
248
+ </p>
249
+ ))}
250
+ </div>
251
+ </div>
252
+ )}
253
+ </motion.div>
254
+ )}
255
+
256
+ {/* Actions */}
257
+ <div className="flex gap-3">
258
+ <Button
259
+ onClick={handlePushToSmartlead}
260
+ disabled={isPushing || !uploadedFile?.fileId}
261
+ className="flex-1 bg-blue-600 hover:bg-blue-700"
262
+ >
263
+ {isPushing ? (
264
+ <>
265
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
266
+ Pushing...
267
+ </>
268
+ ) : (
269
+ <>
270
+ <Send className="h-4 w-4 mr-2" />
271
+ Generate + Push to Smartlead
272
+ </>
273
+ )}
274
+ </Button>
275
+ <Button
276
+ onClick={handleDownloadCSV}
277
+ variant="outline"
278
+ className="flex-1"
279
+ >
280
+ <Download className="h-4 w-4 mr-2" />
281
+ Download CSV
282
+ </Button>
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
frontend/src/pages/EmailSequenceGenerator.jsx CHANGED
@@ -7,6 +7,7 @@ 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
 
11
  export default function EmailSequenceGenerator() {
12
  const [step, setStep] = useState(1);
@@ -254,8 +255,25 @@ export default function EmailSequenceGenerator() {
254
  onComplete={handleGenerationComplete}
255
  />
256
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  {!isGenerating && (
258
- <div className="flex justify-start pt-4">
259
  <Button
260
  variant="outline"
261
  onClick={() => setStep(2)}
@@ -264,6 +282,13 @@ export default function EmailSequenceGenerator() {
264
  <ArrowLeft className="h-4 w-4 mr-2" />
265
  Edit Templates
266
  </Button>
 
 
 
 
 
 
 
267
  </div>
268
  )}
269
  </motion.div>
 
7
  import ProductSelector from '@/components/products/ProductSelector';
8
  import PromptEditor from '@/components/prompts/PromptEditor';
9
  import SequenceViewer from '@/components/sequences/SequenceViewer';
10
+ import SmartleadPanel from '@/components/smartlead/SmartleadPanel';
11
 
12
  export default function EmailSequenceGenerator() {
13
  const [step, setStep] = useState(1);
 
255
  onComplete={handleGenerationComplete}
256
  />
257
 
258
+ {generationComplete && (
259
+ <motion.div
260
+ initial={{ opacity: 0, y: 20 }}
261
+ animate={{ opacity: 1, y: 0 }}
262
+ transition={{ delay: 0.3 }}
263
+ className="mt-8"
264
+ >
265
+ <SmartleadPanel
266
+ uploadedFile={uploadedFile}
267
+ sequencesCount={uploadedFile?.contactCount || 0}
268
+ onPushComplete={(result) => {
269
+ console.log('Push completed:', result);
270
+ }}
271
+ />
272
+ </motion.div>
273
+ )}
274
+
275
  {!isGenerating && (
276
+ <div className="flex justify-between pt-4">
277
  <Button
278
  variant="outline"
279
  onClick={() => setStep(2)}
 
282
  <ArrowLeft className="h-4 w-4 mr-2" />
283
  Edit Templates
284
  </Button>
285
+ <Button
286
+ variant="outline"
287
+ onClick={() => window.location.href = '/history'}
288
+ className="px-6"
289
+ >
290
+ View Run History
291
+ </Button>
292
  </div>
293
  )}
294
  </motion.div>
frontend/src/pages/RunHistory.jsx ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { History, CheckCircle2, XCircle, Clock, ExternalLink, Filter } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { motion } from 'framer-motion';
8
+
9
+ export default function RunHistory() {
10
+ const [runs, setRuns] = useState([]);
11
+ const [loading, setLoading] = useState(true);
12
+ const [filterStatus, setFilterStatus] = useState('all');
13
+ const [searchQuery, setSearchQuery] = useState('');
14
+
15
+ useEffect(() => {
16
+ fetchRuns();
17
+ }, []);
18
+
19
+ const fetchRuns = async () => {
20
+ try {
21
+ setLoading(true);
22
+ const response = await fetch('/api/smartlead-runs');
23
+ if (response.ok) {
24
+ const data = await response.json();
25
+ setRuns(data);
26
+ }
27
+ } catch (error) {
28
+ console.error('Error fetching runs:', error);
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
+
34
+ const filteredRuns = runs.filter(run => {
35
+ const matchesStatus = filterStatus === 'all' || run.status === filterStatus;
36
+ const matchesSearch = searchQuery === '' ||
37
+ run.campaign_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
38
+ run.campaign_id?.toLowerCase().includes(searchQuery.toLowerCase()) ||
39
+ run.run_id?.toLowerCase().includes(searchQuery.toLowerCase());
40
+ return matchesStatus && matchesSearch;
41
+ });
42
+
43
+ const getStatusBadge = (status) => {
44
+ const variants = {
45
+ 'completed': 'bg-green-100 text-green-700',
46
+ 'failed': 'bg-red-100 text-red-700',
47
+ 'pending': 'bg-yellow-100 text-yellow-700',
48
+ 'dry_run_completed': 'bg-blue-100 text-blue-700'
49
+ };
50
+ return variants[status] || 'bg-slate-100 text-slate-700';
51
+ };
52
+
53
+ const getStatusIcon = (status) => {
54
+ if (status === 'completed' || status === 'dry_run_completed') {
55
+ return <CheckCircle2 className="h-4 w-4" />;
56
+ } else if (status === 'failed') {
57
+ return <XCircle className="h-4 w-4" />;
58
+ } else {
59
+ return <Clock className="h-4 w-4" />;
60
+ }
61
+ };
62
+
63
+ const formatDate = (dateString) => {
64
+ if (!dateString) return 'N/A';
65
+ const date = new Date(dateString);
66
+ return date.toLocaleString();
67
+ };
68
+
69
+ return (
70
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
71
+ {/* Header */}
72
+ <header className="border-b border-slate-100 bg-white/80 backdrop-blur-sm sticky top-0 z-50">
73
+ <div className="max-w-6xl mx-auto px-6 py-4">
74
+ <div className="flex items-center justify-between">
75
+ <div className="flex items-center gap-3">
76
+ <div className="h-10 w-10 rounded-xl bg-gradient-to-br from-violet-600 to-purple-600
77
+ flex items-center justify-center shadow-lg shadow-violet-200">
78
+ <History className="h-5 w-5 text-white" />
79
+ </div>
80
+ <div>
81
+ <h1 className="font-bold text-slate-800 text-lg">Run History</h1>
82
+ <p className="text-xs text-slate-500">Smartlead campaign push history</p>
83
+ </div>
84
+ </div>
85
+ <Button
86
+ variant="outline"
87
+ onClick={() => window.location.href = '/'}
88
+ className="text-slate-500 hover:text-slate-700"
89
+ >
90
+ Back to Generator
91
+ </Button>
92
+ </div>
93
+ </div>
94
+ </header>
95
+
96
+ <main className="max-w-6xl mx-auto px-6 py-8">
97
+ {/* Filters */}
98
+ <div className="mb-6 flex flex-col sm:flex-row gap-3">
99
+ <div className="relative flex-1">
100
+ <Input
101
+ placeholder="Search by campaign name, ID, or run ID..."
102
+ value={searchQuery}
103
+ onChange={(e) => setSearchQuery(e.target.value)}
104
+ className="pl-10"
105
+ />
106
+ </div>
107
+ <Select value={filterStatus} onValueChange={setFilterStatus}>
108
+ <SelectTrigger className="w-full sm:w-48">
109
+ <Filter className="h-4 w-4 mr-2 text-slate-400" />
110
+ <SelectValue placeholder="Filter by status" />
111
+ </SelectTrigger>
112
+ <SelectContent>
113
+ <SelectItem value="all">All Status</SelectItem>
114
+ <SelectItem value="completed">Completed</SelectItem>
115
+ <SelectItem value="failed">Failed</SelectItem>
116
+ <SelectItem value="pending">Pending</SelectItem>
117
+ <SelectItem value="dry_run_completed">Dry Run</SelectItem>
118
+ </SelectContent>
119
+ </Select>
120
+ </div>
121
+
122
+ {/* Runs List */}
123
+ {loading ? (
124
+ <div className="text-center py-16">
125
+ <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-violet-600"></div>
126
+ <p className="text-sm text-slate-500 mt-2">Loading runs...</p>
127
+ </div>
128
+ ) : filteredRuns.length === 0 ? (
129
+ <div className="text-center py-16">
130
+ <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
131
+ <History className="h-8 w-8 text-slate-300" />
132
+ </div>
133
+ <h3 className="text-lg font-semibold text-slate-400 mb-2">No runs found</h3>
134
+ <p className="text-sm text-slate-400">Push sequences to Smartlead to see history here</p>
135
+ </div>
136
+ ) : (
137
+ <div className="space-y-3">
138
+ {filteredRuns.map((run, index) => (
139
+ <motion.div
140
+ key={run.run_id}
141
+ initial={{ opacity: 0, y: 20 }}
142
+ animate={{ opacity: 1, y: 0 }}
143
+ transition={{ duration: 0.3, delay: index * 0.05 }}
144
+ className="rounded-xl border border-slate-200 bg-white p-5 hover:shadow-md transition-shadow"
145
+ >
146
+ <div className="flex items-start justify-between mb-4">
147
+ <div className="flex items-start gap-4 flex-1">
148
+ <div className={`rounded-lg p-2 ${getStatusBadge(run.status)}`}>
149
+ {getStatusIcon(run.status)}
150
+ </div>
151
+ <div className="flex-1">
152
+ <div className="flex items-center gap-2 mb-1">
153
+ <h4 className="font-semibold text-slate-800">
154
+ {run.campaign_name || 'Unnamed Campaign'}
155
+ </h4>
156
+ <Badge className={getStatusBadge(run.status)}>
157
+ {run.status}
158
+ </Badge>
159
+ {run.dry_run && (
160
+ <Badge variant="outline" className="text-xs">
161
+ Dry Run
162
+ </Badge>
163
+ )}
164
+ </div>
165
+ <div className="flex flex-wrap items-center gap-4 text-sm text-slate-500">
166
+ {run.campaign_id && (
167
+ <span className="font-mono text-xs">
168
+ Campaign: {run.campaign_id}
169
+ </span>
170
+ )}
171
+ <span>
172
+ {run.mode === 'new' ? 'New Campaign' : 'Existing Campaign'}
173
+ </span>
174
+ <span>
175
+ {run.steps_count} {run.steps_count === 1 ? 'step' : 'steps'}
176
+ </span>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ <div className="text-right text-xs text-slate-500">
181
+ <div>{formatDate(run.created_at)}</div>
182
+ {run.completed_at && (
183
+ <div className="text-slate-400 mt-1">
184
+ Completed: {formatDate(run.completed_at)}
185
+ </div>
186
+ )}
187
+ </div>
188
+ </div>
189
+
190
+ {/* Stats */}
191
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-slate-100">
192
+ <div>
193
+ <div className="text-xs text-slate-500 mb-1">Total Leads</div>
194
+ <div className="text-lg font-semibold text-slate-800">{run.total_leads}</div>
195
+ </div>
196
+ <div>
197
+ <div className="text-xs text-slate-500 mb-1">Added</div>
198
+ <div className="text-lg font-semibold text-green-600">{run.added_leads}</div>
199
+ </div>
200
+ {run.skipped_leads > 0 && (
201
+ <div>
202
+ <div className="text-xs text-slate-500 mb-1">Skipped</div>
203
+ <div className="text-lg font-semibold text-yellow-600">{run.skipped_leads}</div>
204
+ </div>
205
+ )}
206
+ {run.failed_leads > 0 && (
207
+ <div>
208
+ <div className="text-xs text-slate-500 mb-1">Failed</div>
209
+ <div className="text-lg font-semibold text-red-600">{run.failed_leads}</div>
210
+ </div>
211
+ )}
212
+ </div>
213
+
214
+ {/* Run ID */}
215
+ <div className="mt-3 pt-3 border-t border-slate-100">
216
+ <div className="flex items-center justify-between">
217
+ <span className="text-xs text-slate-400 font-mono">
218
+ Run ID: {run.run_id}
219
+ </span>
220
+ {run.campaign_id && (
221
+ <Button
222
+ variant="ghost"
223
+ size="sm"
224
+ onClick={() => {
225
+ // TODO: Open Smartlead campaign URL if available
226
+ window.open(`https://app.smartlead.ai/campaigns/${run.campaign_id}`, '_blank');
227
+ }}
228
+ className="h-7 text-xs"
229
+ >
230
+ <ExternalLink className="h-3 w-3 mr-1" />
231
+ View in Smartlead
232
+ </Button>
233
+ )}
234
+ </div>
235
+ </div>
236
+ </motion.div>
237
+ ))}
238
+ </div>
239
+ )}
240
+ </main>
241
+ </div>
242
+ );
243
+ }