Seth commited on
Commit
b9cd14b
·
1 Parent(s): f8253ae
backend/app/database.py CHANGED
@@ -84,6 +84,7 @@ class UploadedFile(Base):
84
  filename = Column(String)
85
  contact_count = Column(Integer)
86
  file_path = Column(String)
 
87
  created_at = Column(DateTime, default=datetime.utcnow)
88
 
89
 
@@ -116,6 +117,7 @@ class GeneratedSequence(Base):
116
  product = Column(String)
117
  subject = Column(String)
118
  email_content = Column(Text)
 
119
  created_at = Column(DateTime, default=datetime.utcnow)
120
 
121
 
@@ -407,6 +409,15 @@ def run_migrations(connection_engine):
407
  "UPDATE generated_sequences SET channel = 'email' WHERE channel IS NULL OR channel = ''"
408
  )
409
  )
 
 
 
 
 
 
 
 
 
410
 
411
  insp = inspect(connection_engine)
412
  if insp.has_table("unipile_accounts"):
 
84
  filename = Column(String)
85
  contact_count = Column(Integer)
86
  file_path = Column(String)
87
+ sequence_plan_json = Column(Text, nullable=True)
88
  created_at = Column(DateTime, default=datetime.utcnow)
89
 
90
 
 
117
  product = Column(String)
118
  subject = Column(String)
119
  email_content = Column(Text)
120
+ step_order = Column(Integer, nullable=True)
121
  created_at = Column(DateTime, default=datetime.utcnow)
122
 
123
 
 
409
  "UPDATE generated_sequences SET channel = 'email' WHERE channel IS NULL OR channel = ''"
410
  )
411
  )
412
+ gcols2 = [c["name"] for c in insp.get_columns("generated_sequences")]
413
+ if "step_order" not in gcols2:
414
+ conn.execute(text("ALTER TABLE generated_sequences ADD COLUMN step_order INTEGER"))
415
+
416
+ insp = inspect(connection_engine)
417
+ if insp.has_table("uploaded_files"):
418
+ ufcols = [c["name"] for c in insp.get_columns("uploaded_files")]
419
+ if "sequence_plan_json" not in ufcols:
420
+ conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN sequence_plan_json TEXT"))
421
 
422
  insp = inspect(connection_engine)
423
  if insp.has_table("unipile_accounts"):
backend/app/gpt_service.py CHANGED
@@ -16,7 +16,12 @@ logger = logging.getLogger(__name__)
16
  client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
17
 
18
 
19
- def generate_email_sequence(contact: Dict, prompt_template: str, product_name: str) -> List[Dict]:
 
 
 
 
 
20
  """
21
  Generate a personalized email sequence for a contact using GPT.
22
  """
@@ -72,9 +77,34 @@ def generate_email_sequence(contact: Dict, prompt_template: str, product_name: s
72
  num_emails = 9
73
  elif "10-email" in prompt_template or "10 email" in prompt_template:
74
  num_emails = 10
75
-
76
- user_prompt = f"""Generate a complete email sequence for this contact:
 
 
 
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  Contact Information:
79
  - First Name: {first_name}
80
  - Last Name: {last_name}
@@ -106,6 +136,7 @@ Anna
106
  ... (continue for all {num_emails} emails, each starting with "Hi {first_name},")
107
 
108
  Remember: Use only the information provided above. Do not reference anything not explicitly mentioned."""
 
109
  else:
110
  # Use standard prompt for other products
111
  system_prompt = """You are an expert email outreach specialist. Your task is to personalize email templates for B2B sales outreach.
@@ -125,9 +156,29 @@ Personalize this email template:
125
  Replace all variables like {{first_name}}, {{company}}, {{sender_name}} with the actual information.
126
  Make it sound natural and personalized. Keep the same structure and format.
127
  If sender_name is not provided, use "Alex Thompson" as the sender name."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
 
129
  # Use higher max_tokens for AP Automation comprehensive prompts
130
  max_tokens = 2000 if is_ap_automation_prompt else 1000
 
 
131
 
132
  response = client.chat.completions.create(
133
  model="gpt-4o-mini", # Using gpt-4o-mini for cost efficiency, can be changed to gpt-4
@@ -144,7 +195,7 @@ If sender_name is not provided, use "Alex Thompson" as the sender name."""
144
  # Parse multiple emails from the response
145
  emails = []
146
 
147
- if is_ap_automation_prompt:
148
  # Parse the structured format: Email 1, Email 2, etc.
149
  lines = generated_text.split('\n')
150
  current_email = None
@@ -312,7 +363,12 @@ If sender_name is not provided, use "Alex Thompson" as the sender name."""
312
  }]
313
 
314
 
315
- def generate_linkedin_sequence(contact: Dict, prompt_template: str, product_name: str) -> List[Dict]:
 
 
 
 
 
316
  """
317
  Generate LinkedIn DM-style messages (connection note + follow-ups) for a contact.
318
  Stored like email sequences with subject empty and channel=linkedin on the row.
@@ -340,12 +396,33 @@ def generate_linkedin_sequence(contact: Dict, prompt_template: str, product_name
340
  raise ValueError(f"Contact missing email address: {contact}")
341
 
342
  system_prompt = prompt_template
343
- num_messages = 3
344
- if "4-message" in prompt_template or "4 message" in prompt_template.lower():
345
- num_messages = 4
346
- elif "5-message" in prompt_template or "5 message" in prompt_template.lower():
347
- num_messages = 5
348
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
349
  user_prompt = f"""Generate a complete LinkedIn outreach sequence for this contact.
350
 
351
  Contact Information:
@@ -358,6 +435,7 @@ Contact Information:
358
  - Location: {location if location else 'Not specified'}
359
 
360
  Generate all {num_messages} messages following the rules in the system prompt.
 
361
  CRITICAL: Write for LinkedIn DMs or connection notes — short, plain, professional. No email-style subjects.
362
 
363
  Output format (strict):
 
16
  client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
17
 
18
 
19
+ def generate_email_sequence(
20
+ contact: Dict,
21
+ prompt_template: str,
22
+ product_name: str,
23
+ campaign_sequence: Optional[Dict[str, Any]] = None,
24
+ ) -> List[Dict]:
25
  """
26
  Generate a personalized email sequence for a contact using GPT.
27
  """
 
77
  num_emails = 9
78
  elif "10-email" in prompt_template or "10 email" in prompt_template:
79
  num_emails = 10
80
+ if campaign_sequence and campaign_sequence.get("email_count"):
81
+ try:
82
+ num_emails = max(1, min(10, int(campaign_sequence["email_count"])))
83
+ except (TypeError, ValueError):
84
+ pass
85
 
86
+ plan_block = ""
87
+ if campaign_sequence:
88
+ titles = campaign_sequence.get("email_titles") or []
89
+ waits = (campaign_sequence.get("wait_context") or "").strip()
90
+ if titles:
91
+ plan_block += "\n\n🔒 CAMPAIGN SEQUENCE (AUTHORITATIVE — OVERRIDES ANY OTHER EMAIL COUNT IN THIS PROMPT)\n"
92
+ plan_block += f"You MUST output exactly {num_emails} emails labeled Email 1 … Email {num_emails}.\n"
93
+ plan_block += "Each email corresponds to one Gmail touch in this campaign, in order:\n"
94
+ for i, t in enumerate(titles, start=1):
95
+ plan_block += f" {i}. {t}\n"
96
+ if waits:
97
+ plan_block += "\nDelays between touches (context only — do not output extra emails for waits):\n" + waits + "\n"
98
+ plan_block += (
99
+ "\nLinkedIn steps in this campaign are generated separately; write only the Gmail emails here.\n"
100
+ "Keep narrative continuity where a prospect may also see LinkedIn touches in the same week.\n"
101
+ )
102
+
103
+ user_prompt = (
104
+ f"""Generate a complete email sequence for this contact:
105
+ """
106
+ + plan_block
107
+ + f"""
108
  Contact Information:
109
  - First Name: {first_name}
110
  - Last Name: {last_name}
 
136
  ... (continue for all {num_emails} emails, each starting with "Hi {first_name},")
137
 
138
  Remember: Use only the information provided above. Do not reference anything not explicitly mentioned."""
139
+ )
140
  else:
141
  # Use standard prompt for other products
142
  system_prompt = """You are an expert email outreach specialist. Your task is to personalize email templates for B2B sales outreach.
 
156
  Replace all variables like {{first_name}}, {{company}}, {{sender_name}} with the actual information.
157
  Make it sound natural and personalized. Keep the same structure and format.
158
  If sender_name is not provided, use "Alex Thompson" as the sender name."""
159
+ if campaign_sequence and int(campaign_sequence.get("email_count") or 0) > 1:
160
+ n = int(campaign_sequence["email_count"])
161
+ titles = campaign_sequence.get("email_titles") or []
162
+ title_lines = "\n".join(f" {i}. {t}" for i, t in enumerate(titles, start=1)) if titles else ""
163
+ waits = (campaign_sequence.get("wait_context") or "").strip()
164
+ user_prompt += f"""
165
+
166
+ 🔒 CAMPAIGN SEQUENCE (AUTHORITATIVE)
167
+ Output exactly {n} emails for this contact, labeled Email 1 through Email {n}, each with Subject: and Body: lines (same structure as a multi-touch sequence).
168
+ Each body must start with "Hi {first_name},".
169
+ Touches in order:
170
+ {title_lines or "(see template)"}
171
+ {f"Delays between touches (context only): {waits}" if waits else ""}
172
+ """
173
+
174
+ use_structured_parse = is_ap_automation_prompt or (
175
+ campaign_sequence is not None and int(campaign_sequence.get("email_count") or 0) > 1
176
+ )
177
 
178
  # Use higher max_tokens for AP Automation comprehensive prompts
179
  max_tokens = 2000 if is_ap_automation_prompt else 1000
180
+ if campaign_sequence and int(campaign_sequence.get("email_count") or 0) > 1:
181
+ max_tokens = max(max_tokens, 2800)
182
 
183
  response = client.chat.completions.create(
184
  model="gpt-4o-mini", # Using gpt-4o-mini for cost efficiency, can be changed to gpt-4
 
195
  # Parse multiple emails from the response
196
  emails = []
197
 
198
+ if use_structured_parse:
199
  # Parse the structured format: Email 1, Email 2, etc.
200
  lines = generated_text.split('\n')
201
  current_email = None
 
363
  }]
364
 
365
 
366
+ def generate_linkedin_sequence(
367
+ contact: Dict,
368
+ prompt_template: str,
369
+ product_name: str,
370
+ campaign_sequence: Optional[Dict[str, Any]] = None,
371
+ ) -> List[Dict]:
372
  """
373
  Generate LinkedIn DM-style messages (connection note + follow-ups) for a contact.
374
  Stored like email sequences with subject empty and channel=linkedin on the row.
 
396
  raise ValueError(f"Contact missing email address: {contact}")
397
 
398
  system_prompt = prompt_template
399
+ li_actions: List[str] = []
400
+ if campaign_sequence and isinstance(campaign_sequence.get("linkedin_actions"), list):
401
+ li_actions = [str(x) for x in campaign_sequence["linkedin_actions"] if x]
402
+ if li_actions:
403
+ connects = [a for a in li_actions if a == "linkedin_connect"]
404
+ dms = [a for a in li_actions if a == "linkedin_dm"]
405
+ other = [a for a in li_actions if a not in ("linkedin_connect", "linkedin_dm")]
406
+ li_actions = connects + dms + other
407
+ num_messages = len(li_actions)
408
+ else:
409
+ num_messages = 3
410
+ if "4-message" in prompt_template or "4 message" in prompt_template.lower():
411
+ num_messages = 4
412
+ elif "5-message" in prompt_template or "5 message" in prompt_template.lower():
413
+ num_messages = 5
414
+
415
+ plan_extra = ""
416
+ if li_actions:
417
+ plan_extra = "\nStrict mapping:\n"
418
+ for i, act in enumerate(li_actions, start=1):
419
+ if act == "linkedin_connect":
420
+ plan_extra += f" Message {i}: LinkedIn CONNECTION REQUEST note (short, under 300 characters when possible).\n"
421
+ else:
422
+ plan_extra += f" Message {i}: LinkedIn DM / follow-up after connection (professional, concise).\n"
423
+ plan_extra += (
424
+ "\nThe first LinkedIn touch in this list is always a connection request before any follow-up DMs.\n"
425
+ )
426
  user_prompt = f"""Generate a complete LinkedIn outreach sequence for this contact.
427
 
428
  Contact Information:
 
435
  - Location: {location if location else 'Not specified'}
436
 
437
  Generate all {num_messages} messages following the rules in the system prompt.
438
+ {plan_extra}
439
  CRITICAL: Write for LinkedIn DMs or connection notes — short, plain, professional. No email-style subjects.
440
 
441
  Output format (strict):
backend/app/main.py CHANGED
@@ -12,7 +12,8 @@ import os
12
  import csv
13
  import io
14
  import concurrent.futures
15
- from typing import Dict, List, Optional
 
16
  import json
17
  import asyncio
18
  import math
@@ -3421,11 +3422,95 @@ async def save_prompts(request: PromptSaveRequest, t: TenantContext = Depends(ge
3421
  )
3422
 
3423
  db.commit()
 
 
 
 
 
 
 
 
 
 
 
3424
  return {"message": "Prompts saved successfully"}
3425
  except Exception as e:
3426
  raise HTTPException(status_code=500, detail=f"Error saving prompts: {str(e)}")
3427
 
3428
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3429
  @app.get("/api/generation-status")
3430
  async def generation_status(file_id: str = Query(...), t: TenantContext = Depends(get_tenant_context)):
3431
  """Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
@@ -3441,6 +3526,16 @@ async def generation_status(file_id: str = Query(...), t: TenantContext = Depend
3441
  if not db_file:
3442
  raise HTTPException(status_code=404, detail="File not found")
3443
  total_contacts = db_file.contact_count or 0
 
 
 
 
 
 
 
 
 
 
3444
  has_linkedin = (
3445
  db.query(Prompt.id)
3446
  .filter(
@@ -3471,14 +3566,27 @@ async def generation_status(file_id: str = Query(...), t: TenantContext = Depend
3471
  .distinct()
3472
  .count()
3473
  )
3474
- completed_units = email_done + (li_done if has_linkedin else 0)
3475
- expected_units = total_contacts * (2 if has_linkedin else 1)
 
 
 
 
 
 
 
 
 
 
 
 
3476
  return {
3477
  "file_id": file_id,
3478
  "total_contacts": total_contacts,
3479
  "completed_count": completed_units,
3480
  "is_complete": total_contacts > 0 and completed_units >= expected_units,
3481
  "has_linkedin_prompts": has_linkedin,
 
3482
  }
3483
 
3484
 
@@ -3492,7 +3600,11 @@ async def get_sequences(file_id: str = Query(...), t: TenantContext = Depends(ge
3492
  GeneratedSequence.tenant_id == t.tenant_id,
3493
  GeneratedSequence.file_id == file_id,
3494
  )
3495
- .order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
 
 
 
 
3496
  .all()
3497
  )
3498
  out = []
@@ -3509,6 +3621,7 @@ async def get_sequences(file_id: str = Query(...), t: TenantContext = Depends(ge
3509
  "subject": seq.subject,
3510
  "emailContent": seq.email_content,
3511
  "channel": getattr(seq, "channel", None) or "email",
 
3512
  })
3513
  return {"sequences": out}
3514
 
@@ -3555,13 +3668,31 @@ async def generate_sequences(
3555
  if getattr(p, "prompt_kind", None) == "linkedin"
3556
  }
3557
 
3558
- if not email_prompt_dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3559
  yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
3560
  return
 
 
 
3561
 
3562
- products = list(email_prompt_dict.keys())
3563
- li_products = list(linkedin_prompt_dict.keys())
3564
- run_linkedin = len(li_products) > 0
3565
 
3566
  if reset:
3567
  db.query(GeneratedSequence).filter(
@@ -3581,129 +3712,227 @@ async def generate_sequences(
3581
  return min(100.0, max(0.0, 50.0 + (seq_id / total_contacts) * 50.0))
3582
  return min(100.0, max(0.0, (seq_id / total_contacts) * 100.0))
3583
 
3584
- sequence_id = 1
3585
- for idx, row in df.iterrows():
3586
- existing = (
3587
- db.query(GeneratedSequence)
 
3588
  .filter(
3589
  GeneratedSequence.tenant_id == tenant_id,
3590
  GeneratedSequence.file_id == file_id,
3591
- GeneratedSequence.sequence_id == sequence_id,
3592
- GeneratedSequence.channel == "email",
3593
  )
3594
- .order_by(GeneratedSequence.email_number)
3595
- .all()
3596
  )
3597
- if existing:
3598
- for seq in existing:
3599
- sequence_response = {
3600
- "id": seq.sequence_id,
3601
- "emailNumber": seq.email_number,
3602
- "firstName": seq.first_name,
3603
- "lastName": seq.last_name,
3604
- "email": seq.email,
3605
- "company": seq.company,
3606
- "title": seq.title or "",
3607
- "product": seq.product,
3608
- "subject": seq.subject,
3609
- "emailContent": seq.email_content,
3610
- "channel": "email",
3611
- }
3612
- yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
3613
- yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=False)})}\n\n"
3614
- sequence_id += 1
3615
- await asyncio.sleep(0.05)
3616
- continue
3617
 
3618
- contact = row.to_dict()
3619
- product_name = products[(sequence_id - 1) % len(products)]
3620
- prompt_template = email_prompt_dict[product_name]
3621
-
3622
- loop = asyncio.get_event_loop()
3623
- with concurrent.futures.ThreadPoolExecutor() as executor:
3624
- sequence_data_list = await loop.run_in_executor(
3625
- executor,
3626
- generate_email_sequence,
3627
- contact,
3628
- prompt_template,
3629
- product_name,
3630
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3631
 
3632
- for seq_data in sequence_data_list:
3633
- db_sequence = GeneratedSequence(
3634
- tenant_id=tenant_id,
3635
- file_id=file_id,
3636
- sequence_id=sequence_id,
3637
- email_number=seq_data["email_number"],
3638
- channel="email",
3639
- first_name=seq_data["first_name"],
3640
- last_name=seq_data["last_name"],
3641
- email=seq_data["email"],
3642
- company=seq_data["company"],
3643
- title=seq_data.get("title", ""),
3644
- product=seq_data["product"],
3645
- subject=seq_data["subject"],
3646
- email_content=seq_data["email_content"],
3647
- )
3648
- db.add(db_sequence)
3649
 
3650
- db.commit()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3651
 
3652
- for seq_data in sequence_data_list:
3653
- sequence_response = {
3654
- "id": sequence_id,
3655
- "emailNumber": seq_data["email_number"],
3656
- "firstName": seq_data["first_name"],
3657
- "lastName": seq_data["last_name"],
3658
- "email": seq_data["email"],
3659
- "company": seq_data["company"],
3660
- "title": seq_data.get("title", ""),
3661
- "product": seq_data["product"],
3662
- "subject": seq_data["subject"],
3663
- "emailContent": seq_data["email_content"],
3664
- "channel": "email",
3665
- }
3666
- yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
 
 
 
 
 
 
3667
 
3668
- yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=False)})}\n\n"
3669
- sequence_id += 1
3670
- await asyncio.sleep(0.1)
 
3671
 
3672
  if run_linkedin:
3673
  yield f"data: {json.dumps({'type': 'phase', 'phase': 'linkedin', 'message': 'Generating LinkedIn sequences'})}\n\n"
3674
  sequence_id = 1
3675
  for idx, row in df.iterrows():
3676
- existing_li = (
3677
- db.query(GeneratedSequence)
3678
- .filter(
3679
- GeneratedSequence.tenant_id == tenant_id,
3680
- GeneratedSequence.file_id == file_id,
3681
- GeneratedSequence.sequence_id == sequence_id,
3682
- GeneratedSequence.channel == "linkedin",
 
 
 
 
3683
  )
3684
- .order_by(GeneratedSequence.email_number)
3685
- .all()
3686
- )
3687
- if existing_li:
3688
- for seq in existing_li:
3689
- sequence_response = {
3690
- "id": seq.sequence_id,
3691
- "emailNumber": seq.email_number,
3692
- "firstName": seq.first_name,
3693
- "lastName": seq.last_name,
3694
- "email": seq.email,
3695
- "company": seq.company,
3696
- "title": seq.title or "",
3697
- "product": seq.product,
3698
- "subject": seq.subject or "",
3699
- "emailContent": seq.email_content,
3700
- "channel": "linkedin",
3701
- }
3702
- yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
3703
- yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=True)})}\n\n"
3704
- sequence_id += 1
3705
- await asyncio.sleep(0.05)
3706
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3707
 
3708
  contact = row.to_dict()
3709
  li_product = li_products[(sequence_id - 1) % len(li_products)]
@@ -3711,15 +3940,28 @@ async def generate_sequences(
3711
 
3712
  loop = asyncio.get_event_loop()
3713
  with concurrent.futures.ThreadPoolExecutor() as executor:
3714
- li_list = await loop.run_in_executor(
3715
- executor,
3716
- generate_linkedin_sequence,
3717
- contact,
3718
- li_template,
3719
- li_product,
3720
- )
3721
-
3722
- for seq_data in li_list:
 
 
 
 
 
 
 
 
 
 
 
 
 
3723
  db_sequence = GeneratedSequence(
3724
  tenant_id=tenant_id,
3725
  file_id=file_id,
@@ -3734,12 +3976,16 @@ async def generate_sequences(
3734
  product=seq_data["product"],
3735
  subject=seq_data.get("subject") or "",
3736
  email_content=seq_data["email_content"],
 
3737
  )
3738
  db.add(db_sequence)
3739
 
3740
  db.commit()
3741
 
3742
- for seq_data in li_list:
 
 
 
3743
  sequence_response = {
3744
  "id": sequence_id,
3745
  "emailNumber": seq_data["email_number"],
@@ -3752,10 +3998,12 @@ async def generate_sequences(
3752
  "subject": seq_data.get("subject") or "",
3753
  "emailContent": seq_data["email_content"],
3754
  "channel": "linkedin",
 
3755
  }
3756
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
3757
 
3758
- yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=True)})}\n\n"
 
3759
  sequence_id += 1
3760
  await asyncio.sleep(0.1)
3761
 
 
12
  import csv
13
  import io
14
  import concurrent.futures
15
+ from functools import partial
16
+ from typing import Any, Dict, List, Optional
17
  import json
18
  import asyncio
19
  import math
 
3422
  )
3423
 
3424
  db.commit()
3425
+
3426
+ uf = (
3427
+ db.query(UploadedFile)
3428
+ .filter(UploadedFile.tenant_id == t.tenant_id, UploadedFile.file_id == request.file_id)
3429
+ .first()
3430
+ )
3431
+ if uf is not None:
3432
+ if request.sequence_plan is not None:
3433
+ uf.sequence_plan_json = json.dumps(request.sequence_plan)
3434
+ db.commit()
3435
+
3436
  return {"message": "Prompts saved successfully"}
3437
  except Exception as e:
3438
  raise HTTPException(status_code=500, detail=f"Error saving prompts: {str(e)}")
3439
 
3440
 
3441
+ def _normalize_campaign_plan_steps(raw_data) -> Optional[List[Dict]]:
3442
+ if raw_data is None:
3443
+ return None
3444
+ if isinstance(raw_data, dict) and isinstance(raw_data.get("steps"), list):
3445
+ return raw_data["steps"]
3446
+ if isinstance(raw_data, list):
3447
+ return raw_data
3448
+ return None
3449
+
3450
+
3451
+ def _parse_campaign_plan_pack(db_file) -> Optional[Dict[str, Any]]:
3452
+ """Build GPT kwargs + per-row step_order alignment from wizard sequence JSON on UploadedFile."""
3453
+ raw = getattr(db_file, "sequence_plan_json", None)
3454
+ if not raw or not str(raw).strip():
3455
+ return None
3456
+ try:
3457
+ parsed = json.loads(raw)
3458
+ except Exception:
3459
+ return None
3460
+ steps = _normalize_campaign_plan_steps(parsed)
3461
+ if not steps:
3462
+ return None
3463
+ gmail_specs: List[Dict[str, Any]] = []
3464
+ li_wizard: List[Dict[str, Any]] = []
3465
+ wait_lines: List[str] = []
3466
+ ord_global = 0
3467
+ for s in steps:
3468
+ if not isinstance(s, dict):
3469
+ continue
3470
+ if s.get("type") == "wait":
3471
+ try:
3472
+ d = int(s.get("days") or 1)
3473
+ except (TypeError, ValueError):
3474
+ d = 1
3475
+ wait_lines.append(f"Wait {d} day(s) between surrounding sequence touches.")
3476
+ continue
3477
+ if s.get("type") != "action":
3478
+ continue
3479
+ ord_global += 1
3480
+ ch = s.get("channel")
3481
+ act = s.get("action")
3482
+ title = (s.get("title") or "").strip() or ("Email" if ch == "gmail" else "LinkedIn touch")
3483
+ if ch == "gmail":
3484
+ gmail_specs.append({"step_order": ord_global, "title": title})
3485
+ elif ch == "linkedin" and act in ("linkedin_connect", "linkedin_dm"):
3486
+ li_wizard.append({"step_order": ord_global, "action": act, "title": title})
3487
+ li_connects = [x for x in li_wizard if x["action"] == "linkedin_connect"]
3488
+ li_dms = [x for x in li_wizard if x["action"] == "linkedin_dm"]
3489
+ li_other = [x for x in li_wizard if x["action"] not in ("linkedin_connect", "linkedin_dm")]
3490
+ li_for_gpt = li_connects + li_dms + li_other
3491
+ total_actions = len(gmail_specs) + len(li_wizard)
3492
+ if total_actions == 0:
3493
+ return None
3494
+ email_campaign_kw = None
3495
+ if gmail_specs:
3496
+ email_campaign_kw = {
3497
+ "email_count": len(gmail_specs),
3498
+ "email_titles": [g["title"] for g in gmail_specs],
3499
+ "wait_context": "\n".join(wait_lines),
3500
+ }
3501
+ linkedin_campaign_kw = None
3502
+ if li_for_gpt:
3503
+ linkedin_campaign_kw = {"linkedin_actions": [x["action"] for x in li_for_gpt]}
3504
+ return {
3505
+ "gmail_specs": gmail_specs,
3506
+ "li_wizard": li_wizard,
3507
+ "li_for_gpt": li_for_gpt,
3508
+ "total_actions": total_actions,
3509
+ "email_campaign_kw": email_campaign_kw,
3510
+ "linkedin_campaign_kw": linkedin_campaign_kw,
3511
+ }
3512
+
3513
+
3514
  @app.get("/api/generation-status")
3515
  async def generation_status(file_id: str = Query(...), t: TenantContext = Depends(get_tenant_context)):
3516
  """Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
 
3526
  if not db_file:
3527
  raise HTTPException(status_code=404, detail="File not found")
3528
  total_contacts = db_file.contact_count or 0
3529
+ plan_actions = 0
3530
+ if getattr(db_file, "sequence_plan_json", None):
3531
+ try:
3532
+ raw = json.loads(db_file.sequence_plan_json)
3533
+ if isinstance(raw, list):
3534
+ plan_actions = sum(1 for s in raw if isinstance(s, dict) and s.get("type") == "action")
3535
+ elif isinstance(raw, dict) and isinstance(raw.get("steps"), list):
3536
+ plan_actions = sum(1 for s in raw["steps"] if isinstance(s, dict) and s.get("type") == "action")
3537
+ except Exception:
3538
+ plan_actions = 0
3539
  has_linkedin = (
3540
  db.query(Prompt.id)
3541
  .filter(
 
3566
  .distinct()
3567
  .count()
3568
  )
3569
+ if plan_actions > 0:
3570
+ row_count = (
3571
+ db.query(GeneratedSequence.id)
3572
+ .filter(
3573
+ GeneratedSequence.tenant_id == t.tenant_id,
3574
+ GeneratedSequence.file_id == file_id,
3575
+ )
3576
+ .count()
3577
+ )
3578
+ completed_units = row_count
3579
+ expected_units = total_contacts * plan_actions
3580
+ else:
3581
+ completed_units = email_done + (li_done if has_linkedin else 0)
3582
+ expected_units = total_contacts * (2 if has_linkedin else 1)
3583
  return {
3584
  "file_id": file_id,
3585
  "total_contacts": total_contacts,
3586
  "completed_count": completed_units,
3587
  "is_complete": total_contacts > 0 and completed_units >= expected_units,
3588
  "has_linkedin_prompts": has_linkedin,
3589
+ "campaign_sequence_actions": plan_actions or None,
3590
  }
3591
 
3592
 
 
3600
  GeneratedSequence.tenant_id == t.tenant_id,
3601
  GeneratedSequence.file_id == file_id,
3602
  )
3603
+ .order_by(
3604
+ GeneratedSequence.sequence_id,
3605
+ func.coalesce(GeneratedSequence.step_order, 999999),
3606
+ GeneratedSequence.email_number,
3607
+ )
3608
  .all()
3609
  )
3610
  out = []
 
3621
  "subject": seq.subject,
3622
  "emailContent": seq.email_content,
3623
  "channel": getattr(seq, "channel", None) or "email",
3624
+ "stepOrder": getattr(seq, "step_order", None),
3625
  })
3626
  return {"sequences": out}
3627
 
 
3668
  if getattr(p, "prompt_kind", None) == "linkedin"
3669
  }
3670
 
3671
+ plan_pack = _parse_campaign_plan_pack(db_file)
3672
+ run_email = True
3673
+ run_linkedin = len(linkedin_prompt_dict) > 0
3674
+ email_kw: Optional[Dict[str, Any]] = None
3675
+ li_kw: Optional[Dict[str, Any]] = None
3676
+ gmail_specs: List[Dict[str, Any]] = []
3677
+ li_meta_for_rows: List[Dict[str, Any]] = []
3678
+
3679
+ if plan_pack:
3680
+ run_email = bool(plan_pack.get("email_campaign_kw"))
3681
+ run_linkedin = bool(plan_pack.get("linkedin_campaign_kw"))
3682
+ email_kw = plan_pack.get("email_campaign_kw")
3683
+ li_kw = plan_pack.get("linkedin_campaign_kw")
3684
+ gmail_specs = plan_pack.get("gmail_specs") or []
3685
+ li_meta_for_rows = plan_pack.get("li_for_gpt") or []
3686
+
3687
+ if run_email and not email_prompt_dict:
3688
  yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
3689
  return
3690
+ if run_linkedin and not linkedin_prompt_dict:
3691
+ yield f"data: {json.dumps({'type': 'error', 'error': 'No LinkedIn prompts found'})}\n\n"
3692
+ return
3693
 
3694
+ products = list(email_prompt_dict.keys()) if email_prompt_dict else []
3695
+ li_products = list(linkedin_prompt_dict.keys()) if linkedin_prompt_dict else []
 
3696
 
3697
  if reset:
3698
  db.query(GeneratedSequence).filter(
 
3712
  return min(100.0, max(0.0, 50.0 + (seq_id / total_contacts) * 50.0))
3713
  return min(100.0, max(0.0, (seq_id / total_contacts) * 100.0))
3714
 
3715
+ def plan_progress_pct() -> float:
3716
+ if not plan_pack or total_contacts <= 0:
3717
+ return 0.0
3718
+ cnt = (
3719
+ db.query(func.count(GeneratedSequence.id))
3720
  .filter(
3721
  GeneratedSequence.tenant_id == tenant_id,
3722
  GeneratedSequence.file_id == file_id,
 
 
3723
  )
3724
+ .scalar()
3725
+ or 0
3726
  )
3727
+ exp = total_contacts * plan_pack["total_actions"]
3728
+ return min(100.0, max(0.0, (cnt / max(1, exp)) * 100.0))
3729
+
3730
+ def orm_to_sequence_response(seq: GeneratedSequence) -> Dict[str, Any]:
3731
+ ch = getattr(seq, "channel", None) or "email"
3732
+ return {
3733
+ "id": seq.sequence_id,
3734
+ "emailNumber": seq.email_number,
3735
+ "firstName": seq.first_name,
3736
+ "lastName": seq.last_name,
3737
+ "email": seq.email,
3738
+ "company": seq.company,
3739
+ "title": seq.title or "",
3740
+ "product": seq.product,
3741
+ "subject": (seq.subject or "") if ch == "linkedin" else seq.subject,
3742
+ "emailContent": seq.email_content,
3743
+ "channel": ch,
3744
+ "stepOrder": getattr(seq, "step_order", None),
3745
+ }
 
3746
 
3747
+ sequence_id = 1
3748
+ if run_email:
3749
+ for idx, row in df.iterrows():
3750
+ if plan_pack and gmail_specs:
3751
+ email_done_n = (
3752
+ db.query(func.count(GeneratedSequence.id))
3753
+ .filter(
3754
+ GeneratedSequence.tenant_id == tenant_id,
3755
+ GeneratedSequence.file_id == file_id,
3756
+ GeneratedSequence.sequence_id == sequence_id,
3757
+ GeneratedSequence.channel == "email",
3758
+ )
3759
+ .scalar()
3760
+ or 0
3761
+ )
3762
+ if email_done_n >= len(gmail_specs):
3763
+ existing_em = (
3764
+ db.query(GeneratedSequence)
3765
+ .filter(
3766
+ GeneratedSequence.tenant_id == tenant_id,
3767
+ GeneratedSequence.file_id == file_id,
3768
+ GeneratedSequence.sequence_id == sequence_id,
3769
+ GeneratedSequence.channel == "email",
3770
+ )
3771
+ .order_by(
3772
+ func.coalesce(GeneratedSequence.step_order, 999999),
3773
+ GeneratedSequence.email_number,
3774
+ )
3775
+ .all()
3776
+ )
3777
+ for seq in existing_em:
3778
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': orm_to_sequence_response(seq)})}\n\n"
3779
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=False)
3780
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
3781
+ sequence_id += 1
3782
+ await asyncio.sleep(0.05)
3783
+ continue
3784
+ else:
3785
+ existing = (
3786
+ db.query(GeneratedSequence)
3787
+ .filter(
3788
+ GeneratedSequence.tenant_id == tenant_id,
3789
+ GeneratedSequence.file_id == file_id,
3790
+ GeneratedSequence.sequence_id == sequence_id,
3791
+ GeneratedSequence.channel == "email",
3792
+ )
3793
+ .order_by(GeneratedSequence.email_number)
3794
+ .all()
3795
+ )
3796
+ if existing:
3797
+ for seq in existing:
3798
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': orm_to_sequence_response(seq)})}\n\n"
3799
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=False)
3800
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
3801
+ sequence_id += 1
3802
+ await asyncio.sleep(0.05)
3803
+ continue
3804
 
3805
+ contact = row.to_dict()
3806
+ product_name = products[(sequence_id - 1) % len(products)]
3807
+ prompt_template = email_prompt_dict[product_name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3808
 
3809
+ loop = asyncio.get_event_loop()
3810
+ with concurrent.futures.ThreadPoolExecutor() as executor:
3811
+ if plan_pack and email_kw:
3812
+ gen_fn = partial(generate_email_sequence, campaign_sequence=email_kw)
3813
+ sequence_data_list = await loop.run_in_executor(
3814
+ executor,
3815
+ gen_fn,
3816
+ contact,
3817
+ prompt_template,
3818
+ product_name,
3819
+ )
3820
+ else:
3821
+ sequence_data_list = await loop.run_in_executor(
3822
+ executor,
3823
+ generate_email_sequence,
3824
+ contact,
3825
+ prompt_template,
3826
+ product_name,
3827
+ )
3828
+
3829
+ for i, seq_data in enumerate(sequence_data_list):
3830
+ step_order_v = None
3831
+ if plan_pack and gmail_specs and i < len(gmail_specs):
3832
+ step_order_v = gmail_specs[i]["step_order"]
3833
+ db_sequence = GeneratedSequence(
3834
+ tenant_id=tenant_id,
3835
+ file_id=file_id,
3836
+ sequence_id=sequence_id,
3837
+ email_number=seq_data["email_number"],
3838
+ channel="email",
3839
+ first_name=seq_data["first_name"],
3840
+ last_name=seq_data["last_name"],
3841
+ email=seq_data["email"],
3842
+ company=seq_data["company"],
3843
+ title=seq_data.get("title", ""),
3844
+ product=seq_data["product"],
3845
+ subject=seq_data["subject"],
3846
+ email_content=seq_data["email_content"],
3847
+ step_order=step_order_v,
3848
+ )
3849
+ db.add(db_sequence)
3850
 
3851
+ db.commit()
3852
+
3853
+ for i, seq_data in enumerate(sequence_data_list):
3854
+ step_order_v = None
3855
+ if plan_pack and gmail_specs and i < len(gmail_specs):
3856
+ step_order_v = gmail_specs[i]["step_order"]
3857
+ sequence_response = {
3858
+ "id": sequence_id,
3859
+ "emailNumber": seq_data["email_number"],
3860
+ "firstName": seq_data["first_name"],
3861
+ "lastName": seq_data["last_name"],
3862
+ "email": seq_data["email"],
3863
+ "company": seq_data["company"],
3864
+ "title": seq_data.get("title", ""),
3865
+ "product": seq_data["product"],
3866
+ "subject": seq_data["subject"],
3867
+ "emailContent": seq_data["email_content"],
3868
+ "channel": "email",
3869
+ "stepOrder": step_order_v,
3870
+ }
3871
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
3872
 
3873
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=False)
3874
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
3875
+ sequence_id += 1
3876
+ await asyncio.sleep(0.1)
3877
 
3878
  if run_linkedin:
3879
  yield f"data: {json.dumps({'type': 'phase', 'phase': 'linkedin', 'message': 'Generating LinkedIn sequences'})}\n\n"
3880
  sequence_id = 1
3881
  for idx, row in df.iterrows():
3882
+ if plan_pack and li_meta_for_rows:
3883
+ li_done_n = (
3884
+ db.query(func.count(GeneratedSequence.id))
3885
+ .filter(
3886
+ GeneratedSequence.tenant_id == tenant_id,
3887
+ GeneratedSequence.file_id == file_id,
3888
+ GeneratedSequence.sequence_id == sequence_id,
3889
+ GeneratedSequence.channel == "linkedin",
3890
+ )
3891
+ .scalar()
3892
+ or 0
3893
  )
3894
+ if li_done_n >= len(li_meta_for_rows):
3895
+ existing_li = (
3896
+ db.query(GeneratedSequence)
3897
+ .filter(
3898
+ GeneratedSequence.tenant_id == tenant_id,
3899
+ GeneratedSequence.file_id == file_id,
3900
+ GeneratedSequence.sequence_id == sequence_id,
3901
+ GeneratedSequence.channel == "linkedin",
3902
+ )
3903
+ .order_by(
3904
+ func.coalesce(GeneratedSequence.step_order, 999999),
3905
+ GeneratedSequence.email_number,
3906
+ )
3907
+ .all()
3908
+ )
3909
+ for seq in existing_li:
3910
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': orm_to_sequence_response(seq)})}\n\n"
3911
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=True)
3912
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
3913
+ sequence_id += 1
3914
+ await asyncio.sleep(0.05)
3915
+ continue
3916
+ else:
3917
+ existing_li = (
3918
+ db.query(GeneratedSequence)
3919
+ .filter(
3920
+ GeneratedSequence.tenant_id == tenant_id,
3921
+ GeneratedSequence.file_id == file_id,
3922
+ GeneratedSequence.sequence_id == sequence_id,
3923
+ GeneratedSequence.channel == "linkedin",
3924
+ )
3925
+ .order_by(GeneratedSequence.email_number)
3926
+ .all()
3927
+ )
3928
+ if existing_li:
3929
+ for seq in existing_li:
3930
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': orm_to_sequence_response(seq)})}\n\n"
3931
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=True)
3932
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
3933
+ sequence_id += 1
3934
+ await asyncio.sleep(0.05)
3935
+ continue
3936
 
3937
  contact = row.to_dict()
3938
  li_product = li_products[(sequence_id - 1) % len(li_products)]
 
3940
 
3941
  loop = asyncio.get_event_loop()
3942
  with concurrent.futures.ThreadPoolExecutor() as executor:
3943
+ if plan_pack and li_kw:
3944
+ gen_li = partial(generate_linkedin_sequence, campaign_sequence=li_kw)
3945
+ li_list = await loop.run_in_executor(
3946
+ executor,
3947
+ gen_li,
3948
+ contact,
3949
+ li_template,
3950
+ li_product,
3951
+ )
3952
+ else:
3953
+ li_list = await loop.run_in_executor(
3954
+ executor,
3955
+ generate_linkedin_sequence,
3956
+ contact,
3957
+ li_template,
3958
+ li_product,
3959
+ )
3960
+
3961
+ for i, seq_data in enumerate(li_list):
3962
+ step_order_v = None
3963
+ if plan_pack and li_meta_for_rows and i < len(li_meta_for_rows):
3964
+ step_order_v = li_meta_for_rows[i]["step_order"]
3965
  db_sequence = GeneratedSequence(
3966
  tenant_id=tenant_id,
3967
  file_id=file_id,
 
3976
  product=seq_data["product"],
3977
  subject=seq_data.get("subject") or "",
3978
  email_content=seq_data["email_content"],
3979
+ step_order=step_order_v,
3980
  )
3981
  db.add(db_sequence)
3982
 
3983
  db.commit()
3984
 
3985
+ for i, seq_data in enumerate(li_list):
3986
+ step_order_v = None
3987
+ if plan_pack and li_meta_for_rows and i < len(li_meta_for_rows):
3988
+ step_order_v = li_meta_for_rows[i]["step_order"]
3989
  sequence_response = {
3990
  "id": sequence_id,
3991
  "emailNumber": seq_data["email_number"],
 
3998
  "subject": seq_data.get("subject") or "",
3999
  "emailContent": seq_data["email_content"],
4000
  "channel": "linkedin",
4001
+ "stepOrder": step_order_v,
4002
  }
4003
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
4004
 
4005
+ prog = plan_progress_pct() if plan_pack else progress_pct(sequence_id, linkedin_phase=True)
4006
+ yield f"data: {json.dumps({'type': 'progress', 'progress': prog})}\n\n"
4007
  sequence_id += 1
4008
  await asyncio.sleep(0.1)
4009
 
backend/app/models.py CHANGED
@@ -14,6 +14,7 @@ class PromptSaveRequest(BaseModel):
14
  prompts: Dict[str, str]
15
  products: List[str]
16
  linkedin_prompts: Dict[str, str] = Field(default_factory=dict)
 
17
 
18
 
19
  class SequenceResponse(BaseModel):
 
14
  prompts: Dict[str, str]
15
  products: List[str]
16
  linkedin_prompts: Dict[str, str] = Field(default_factory=dict)
17
+ sequence_plan: Optional[List[Dict]] = Field(default=None)
18
 
19
 
20
  class SequenceResponse(BaseModel):
frontend/src/components/campaigns/CampaignsDashboardTab.jsx CHANGED
@@ -144,6 +144,7 @@ export default function CampaignsDashboardTab() {
144
  name: payload.name,
145
  status: 'running',
146
  contacts,
 
147
  openRate: null,
148
  replyRate: null,
149
  teamExtra: 0,
 
144
  name: payload.name,
145
  status: 'running',
146
  contacts,
147
+ fileId: payload.fileId || null,
148
  openRate: null,
149
  replyRate: null,
150
  teamExtra: 0,
frontend/src/components/campaigns/CreateCampaignWizard.jsx CHANGED
@@ -1,11 +1,20 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
2
  import { apiFetch } from '@/lib/api';
 
3
  import { createPortal } from 'react-dom';
4
- import { X, Upload, ArrowRight, ArrowLeft } from 'lucide-react';
5
  import { Button } from '@/components/ui/button';
6
  import { Input } from '@/components/ui/input';
 
 
7
  import { cn } from '@/lib/utils';
8
  import CampaignSequenceBuilder, { createDefaultSequenceSteps } from '@/components/campaigns/CampaignSequenceBuilder';
 
 
 
 
 
 
9
 
10
  const STEPS = [
11
  { id: 1, label: 'Upload & Select' },
@@ -14,6 +23,8 @@ const STEPS = [
14
  { id: 4, label: 'Review & Launch' },
15
  ];
16
 
 
 
17
  function estimateCsvRows(file) {
18
  if (!file) return 0;
19
  return new Promise((resolve) => {
@@ -28,23 +39,223 @@ function estimateCsvRows(file) {
28
  });
29
  }
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  export default function CreateCampaignWizard({ open, onOpenChange, onComplete }) {
32
  const [step, setStep] = useState(1);
33
  const [campaignName, setCampaignName] = useState('');
34
  const [prospectFile, setProspectFile] = useState(null);
 
 
35
  const [dragOver, setDragOver] = useState(false);
36
  const [estimatedContacts, setEstimatedContacts] = useState(0);
37
  const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
38
  const [linkedinDefaults, setLinkedinDefaults] = useState(null);
39
  const [mailboxDefaults, setMailboxDefaults] = useState(null);
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  const reset = useCallback(() => {
 
 
 
 
42
  setStep(1);
43
  setCampaignName('');
44
  setProspectFile(null);
 
 
45
  setEstimatedContacts(0);
46
  setDragOver(false);
47
  setSequenceSteps(createDefaultSequenceSteps());
 
 
 
 
 
 
 
 
 
 
 
48
  }, []);
49
 
50
  useEffect(() => {
@@ -129,15 +340,161 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
129
  );
130
  }, [linkedinDefaults]);
131
 
132
- const pickFile = (file) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  if (!file) return;
134
- const ok =
135
- file.name.toLowerCase().endsWith('.csv') || file.name.toLowerCase().endsWith('.xlsx');
136
- if (!ok) return;
 
137
  setProspectFile(file);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  };
139
 
140
- const canContinueStep1 = campaignName.trim().length > 0 && prospectFile;
 
 
 
 
 
 
 
 
 
 
141
 
142
  const handleContinue = () => {
143
  if (step === 1 && !canContinueStep1) return;
@@ -148,6 +505,39 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
148
  if (step > 1) setStep((s) => s - 1);
149
  };
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  const handleLaunch = () => {
152
  if (!onComplete) {
153
  onOpenChange(false);
@@ -155,8 +545,9 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
155
  }
156
  onComplete({
157
  name: campaignName.trim(),
158
- contacts: estimatedContacts || 0,
159
- prospectFileName: prospectFile?.name || '',
 
160
  sequence: sequenceSteps,
161
  });
162
  onOpenChange(false);
@@ -181,7 +572,7 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
181
  <div
182
  className={cn(
183
  'relative z-[101] flex max-h-[min(92vh,900px)] w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-violet-200/30',
184
- step === 2 ? 'max-w-4xl' : 'max-w-3xl'
185
  )}
186
  >
187
  <div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
@@ -197,7 +588,6 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
197
  </button>
198
  </div>
199
 
200
- {/* Stepper */}
201
  <div className="border-b border-slate-100 bg-slate-50/80 px-4 py-4 sm:px-6">
202
  <div className="flex flex-wrap items-start justify-between gap-4">
203
  {STEPS.map((s) => {
@@ -271,12 +661,12 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
271
  const f = e.dataTransfer.files?.[0];
272
  pickFile(f);
273
  }}
274
- onClick={() => document.getElementById('wizard-csv-input')?.click()}
275
  >
276
  <input
277
  id="wizard-csv-input"
278
  type="file"
279
- accept=".csv,.xlsx"
280
  className="hidden"
281
  onChange={(e) => pickFile(e.target.files?.[0])}
282
  />
@@ -290,6 +680,7 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
290
  type="button"
291
  variant="outline"
292
  className="mt-4"
 
293
  onClick={(e) => {
294
  e.stopPropagation();
295
  document.getElementById('wizard-csv-input')?.click();
@@ -297,13 +688,19 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
297
  >
298
  Browse Files
299
  </Button>
300
- {prospectFile ? (
 
 
301
  <p className="mt-3 text-xs font-medium text-violet-700">
302
- {prospectFile.name}
303
- {estimatedContacts > 0
304
- ? ` · ~${estimatedContacts.toLocaleString()} rows`
305
- : ''}
 
 
306
  </p>
 
 
307
  ) : null}
308
  </div>
309
  </div>
@@ -316,10 +713,76 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
316
  mailboxDefaults={mailboxDefaults}
317
  />
318
  ) : step === 3 ? (
319
- <PlaceholderStep
320
- title="Generate Contents"
321
- body="AI-generated messages and personalization will live here. Details coming soon."
322
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  ) : (
324
  <div className="space-y-6">
325
  <PlaceholderStep
@@ -333,23 +796,41 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
333
  <p className="font-semibold text-slate-900">{campaignName || '—'}</p>
334
  </div>
335
  <div>
336
- <span className="text-slate-500">Prospects (estimate)</span>
337
  <p className="font-semibold text-slate-900">
338
- {estimatedContacts > 0
339
- ? estimatedContacts.toLocaleString()
340
- : prospectFile
341
- ? 'Calculating…'
342
  : '—'}
343
  </p>
344
  </div>
345
  <div className="sm:col-span-2">
346
  <span className="text-slate-500">File</span>
347
- <p className="font-medium text-slate-800">{prospectFile?.name || '—'}</p>
 
 
348
  </div>
 
 
 
 
 
 
 
 
349
  <div className="sm:col-span-2">
350
  <span className="text-slate-500">Sequence</span>
351
  <p className="font-medium text-slate-800">
352
- {sequenceSteps.length} step{sequenceSteps.length !== 1 ? 's' : ''} configured
 
 
 
 
 
 
 
 
353
  </p>
354
  </div>
355
  </div>
@@ -394,7 +875,9 @@ export default function CreateCampaignWizard({ open, onOpenChange, onComplete })
394
  >
395
  {step === 1
396
  ? 'Continue to Sequence'
397
- : `Continue to ${STEPS[step]?.label ?? 'next'}`}
 
 
398
  <ArrowRight className="ml-2 h-4 w-4" />
399
  </Button>
400
  ) : null}
 
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
  import { apiFetch } from '@/lib/api';
3
+ import { mergeSequenceIntoContacts } from '@/lib/mergeSequenceIntoContacts';
4
  import { createPortal } from 'react-dom';
5
+ import { X, Upload, ArrowRight, ArrowLeft, Sparkles, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
6
  import { Button } from '@/components/ui/button';
7
  import { Input } from '@/components/ui/input';
8
+ import { Progress } from '@/components/ui/progress';
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
10
  import { cn } from '@/lib/utils';
11
  import CampaignSequenceBuilder, { createDefaultSequenceSteps } from '@/components/campaigns/CampaignSequenceBuilder';
12
+ import ProductSelector from '@/components/products/ProductSelector';
13
+ import PromptEditor, {
14
+ DEFAULT_TEMPLATES,
15
+ LINKEDIN_DEFAULT_TEMPLATES,
16
+ } from '@/components/prompts/PromptEditor';
17
+ import SequenceCard from '@/components/sequences/SequenceCard';
18
 
19
  const STEPS = [
20
  { id: 1, label: 'Upload & Select' },
 
23
  { id: 4, label: 'Review & Launch' },
24
  ];
25
 
26
+ const WIZARD_SEQUENCE_TAG = '🔒 CAMPAIGN WIZARD — SEQUENCE';
27
+
28
  function estimateCsvRows(file) {
29
  if (!file) return 0;
30
  return new Promise((resolve) => {
 
39
  });
40
  }
41
 
42
+ function buildCampaignSequenceAppendix(steps) {
43
+ const lines = [
44
+ WIZARD_SEQUENCE_TAG,
45
+ '(AUTHORITATIVE — OVERRIDES ANY FIXED “N EMAILS” OR “N MESSAGES” COUNT IN THIS PROMPT)',
46
+ '',
47
+ 'The campaign sequence is fixed. Generate content only for the channels below; the system maps rows to Gmail vs LinkedIn.',
48
+ 'Respect wait steps as pacing context between touches (do not invent extra touches for waits).',
49
+ '',
50
+ ];
51
+ let n = 0;
52
+ (steps || []).forEach((s) => {
53
+ if (s.type === 'wait') {
54
+ lines.push(`• Wait ${s.days ?? 1} day(s)`);
55
+ }
56
+ if (s.type === 'action') {
57
+ n += 1;
58
+ if (s.channel === 'gmail') {
59
+ lines.push(`${n}. Gmail — ${s.title || 'Email'}`);
60
+ } else if (s.channel === 'linkedin') {
61
+ const kind =
62
+ s.action === 'linkedin_connect'
63
+ ? 'LinkedIn connection request (must be the first LinkedIn output before any DM)'
64
+ : 'LinkedIn DM / follow-up';
65
+ lines.push(`${n}. ${kind} — ${s.title || ''}`);
66
+ }
67
+ }
68
+ });
69
+ lines.push(
70
+ '',
71
+ 'LinkedIn: the first LinkedIn message in your output must be the connection request whenever the sequence includes a connection step before DMs.',
72
+ 'Keep narrative continuity with Gmail touches in the same campaign week where relevant.'
73
+ );
74
+ return lines.join('\n');
75
+ }
76
+
77
+ function WizardSequencePreview({
78
+ contacts,
79
+ sequences,
80
+ isGenerating,
81
+ generationComplete,
82
+ progress,
83
+ contactCount,
84
+ selectedProducts,
85
+ sequenceHasLinkedin,
86
+ genPhase,
87
+ }) {
88
+ const [searchQuery, setSearchQuery] = useState('');
89
+ const [filterProduct, setFilterProduct] = useState('all');
90
+ const [displayedCount, setDisplayedCount] = useState(50);
91
+
92
+ useEffect(() => {
93
+ setDisplayedCount(50);
94
+ }, [searchQuery, filterProduct]);
95
+
96
+ const filteredContacts = contacts.filter((contact) => {
97
+ const matchesSearch =
98
+ searchQuery === '' ||
99
+ contact.firstName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
100
+ contact.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
101
+ contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
102
+ contact.email?.toLowerCase().includes(searchQuery.toLowerCase());
103
+ const matchesFilter = filterProduct === 'all' || contact.product === filterProduct;
104
+ return matchesSearch && matchesFilter;
105
+ });
106
+ const displayedContacts = filteredContacts.slice(0, displayedCount);
107
+ const hasMore = filteredContacts.length > displayedCount;
108
+ const showProgress = isGenerating || !generationComplete;
109
+ const phaseLabel =
110
+ sequenceHasLinkedin && genPhase === 'linkedin' && isGenerating
111
+ ? 'LinkedIn sequences'
112
+ : 'Email sequences';
113
+
114
+ return (
115
+ <div className="w-full space-y-4">
116
+ <div className="mb-2 rounded-2xl border border-slate-200 bg-white p-6">
117
+ <div className="mb-4 flex items-center justify-between">
118
+ <div className="flex items-center gap-3">
119
+ {generationComplete ? (
120
+ <div className="rounded-xl bg-green-100 p-3">
121
+ <CheckCircle2 className="h-6 w-6 text-green-600" />
122
+ </div>
123
+ ) : (
124
+ <div className="rounded-xl bg-violet-100 p-3">
125
+ <Loader2 className="h-6 w-6 animate-spin text-violet-600" />
126
+ </div>
127
+ )}
128
+ <div>
129
+ <h3 className="font-semibold text-slate-800">
130
+ {generationComplete ? 'Generation complete!' : `Generating ${phaseLabel}…`}
131
+ </h3>
132
+ <p className="text-sm text-slate-500">
133
+ {contacts.length} contacts · {sequences.length} generated rows
134
+ {contactCount ? ` · ~${contactCount} contacts in file` : ''}
135
+ </p>
136
+ </div>
137
+ </div>
138
+ </div>
139
+ {showProgress ? <Progress value={progress} className="h-2" /> : null}
140
+ </div>
141
+
142
+ {sequences.length > 0 && (
143
+ <div className="flex flex-col gap-3 sm:flex-row">
144
+ <div className="relative flex-1">
145
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
146
+ <Input
147
+ placeholder="Search contacts…"
148
+ value={searchQuery}
149
+ onChange={(e) => setSearchQuery(e.target.value)}
150
+ className="pl-10"
151
+ />
152
+ </div>
153
+ <Select value={filterProduct} onValueChange={setFilterProduct}>
154
+ <SelectTrigger className="w-full sm:w-48">
155
+ <Filter className="mr-2 h-4 w-4 text-slate-400" />
156
+ <SelectValue placeholder="Filter by product" />
157
+ </SelectTrigger>
158
+ <SelectContent>
159
+ <SelectItem value="all">All Products</SelectItem>
160
+ {selectedProducts.map((product) => (
161
+ <SelectItem key={product.id} value={product.name}>
162
+ {product.name}
163
+ </SelectItem>
164
+ ))}
165
+ </SelectContent>
166
+ </Select>
167
+ </div>
168
+ )}
169
+
170
+ <div className="custom-scrollbar max-h-[min(420px,50vh)] space-y-3 overflow-y-auto pr-1">
171
+ {displayedContacts.map((contact, index) => (
172
+ <SequenceCard
173
+ key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`}
174
+ contact={contact}
175
+ index={index}
176
+ />
177
+ ))}
178
+ {hasMore && (
179
+ <div className="py-3 text-center">
180
+ <Button
181
+ type="button"
182
+ variant="outline"
183
+ onClick={() => setDisplayedCount((c) => Math.min(c + 50, filteredContacts.length))}
184
+ >
185
+ Load more ({filteredContacts.length - displayedCount} remaining)
186
+ </Button>
187
+ </div>
188
+ )}
189
+ </div>
190
+
191
+ {!isGenerating && contacts.length === 0 && sequences.length === 0 && (
192
+ <div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 py-12 text-center text-sm text-slate-500">
193
+ Generated messages will appear here. You can continue to the next step while generation runs in the
194
+ background.
195
+ </div>
196
+ )}
197
+ </div>
198
+ );
199
+ }
200
+
201
  export default function CreateCampaignWizard({ open, onOpenChange, onComplete }) {
202
  const [step, setStep] = useState(1);
203
  const [campaignName, setCampaignName] = useState('');
204
  const [prospectFile, setProspectFile] = useState(null);
205
+ const [wizardUpload, setWizardUpload] = useState(null);
206
+ const [uploadingCsv, setUploadingCsv] = useState(false);
207
  const [dragOver, setDragOver] = useState(false);
208
  const [estimatedContacts, setEstimatedContacts] = useState(0);
209
  const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
210
  const [linkedinDefaults, setLinkedinDefaults] = useState(null);
211
  const [mailboxDefaults, setMailboxDefaults] = useState(null);
212
 
213
+ const [selectedProducts, setSelectedProducts] = useState([]);
214
+ const [prompts, setPrompts] = useState({});
215
+ const [linkedinPrompts, setLinkedinPrompts] = useState({});
216
+
217
+ const [genSequences, setGenSequences] = useState([]);
218
+ const [genContacts, setGenContacts] = useState([]);
219
+ const [genProgress, setGenProgress] = useState(0);
220
+ const [genRunning, setGenRunning] = useState(false);
221
+ const [genComplete, setGenComplete] = useState(false);
222
+ const [genPhase, setGenPhase] = useState('email');
223
+ const [genRunId, setGenRunId] = useState(0);
224
+ const eventSourceRef = useRef(null);
225
+ const prevGenRunIdRef = useRef(null);
226
+
227
+ const sequenceHasLinkedin = useMemo(
228
+ () =>
229
+ sequenceSteps.some(
230
+ (s) => s.type === 'action' && s.channel === 'linkedin' && s.action !== 'email'
231
+ ),
232
+ [sequenceSteps]
233
+ );
234
+
235
  const reset = useCallback(() => {
236
+ if (eventSourceRef.current) {
237
+ eventSourceRef.current.close();
238
+ eventSourceRef.current = null;
239
+ }
240
  setStep(1);
241
  setCampaignName('');
242
  setProspectFile(null);
243
+ setWizardUpload(null);
244
+ setUploadingCsv(false);
245
  setEstimatedContacts(0);
246
  setDragOver(false);
247
  setSequenceSteps(createDefaultSequenceSteps());
248
+ setSelectedProducts([]);
249
+ setPrompts({});
250
+ setLinkedinPrompts({});
251
+ setGenSequences([]);
252
+ setGenContacts([]);
253
+ setGenProgress(0);
254
+ setGenRunning(false);
255
+ setGenComplete(false);
256
+ setGenPhase('email');
257
+ setGenRunId(0);
258
+ prevGenRunIdRef.current = null;
259
  }, []);
260
 
261
  useEffect(() => {
 
340
  );
341
  }, [linkedinDefaults]);
342
 
343
+ useEffect(() => {
344
+ if (step !== 3 || selectedProducts.length === 0) return;
345
+ const appendix = buildCampaignSequenceAppendix(sequenceSteps);
346
+ setPrompts((prev) => {
347
+ const next = { ...prev };
348
+ let changed = false;
349
+ selectedProducts.forEach((p) => {
350
+ let cur = (next[p.name] || '').trim();
351
+ if (!cur) {
352
+ cur = DEFAULT_TEMPLATES[p.name] || '';
353
+ }
354
+ if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return;
355
+ next[p.name] = `${cur}\n\n${appendix}`;
356
+ changed = true;
357
+ });
358
+ return changed ? next : prev;
359
+ });
360
+ if (!sequenceHasLinkedin) return;
361
+ setLinkedinPrompts((prev) => {
362
+ const next = { ...prev };
363
+ let changed = false;
364
+ selectedProducts.forEach((p) => {
365
+ let cur = (next[p.name] || '').trim();
366
+ if (!cur) {
367
+ cur =
368
+ LINKEDIN_DEFAULT_TEMPLATES[p.name] ||
369
+ `🔒 LINKEDIN SYSTEM PROMPT\n\nGenerate a 3-message LinkedIn sequence for ${p.name}.\nLabel: Message 1, Message 2, Message 3.\nUse {{first_name}}, {{company}}. Sender: Anna.`;
370
+ }
371
+ if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return;
372
+ next[p.name] = `${cur}\n\n${appendix}`;
373
+ changed = true;
374
+ });
375
+ return changed ? next : prev;
376
+ });
377
+ }, [step, sequenceSteps, selectedProducts, sequenceHasLinkedin]);
378
+
379
+ useEffect(() => {
380
+ if (!genRunning || !wizardUpload?.fileId) return;
381
+
382
+ const isNewRun = prevGenRunIdRef.current !== genRunId;
383
+ if (isNewRun) {
384
+ prevGenRunIdRef.current = genRunId;
385
+ setGenSequences([]);
386
+ setGenContacts([]);
387
+ setGenProgress(0);
388
+ setGenComplete(false);
389
+ setGenPhase('email');
390
+ }
391
+
392
+ if (eventSourceRef.current) {
393
+ eventSourceRef.current.close();
394
+ eventSourceRef.current = null;
395
+ }
396
+
397
+ const resetParam = isNewRun ? 1 : 0;
398
+ const url = `/api/generate-sequences?file_id=${encodeURIComponent(wizardUpload.fileId)}&reset=${resetParam}`;
399
+ const es = new EventSource(url);
400
+ eventSourceRef.current = es;
401
+
402
+ es.onmessage = (event) => {
403
+ try {
404
+ const data = JSON.parse(event.data);
405
+ if (data.type === 'sequence') {
406
+ const seq = data.sequence;
407
+ setGenSequences((prev) => {
408
+ const sig = `${seq.id}-${seq.emailNumber}-${seq.channel || 'email'}-${seq.stepOrder ?? ''}`;
409
+ if (
410
+ prev.some(
411
+ (s) =>
412
+ `${s.id}-${s.emailNumber}-${s.channel || 'email'}-${s.stepOrder ?? ''}` === sig
413
+ )
414
+ ) {
415
+ return prev;
416
+ }
417
+ return [...prev, seq];
418
+ });
419
+ setGenContacts((prev) => mergeSequenceIntoContacts(prev, seq));
420
+ } else if (data.type === 'progress' && typeof data.progress === 'number') {
421
+ setGenProgress(data.progress);
422
+ } else if (data.type === 'phase' && data.phase === 'linkedin') {
423
+ setGenPhase('linkedin');
424
+ } else if (data.type === 'complete') {
425
+ setGenRunning(false);
426
+ setGenComplete(true);
427
+ es.close();
428
+ if (eventSourceRef.current === es) eventSourceRef.current = null;
429
+ } else if (data.type === 'error') {
430
+ console.error(data.error);
431
+ alert(`Generation error: ${data.error}`);
432
+ setGenRunning(false);
433
+ es.close();
434
+ if (eventSourceRef.current === es) eventSourceRef.current = null;
435
+ }
436
+ } catch (e) {
437
+ console.error(e);
438
+ }
439
+ };
440
+
441
+ es.onerror = () => {
442
+ es.close();
443
+ if (eventSourceRef.current === es) eventSourceRef.current = null;
444
+ };
445
+
446
+ return () => {
447
+ es.close();
448
+ if (eventSourceRef.current === es) eventSourceRef.current = null;
449
+ };
450
+ }, [genRunning, wizardUpload?.fileId, genRunId]);
451
+
452
+ const pickFile = async (file) => {
453
  if (!file) return;
454
+ if (!file.name.toLowerCase().endsWith('.csv')) {
455
+ alert('Please upload a .csv file (Apollo export), same as Email / AI Generator.');
456
+ return;
457
+ }
458
  setProspectFile(file);
459
+ setWizardUpload(null);
460
+ setUploadingCsv(true);
461
+ try {
462
+ const formData = new FormData();
463
+ formData.append('file', file);
464
+ const response = await apiFetch('/api/upload-csv', {
465
+ method: 'POST',
466
+ body: formData,
467
+ });
468
+ if (!response.ok) {
469
+ throw new Error('Upload failed');
470
+ }
471
+ const data = await response.json();
472
+ setWizardUpload({
473
+ fileId: data.file_id,
474
+ contactCount: data.contact_count,
475
+ name: file.name,
476
+ });
477
+ } catch (err) {
478
+ console.error(err);
479
+ alert('Could not upload CSV. Please try again.');
480
+ setProspectFile(null);
481
+ setWizardUpload(null);
482
+ } finally {
483
+ setUploadingCsv(false);
484
+ }
485
  };
486
 
487
+ const canContinueStep1 =
488
+ campaignName.trim().length > 0 && !!wizardUpload?.fileId && !uploadingCsv;
489
+
490
+ const canGenerate = useMemo(() => {
491
+ const emailOk =
492
+ selectedProducts.length > 0 &&
493
+ selectedProducts.every((p) => (prompts[p.name] || '').trim());
494
+ if (!emailOk || !wizardUpload?.fileId) return false;
495
+ if (!sequenceHasLinkedin) return true;
496
+ return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim());
497
+ }, [selectedProducts, prompts, linkedinPrompts, wizardUpload?.fileId, sequenceHasLinkedin]);
498
 
499
  const handleContinue = () => {
500
  if (step === 1 && !canContinueStep1) return;
 
505
  if (step > 1) setStep((s) => s - 1);
506
  };
507
 
508
+ const handleGenerate = async () => {
509
+ if (!canGenerate) {
510
+ alert('Select products and fill all prompt templates before generating.');
511
+ return;
512
+ }
513
+ try {
514
+ const res = await apiFetch('/api/save-prompts', {
515
+ method: 'POST',
516
+ headers: { 'Content-Type': 'application/json' },
517
+ body: JSON.stringify({
518
+ file_id: wizardUpload.fileId,
519
+ prompts,
520
+ products: selectedProducts.map((p) => p.name),
521
+ linkedin_prompts: sequenceHasLinkedin ? linkedinPrompts : {},
522
+ sequence_plan: sequenceSteps,
523
+ }),
524
+ });
525
+ if (!res.ok) {
526
+ const err = await res.json().catch(() => ({}));
527
+ throw new Error(err.detail || res.statusText);
528
+ }
529
+ } catch (e) {
530
+ console.error(e);
531
+ alert('Failed to save prompts. Please try again.');
532
+ return;
533
+ }
534
+ alert(
535
+ 'Generation has started. It will continue in the background — you can continue to Review & Launch and save the campaign while rows are still being created.'
536
+ );
537
+ setGenRunId((r) => r + 1);
538
+ setGenRunning(true);
539
+ };
540
+
541
  const handleLaunch = () => {
542
  if (!onComplete) {
543
  onOpenChange(false);
 
545
  }
546
  onComplete({
547
  name: campaignName.trim(),
548
+ contacts: wizardUpload?.contactCount || estimatedContacts || 0,
549
+ prospectFileName: wizardUpload?.name || prospectFile?.name || '',
550
+ fileId: wizardUpload?.fileId || null,
551
  sequence: sequenceSteps,
552
  });
553
  onOpenChange(false);
 
572
  <div
573
  className={cn(
574
  'relative z-[101] flex max-h-[min(92vh,900px)] w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-violet-200/30',
575
+ step === 2 || step === 3 ? 'max-w-4xl' : 'max-w-3xl'
576
  )}
577
  >
578
  <div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
 
588
  </button>
589
  </div>
590
 
 
591
  <div className="border-b border-slate-100 bg-slate-50/80 px-4 py-4 sm:px-6">
592
  <div className="flex flex-wrap items-start justify-between gap-4">
593
  {STEPS.map((s) => {
 
661
  const f = e.dataTransfer.files?.[0];
662
  pickFile(f);
663
  }}
664
+ onClick={() => !uploadingCsv && document.getElementById('wizard-csv-input')?.click()}
665
  >
666
  <input
667
  id="wizard-csv-input"
668
  type="file"
669
+ accept=".csv"
670
  className="hidden"
671
  onChange={(e) => pickFile(e.target.files?.[0])}
672
  />
 
680
  type="button"
681
  variant="outline"
682
  className="mt-4"
683
+ disabled={uploadingCsv}
684
  onClick={(e) => {
685
  e.stopPropagation();
686
  document.getElementById('wizard-csv-input')?.click();
 
688
  >
689
  Browse Files
690
  </Button>
691
+ {uploadingCsv ? (
692
+ <p className="mt-3 text-xs font-medium text-violet-700">Uploading…</p>
693
+ ) : wizardUpload ? (
694
  <p className="mt-3 text-xs font-medium text-violet-700">
695
+ {wizardUpload.name}
696
+ {wizardUpload.contactCount
697
+ ? ` · ${Number(wizardUpload.contactCount).toLocaleString()} contacts`
698
+ : estimatedContacts > 0
699
+ ? ` · ~${estimatedContacts.toLocaleString()} rows`
700
+ : ''}
701
  </p>
702
+ ) : prospectFile ? (
703
+ <p className="mt-3 text-xs text-amber-700">Upload failed — try another file.</p>
704
  ) : null}
705
  </div>
706
  </div>
 
713
  mailboxDefaults={mailboxDefaults}
714
  />
715
  ) : step === 3 ? (
716
+ <div className="space-y-8">
717
+ <div>
718
+ <span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500">
719
+ Step 3
720
+ </span>
721
+ <h3 className="mt-2 text-lg font-semibold text-slate-900">Prompts & generation</h3>
722
+ <p className="mt-1 text-sm text-slate-600">
723
+ Same experience as Email / AI Generator: templates follow your product defaults, with
724
+ an added block that mirrors the sequence you configured (Gmail + LinkedIn). Touch
725
+ counts in the prompt are ignored — the wizard sequence is authoritative.
726
+ </p>
727
+ </div>
728
+ <ProductSelector
729
+ selectedProducts={selectedProducts}
730
+ onProductsChange={setSelectedProducts}
731
+ />
732
+ {selectedProducts.length > 0 ? (
733
+ <>
734
+ <PromptEditor
735
+ selectedProducts={selectedProducts}
736
+ prompts={prompts}
737
+ onPromptsChange={setPrompts}
738
+ variant="email"
739
+ />
740
+ {sequenceHasLinkedin ? (
741
+ <div className="space-y-2">
742
+ <h4 className="text-base font-semibold text-slate-800">LinkedIn prompts</h4>
743
+ <p className="text-sm text-slate-500">
744
+ Required because this campaign includes LinkedIn steps.
745
+ </p>
746
+ <PromptEditor
747
+ selectedProducts={selectedProducts}
748
+ prompts={linkedinPrompts}
749
+ onPromptsChange={setLinkedinPrompts}
750
+ variant="linkedin"
751
+ />
752
+ </div>
753
+ ) : null}
754
+ <div className="flex flex-wrap items-center gap-3">
755
+ <Button
756
+ type="button"
757
+ disabled={!canGenerate || genRunning}
758
+ className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700 hover:to-purple-700"
759
+ onClick={handleGenerate}
760
+ >
761
+ <Sparkles className="mr-2 h-4 w-4" />
762
+ {genRunning ? 'Generating…' : 'Generate'}
763
+ </Button>
764
+ {genRunning ? (
765
+ <span className="text-sm text-slate-600">
766
+ Running in the background — you can go to the next step anytime.
767
+ </span>
768
+ ) : null}
769
+ </div>
770
+ <WizardSequencePreview
771
+ contacts={genContacts}
772
+ sequences={genSequences}
773
+ isGenerating={genRunning}
774
+ generationComplete={genComplete}
775
+ progress={genProgress}
776
+ contactCount={wizardUpload?.contactCount}
777
+ selectedProducts={selectedProducts}
778
+ sequenceHasLinkedin={sequenceHasLinkedin}
779
+ genPhase={genPhase}
780
+ />
781
+ </>
782
+ ) : (
783
+ <p className="text-sm text-slate-500">Select at least one product to edit prompts.</p>
784
+ )}
785
+ </div>
786
  ) : (
787
  <div className="space-y-6">
788
  <PlaceholderStep
 
796
  <p className="font-semibold text-slate-900">{campaignName || '—'}</p>
797
  </div>
798
  <div>
799
+ <span className="text-slate-500">Prospects</span>
800
  <p className="font-semibold text-slate-900">
801
+ {wizardUpload?.contactCount != null
802
+ ? Number(wizardUpload.contactCount).toLocaleString()
803
+ : estimatedContacts > 0
804
+ ? estimatedContacts.toLocaleString()
805
  : '—'}
806
  </p>
807
  </div>
808
  <div className="sm:col-span-2">
809
  <span className="text-slate-500">File</span>
810
+ <p className="font-medium text-slate-800">
811
+ {wizardUpload?.name || prospectFile?.name || '—'}
812
+ </p>
813
  </div>
814
+ {wizardUpload?.fileId ? (
815
+ <div className="sm:col-span-2">
816
+ <span className="text-slate-500">Generation file ID</span>
817
+ <p className="break-all font-mono text-xs text-slate-700">
818
+ {wizardUpload.fileId}
819
+ </p>
820
+ </div>
821
+ ) : null}
822
  <div className="sm:col-span-2">
823
  <span className="text-slate-500">Sequence</span>
824
  <p className="font-medium text-slate-800">
825
+ {sequenceSteps.length} step{sequenceSteps.length !== 1 ? 's' : ''}{' '}
826
+ configured
827
+ </p>
828
+ </div>
829
+ <div className="sm:col-span-2">
830
+ <span className="text-slate-500">Generated rows</span>
831
+ <p className="font-medium text-slate-800">
832
+ {genSequences.length}
833
+ {genRunning ? ' (still generating…)' : genComplete ? ' (complete)' : ''}
834
  </p>
835
  </div>
836
  </div>
 
875
  >
876
  {step === 1
877
  ? 'Continue to Sequence'
878
+ : step === 3
879
+ ? 'Continue to Review & Launch'
880
+ : `Continue to ${STEPS[step]?.label ?? 'next'}`}
881
  <ArrowRight className="ml-2 h-4 w-4" />
882
  </Button>
883
  ) : null}
frontend/src/components/prompts/PromptEditor.jsx CHANGED
@@ -5,7 +5,7 @@ import { Textarea } from "@/components/ui/textarea";
5
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
6
  import { motion, AnimatePresence } from 'framer-motion';
7
 
8
- const DEFAULT_TEMPLATES = {
9
  'Accounts Payable Automation': `🔒 SYSTEM PROMPT (DO NOT MODIFY)
10
 
11
  You are an expert B2B outbound copywriter writing cold emails that feel like internal work conversations, not marketing.
@@ -780,7 +780,7 @@ Best,
780
  {{sender_name}}`,
781
  };
782
 
783
- const LINKEDIN_DEFAULT_TEMPLATES = {
784
  'Accounts Payable Automation': `🔒 LINKEDIN SYSTEM PROMPT (DO NOT MODIFY)
785
 
786
  You are an expert B2B LinkedIn copywriter. Write short, human DMs and connection notes for AP / finance professionals at North American mid-market companies.
 
5
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
6
  import { motion, AnimatePresence } from 'framer-motion';
7
 
8
+ export const DEFAULT_TEMPLATES = {
9
  'Accounts Payable Automation': `🔒 SYSTEM PROMPT (DO NOT MODIFY)
10
 
11
  You are an expert B2B outbound copywriter writing cold emails that feel like internal work conversations, not marketing.
 
780
  {{sender_name}}`,
781
  };
782
 
783
+ export const LINKEDIN_DEFAULT_TEMPLATES = {
784
  'Accounts Payable Automation': `🔒 LINKEDIN SYSTEM PROMPT (DO NOT MODIFY)
785
 
786
  You are an expert B2B LinkedIn copywriter. Write short, human DMs and connection notes for AP / finance professionals at North American mid-market companies.
frontend/src/components/sequences/SequenceCard.jsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
2
  import { User, Building2, Mail, ChevronDown, ChevronUp, Copy, CheckCircle2 } from 'lucide-react';
3
  import { Button } from "@/components/ui/button";
4
  import { Badge } from "@/components/ui/badge";
@@ -11,7 +11,34 @@ export default function SequenceCard({ contact, index }) {
11
 
12
  const emails = contact.emails || [];
13
  const linkedin = contact.linkedin || [];
14
- const firstEmail = emails[0] || contact;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  const handleCopy = (emailContent, emailNum) => {
17
  navigator.clipboard.writeText(emailContent);
@@ -82,89 +109,174 @@ export default function SequenceCard({ contact, index }) {
82
  className="border-t border-slate-100"
83
  >
84
  <div className="px-5 py-4 bg-slate-50/50 space-y-4">
85
- {emails.map((email, emailIdx) => (
86
- <div key={`e-${emailIdx}`} className="bg-white rounded-lg border border-slate-200 p-4">
87
- <div className="flex items-center justify-between mb-3">
88
- <div className="flex items-center gap-2">
89
- <h5 className="text-sm font-medium text-slate-600">
90
- Email {email.emailNumber || emailIdx + 1}
91
- </h5>
92
- {emails.length > 1 && (
93
- <Badge variant="outline" className="text-xs">
94
- {email.emailNumber || emailIdx + 1} of {emails.length}
95
- </Badge>
96
- )}
97
- </div>
98
- <Button
99
- variant="ghost"
100
- size="sm"
101
- onClick={(e) => {
102
- e.stopPropagation();
103
- handleCopy(email.emailContent, email.emailNumber || emailIdx + 1);
104
- }}
105
- className="h-8 text-slate-500 hover:text-violet-600"
106
  >
107
- {copiedEmailNum === (email.emailNumber || emailIdx + 1) ? (
108
- <>
109
- <CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
110
- Copied!
111
- </>
112
- ) : (
113
- <>
114
- <Copy className="h-4 w-4 mr-1" />
115
- Copy
116
- </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  )}
118
- </Button>
119
- </div>
120
- <div className="mb-3 pb-3 border-b border-slate-100">
121
- <span className="text-xs text-slate-400 uppercase tracking-wide">Subject</span>
122
- <p className="text-sm font-medium text-slate-800 mt-1">{email.subject}</p>
123
- </div>
124
- <div className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">
125
- {email.emailContent}
126
- </div>
127
- </div>
128
- ))}
129
- {linkedin.map((msg, liIdx) => (
130
- <div
131
- key={`li-${liIdx}`}
132
- className="bg-white rounded-lg border border-sky-200 p-4 shadow-sm"
133
- >
134
- <div className="flex items-center justify-between mb-3">
135
- <h5 className="text-sm font-medium text-sky-800">
136
- LinkedIn — Message {msg.emailNumber || liIdx + 1}
137
- </h5>
138
- <Button
139
- variant="ghost"
140
- size="sm"
141
- onClick={(e) => {
142
- e.stopPropagation();
143
- handleCopy(
144
- msg.emailContent,
145
- `li-${msg.emailNumber || liIdx + 1}`
146
- );
147
- }}
148
- className="h-8 text-slate-500 hover:text-sky-600"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  >
150
- {copiedEmailNum === `li-${msg.emailNumber || liIdx + 1}` ? (
151
- <>
152
- <CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
153
- Copied!
154
- </>
155
- ) : (
156
- <>
157
- <Copy className="h-4 w-4 mr-1" />
158
- Copy
159
- </>
160
- )}
161
- </Button>
162
- </div>
163
- <div className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">
164
- {msg.emailContent}
165
- </div>
166
- </div>
167
- ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  </div>
169
  </motion.div>
170
  )}
 
1
+ import React, { useMemo, useState } from 'react';
2
  import { User, Building2, Mail, ChevronDown, ChevronUp, Copy, CheckCircle2 } from 'lucide-react';
3
  import { Button } from "@/components/ui/button";
4
  import { Badge } from "@/components/ui/badge";
 
11
 
12
  const emails = contact.emails || [];
13
  const linkedin = contact.linkedin || [];
14
+ const firstEmail = emails[0] ||
15
+ linkedin[0] || {
16
+ firstName: contact.firstName,
17
+ lastName: contact.lastName,
18
+ company: contact.company,
19
+ email: contact.email,
20
+ product: contact.product,
21
+ };
22
+
23
+ const mergedTimeline = useMemo(() => {
24
+ const parts = [
25
+ ...(emails || []).map((m, i) => ({
26
+ kind: 'email',
27
+ stepOrder: m.stepOrder,
28
+ data: m,
29
+ key: `e-${m.stepOrder ?? 'x'}-${m.emailNumber ?? i}`,
30
+ })),
31
+ ...(linkedin || []).map((m, i) => ({
32
+ kind: 'linkedin',
33
+ stepOrder: m.stepOrder,
34
+ data: m,
35
+ key: `l-${m.stepOrder ?? 'x'}-${m.emailNumber ?? i}`,
36
+ })),
37
+ ];
38
+ const hasOrder = parts.some((p) => p.stepOrder != null);
39
+ if (!hasOrder) return null;
40
+ return [...parts].sort((a, b) => (a.stepOrder ?? 1e9) - (b.stepOrder ?? 1e9));
41
+ }, [emails, linkedin]);
42
 
43
  const handleCopy = (emailContent, emailNum) => {
44
  navigator.clipboard.writeText(emailContent);
 
109
  className="border-t border-slate-100"
110
  >
111
  <div className="px-5 py-4 bg-slate-50/50 space-y-4">
112
+ {mergedTimeline ? (
113
+ mergedTimeline.map((item, idx) => {
114
+ const copyId =
115
+ item.kind === 'email'
116
+ ? item.data.emailNumber || idx + 1
117
+ : `li-${item.data.emailNumber || idx + 1}`;
118
+ const isLi = item.kind === 'linkedin';
119
+ return (
120
+ <div
121
+ key={item.key}
122
+ className={`rounded-lg border p-4 shadow-sm ${
123
+ isLi
124
+ ? 'border-sky-200 bg-white'
125
+ : 'border-slate-200 bg-white'
126
+ }`}
 
 
 
 
 
 
127
  >
128
+ <div className="mb-3 flex items-center justify-between">
129
+ <div className="flex flex-wrap items-center gap-2">
130
+ <Badge
131
+ variant="outline"
132
+ className={
133
+ isLi ? 'border-sky-200 text-sky-800' : 'text-slate-700'
134
+ }
135
+ >
136
+ {isLi ? 'LinkedIn' : 'Gmail'}
137
+ </Badge>
138
+ <h5
139
+ className={`text-sm font-medium ${
140
+ isLi ? 'text-sky-800' : 'text-slate-600'
141
+ }`}
142
+ >
143
+ {isLi
144
+ ? `Message ${item.data.emailNumber || idx + 1}`
145
+ : `Email ${item.data.emailNumber || idx + 1}`}
146
+ </h5>
147
+ </div>
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ onClick={(e) => {
152
+ e.stopPropagation();
153
+ handleCopy(item.data.emailContent, copyId);
154
+ }}
155
+ className={`h-8 text-slate-500 ${
156
+ isLi ? 'hover:text-sky-600' : 'hover:text-violet-600'
157
+ }`}
158
+ >
159
+ {copiedEmailNum === copyId ? (
160
+ <>
161
+ <CheckCircle2 className="mr-1 h-4 w-4 text-green-500" />
162
+ Copied!
163
+ </>
164
+ ) : (
165
+ <>
166
+ <Copy className="mr-1 h-4 w-4" />
167
+ Copy
168
+ </>
169
+ )}
170
+ </Button>
171
+ </div>
172
+ {!isLi && (
173
+ <div className="mb-3 border-b border-slate-100 pb-3">
174
+ <span className="text-xs uppercase tracking-wide text-slate-400">
175
+ Subject
176
+ </span>
177
+ <p className="mt-1 text-sm font-medium text-slate-800">
178
+ {item.data.subject}
179
+ </p>
180
+ </div>
181
  )}
182
+ <div className="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
183
+ {item.data.emailContent}
184
+ </div>
185
+ </div>
186
+ );
187
+ })
188
+ ) : (
189
+ <>
190
+ {emails.map((email, emailIdx) => (
191
+ <div key={`e-${emailIdx}`} className="rounded-lg border border-slate-200 bg-white p-4">
192
+ <div className="mb-3 flex items-center justify-between">
193
+ <div className="flex items-center gap-2">
194
+ <h5 className="text-sm font-medium text-slate-600">
195
+ Email {email.emailNumber || emailIdx + 1}
196
+ </h5>
197
+ {emails.length > 1 && (
198
+ <Badge variant="outline" className="text-xs">
199
+ {email.emailNumber || emailIdx + 1} of {emails.length}
200
+ </Badge>
201
+ )}
202
+ </div>
203
+ <Button
204
+ variant="ghost"
205
+ size="sm"
206
+ onClick={(e) => {
207
+ e.stopPropagation();
208
+ handleCopy(
209
+ email.emailContent,
210
+ email.emailNumber || emailIdx + 1
211
+ );
212
+ }}
213
+ className="h-8 text-slate-500 hover:text-violet-600"
214
+ >
215
+ {copiedEmailNum === (email.emailNumber || emailIdx + 1) ? (
216
+ <>
217
+ <CheckCircle2 className="mr-1 h-4 w-4 text-green-500" />
218
+ Copied!
219
+ </>
220
+ ) : (
221
+ <>
222
+ <Copy className="mr-1 h-4 w-4" />
223
+ Copy
224
+ </>
225
+ )}
226
+ </Button>
227
+ </div>
228
+ <div className="mb-3 border-b border-slate-100 pb-3">
229
+ <span className="text-xs uppercase tracking-wide text-slate-400">
230
+ Subject
231
+ </span>
232
+ <p className="mt-1 text-sm font-medium text-slate-800">{email.subject}</p>
233
+ </div>
234
+ <div className="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
235
+ {email.emailContent}
236
+ </div>
237
+ </div>
238
+ ))}
239
+ {linkedin.map((msg, liIdx) => (
240
+ <div
241
+ key={`li-${liIdx}`}
242
+ className="rounded-lg border border-sky-200 bg-white p-4 shadow-sm"
243
  >
244
+ <div className="mb-3 flex items-center justify-between">
245
+ <h5 className="text-sm font-medium text-sky-800">
246
+ LinkedIn Message {msg.emailNumber || liIdx + 1}
247
+ </h5>
248
+ <Button
249
+ variant="ghost"
250
+ size="sm"
251
+ onClick={(e) => {
252
+ e.stopPropagation();
253
+ handleCopy(
254
+ msg.emailContent,
255
+ `li-${msg.emailNumber || liIdx + 1}`
256
+ );
257
+ }}
258
+ className="h-8 text-slate-500 hover:text-sky-600"
259
+ >
260
+ {copiedEmailNum === `li-${msg.emailNumber || liIdx + 1}` ? (
261
+ <>
262
+ <CheckCircle2 className="mr-1 h-4 w-4 text-green-500" />
263
+ Copied!
264
+ </>
265
+ ) : (
266
+ <>
267
+ <Copy className="mr-1 h-4 w-4" />
268
+ Copy
269
+ </>
270
+ )}
271
+ </Button>
272
+ </div>
273
+ <div className="whitespace-pre-wrap text-sm leading-relaxed text-slate-700">
274
+ {msg.emailContent}
275
+ </div>
276
+ </div>
277
+ ))}
278
+ </>
279
+ )}
280
  </div>
281
  </motion.div>
282
  )}
frontend/src/context/GeneratorWorkflowContext.jsx CHANGED
@@ -8,59 +8,10 @@ import React, {
8
  useState,
9
  } from 'react';
10
  import { apiFetch } from '@/lib/api';
 
11
 
12
  const GeneratorWorkflowContext = createContext(null);
13
 
14
- function applySequenceToContacts(prev, sequence) {
15
- const ch = sequence.channel || 'email';
16
- const existingContact = prev.find(
17
- (c) =>
18
- c.firstName === sequence.firstName &&
19
- c.lastName === sequence.lastName &&
20
- c.email === sequence.email
21
- );
22
- const step = {
23
- emailNumber: sequence.emailNumber || 1,
24
- subject: sequence.subject,
25
- emailContent: sequence.emailContent,
26
- channel: ch,
27
- };
28
- let updatedContacts;
29
- if (existingContact) {
30
- if (ch === 'linkedin') {
31
- if (!existingContact.linkedin) existingContact.linkedin = [];
32
- if (!existingContact.linkedin.some((e) => e.emailNumber === step.emailNumber)) {
33
- existingContact.linkedin.push(step);
34
- }
35
- } else {
36
- if (!existingContact.emails) existingContact.emails = [];
37
- if (!existingContact.emails.some((e) => e.emailNumber === step.emailNumber)) {
38
- existingContact.emails.push(step);
39
- }
40
- }
41
- updatedContacts = [...prev];
42
- } else {
43
- const base = {
44
- id: sequence.id,
45
- firstName: sequence.firstName,
46
- lastName: sequence.lastName,
47
- email: sequence.email,
48
- company: sequence.company,
49
- title: sequence.title,
50
- product: sequence.product,
51
- emails: [],
52
- linkedin: [],
53
- };
54
- if (ch === 'linkedin') {
55
- base.linkedin = [step];
56
- } else {
57
- base.emails = [step];
58
- }
59
- updatedContacts = [...prev, base];
60
- }
61
- return updatedContacts;
62
- }
63
-
64
  export function GeneratorWorkflowProvider({ children }) {
65
  const [step, setStep] = useState(1);
66
  const [uploadedFile, setUploadedFile] = useState(null);
@@ -141,13 +92,18 @@ export function GeneratorWorkflowProvider({ children }) {
141
  if (data.type === 'sequence') {
142
  const seq = data.sequence;
143
  setSequences((prev) => {
144
- const sig = `${seq.id}-${seq.emailNumber}-${seq.channel || 'email'}`;
145
- if (prev.some((s) => `${s.id}-${s.emailNumber}-${s.channel || 'email'}` === sig)) {
 
 
 
 
 
146
  return prev;
147
  }
148
  return [...prev, seq];
149
  });
150
- setContacts((prev) => applySequenceToContacts(prev, seq));
151
  } else if (data.type === 'progress') {
152
  if (typeof data.progress === 'number') setProgress(data.progress);
153
  } else if (data.type === 'phase') {
@@ -223,14 +179,20 @@ export function GeneratorWorkflowProvider({ children }) {
223
  subject: seq.subject,
224
  emailContent: seq.emailContent,
225
  channel: ch,
 
226
  };
 
 
 
 
 
227
  const c = byContact.get(key);
228
  if (ch === 'linkedin') {
229
- if (!c.linkedin.some((e) => e.emailNumber === row.emailNumber)) {
230
  c.linkedin.push(row);
231
  }
232
  } else {
233
- if (!c.emails.some((e) => e.emailNumber === row.emailNumber)) {
234
  c.emails.push(row);
235
  }
236
  }
@@ -240,9 +202,12 @@ export function GeneratorWorkflowProvider({ children }) {
240
  setSequences(list);
241
  setContacts(arr);
242
  if (status.total_contacts > 0) {
243
- const exp = status.has_linkedin_prompts
244
- ? status.total_contacts * 2
245
- : status.total_contacts;
 
 
 
246
  setProgress(
247
  Math.min(100, ((status.completed_count || 0) / exp) * 100)
248
  );
 
8
  useState,
9
  } from 'react';
10
  import { apiFetch } from '@/lib/api';
11
+ import { mergeSequenceIntoContacts } from '@/lib/mergeSequenceIntoContacts';
12
 
13
  const GeneratorWorkflowContext = createContext(null);
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  export function GeneratorWorkflowProvider({ children }) {
16
  const [step, setStep] = useState(1);
17
  const [uploadedFile, setUploadedFile] = useState(null);
 
92
  if (data.type === 'sequence') {
93
  const seq = data.sequence;
94
  setSequences((prev) => {
95
+ const sig = `${seq.id}-${seq.emailNumber}-${seq.channel || 'email'}-${seq.stepOrder ?? ''}`;
96
+ if (
97
+ prev.some(
98
+ (s) =>
99
+ `${s.id}-${s.emailNumber}-${s.channel || 'email'}-${s.stepOrder ?? ''}` === sig
100
+ )
101
+ ) {
102
  return prev;
103
  }
104
  return [...prev, seq];
105
  });
106
+ setContacts((prev) => mergeSequenceIntoContacts(prev, seq));
107
  } else if (data.type === 'progress') {
108
  if (typeof data.progress === 'number') setProgress(data.progress);
109
  } else if (data.type === 'phase') {
 
179
  subject: seq.subject,
180
  emailContent: seq.emailContent,
181
  channel: ch,
182
+ stepOrder: seq.stepOrder,
183
  };
184
+ const dedupe = (e) =>
185
+ e.stepOrder != null
186
+ ? `ord-${e.stepOrder}-${ch}`
187
+ : `${ch}-${e.emailNumber}`;
188
+ const rowKey = dedupe(row);
189
  const c = byContact.get(key);
190
  if (ch === 'linkedin') {
191
+ if (!c.linkedin.some((e) => dedupe(e) === rowKey)) {
192
  c.linkedin.push(row);
193
  }
194
  } else {
195
+ if (!c.emails.some((e) => dedupe(e) === rowKey)) {
196
  c.emails.push(row);
197
  }
198
  }
 
202
  setSequences(list);
203
  setContacts(arr);
204
  if (status.total_contacts > 0) {
205
+ const exp =
206
+ status.campaign_sequence_actions && status.total_contacts > 0
207
+ ? status.total_contacts * status.campaign_sequence_actions
208
+ : status.has_linkedin_prompts
209
+ ? status.total_contacts * 2
210
+ : status.total_contacts;
211
  setProgress(
212
  Math.min(100, ((status.completed_count || 0) / exp) * 100)
213
  );
frontend/src/lib/mergeSequenceIntoContacts.js ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Merge one generated sequence row (email or LinkedIn) into grouped contacts for previews.
3
+ * When stepOrder is present (campaign wizard), dedupe by stepOrder + channel so merged timelines stay stable.
4
+ */
5
+ export function mergeSequenceIntoContacts(prev, sequence) {
6
+ const ch = sequence.channel || 'email';
7
+ const existingContact = prev.find(
8
+ (c) =>
9
+ c.firstName === sequence.firstName &&
10
+ c.lastName === sequence.lastName &&
11
+ c.email === sequence.email
12
+ );
13
+ const stepOrder =
14
+ sequence.stepOrder != null && sequence.stepOrder !== undefined ? sequence.stepOrder : null;
15
+ const step = {
16
+ emailNumber: sequence.emailNumber || 1,
17
+ subject: sequence.subject,
18
+ emailContent: sequence.emailContent,
19
+ channel: ch,
20
+ stepOrder,
21
+ };
22
+ const dedupeKey =
23
+ stepOrder != null ? `ord-${stepOrder}-${ch}` : `${ch}-${step.emailNumber}`;
24
+
25
+ let updatedContacts;
26
+ if (existingContact) {
27
+ if (ch === 'linkedin') {
28
+ if (!existingContact.linkedin) existingContact.linkedin = [];
29
+ if (
30
+ !existingContact.linkedin.some((e) => {
31
+ const k =
32
+ e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
33
+ return k === dedupeKey;
34
+ })
35
+ ) {
36
+ existingContact.linkedin.push(step);
37
+ }
38
+ } else {
39
+ if (!existingContact.emails) existingContact.emails = [];
40
+ if (
41
+ !existingContact.emails.some((e) => {
42
+ const k =
43
+ e.stepOrder != null ? `ord-${e.stepOrder}-${ch}` : `${ch}-${e.emailNumber}`;
44
+ return k === dedupeKey;
45
+ })
46
+ ) {
47
+ existingContact.emails.push(step);
48
+ }
49
+ }
50
+ updatedContacts = [...prev];
51
+ } else {
52
+ const base = {
53
+ id: sequence.id,
54
+ firstName: sequence.firstName,
55
+ lastName: sequence.lastName,
56
+ email: sequence.email,
57
+ company: sequence.company,
58
+ title: sequence.title,
59
+ product: sequence.product,
60
+ emails: [],
61
+ linkedin: [],
62
+ };
63
+ if (ch === 'linkedin') {
64
+ base.linkedin = [step];
65
+ } else {
66
+ base.emails = [step];
67
+ }
68
+ updatedContacts = [...prev, base];
69
+ }
70
+ return updatedContacts;
71
+ }