Seth commited on
Commit
21bb60f
·
1 Parent(s): 00a69e6
backend/app/database.py CHANGED
@@ -91,6 +91,7 @@ class Prompt(Base):
91
  file_id = Column(String, index=True)
92
  product_name = Column(String)
93
  prompt_template = Column(Text)
 
94
  created_at = Column(DateTime, default=datetime.utcnow)
95
 
96
 
@@ -102,6 +103,7 @@ class GeneratedSequence(Base):
102
  file_id = Column(String, index=True)
103
  sequence_id = Column(Integer) # Contact sequence number
104
  email_number = Column(Integer, default=1) # Email number in sequence (1-10)
 
105
  first_name = Column(String)
106
  last_name = Column(String)
107
  email = Column(String)
@@ -313,6 +315,28 @@ def run_migrations(connection_engine):
313
  )
314
  )
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  # Create tables then migrate legacy SQLite schemas
318
  Base.metadata.create_all(bind=engine)
 
91
  file_id = Column(String, index=True)
92
  product_name = Column(String)
93
  prompt_template = Column(Text)
94
+ prompt_kind = Column(String, default="email") # email | linkedin
95
  created_at = Column(DateTime, default=datetime.utcnow)
96
 
97
 
 
103
  file_id = Column(String, index=True)
104
  sequence_id = Column(Integer) # Contact sequence number
105
  email_number = Column(Integer, default=1) # Email number in sequence (1-10)
106
+ channel = Column(String, default="email") # email | linkedin
107
  first_name = Column(String)
108
  last_name = Column(String)
109
  email = Column(String)
 
315
  )
316
  )
317
 
318
+ insp = inspect(connection_engine)
319
+ if insp.has_table("prompts"):
320
+ pcols = [c["name"] for c in insp.get_columns("prompts")]
321
+ if "prompt_kind" not in pcols:
322
+ conn.execute(text("ALTER TABLE prompts ADD COLUMN prompt_kind TEXT DEFAULT 'email'"))
323
+ conn.execute(
324
+ text(
325
+ "UPDATE prompts SET prompt_kind = 'email' WHERE prompt_kind IS NULL OR prompt_kind = ''"
326
+ )
327
+ )
328
+
329
+ insp = inspect(connection_engine)
330
+ if insp.has_table("generated_sequences"):
331
+ gcols = [c["name"] for c in insp.get_columns("generated_sequences")]
332
+ if "channel" not in gcols:
333
+ conn.execute(text("ALTER TABLE generated_sequences ADD COLUMN channel TEXT DEFAULT 'email'"))
334
+ conn.execute(
335
+ text(
336
+ "UPDATE generated_sequences SET channel = 'email' WHERE channel IS NULL OR channel = ''"
337
+ )
338
+ )
339
+
340
 
341
  # Create tables then migrate legacy SQLite schemas
342
  Base.metadata.create_all(bind=engine)
backend/app/gpt_service.py CHANGED
@@ -4,7 +4,7 @@ import json
4
  import logging
5
  import time
6
  from openai import OpenAI
7
- from typing import Any, Dict, List, Tuple
8
  from urllib.parse import urlparse
9
  import re
10
 
@@ -312,6 +312,161 @@ If sender_name is not provided, use "Alex Thompson" as the sender name."""
312
  }]
313
 
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  def _strip_json_fences(text: str) -> str:
316
  t = (text or "").strip()
317
  if not t.startswith("```"):
 
4
  import logging
5
  import time
6
  from openai import OpenAI
7
+ from typing import Any, Dict, List, Optional, Tuple
8
  from urllib.parse import urlparse
9
  import re
10
 
 
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.
319
+ """
320
+
321
+ def safe_str(value):
322
+ if value is None:
323
+ return ""
324
+ if isinstance(value, float):
325
+ if math.isnan(value):
326
+ return ""
327
+ return str(value).strip()
328
+ return str(value).strip() if value else ""
329
+
330
+ try:
331
+ first_name = safe_str(contact.get("first_name") or contact.get("First Name") or "")
332
+ last_name = safe_str(contact.get("last_name") or contact.get("Last Name") or "")
333
+ company = safe_str(contact.get("company") or contact.get("Company") or contact.get("Organization") or "")
334
+ email = safe_str(contact.get("email") or contact.get("Email") or "")
335
+ title = safe_str(contact.get("title") or contact.get("Title") or contact.get("Job Title") or "")
336
+ industry = safe_str(contact.get("industry") or contact.get("Industry") or "")
337
+ location = safe_str(contact.get("location") or contact.get("Location") or "")
338
+
339
+ if not email:
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:
352
+ - First Name: {first_name}
353
+ - Last Name: {last_name}
354
+ - Company: {company}
355
+ - Title: {title}
356
+ - Email: {email}
357
+ - Industry: {industry if industry else 'Not specified'}
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):
364
+ Message 1
365
+ [text]
366
+
367
+ Message 2
368
+ [text]
369
+
370
+ Message 3
371
+ [text]
372
+ (add Message 4, Message 5 if the prompt specifies more than 3)
373
+
374
+ Use the contact's real first name where a greeting is appropriate. Do not use placeholder tokens like {{{{first_name}}}} in the final output."""
375
+
376
+ response = client.chat.completions.create(
377
+ model="gpt-4o-mini",
378
+ messages=[
379
+ {"role": "system", "content": system_prompt},
380
+ {"role": "user", "content": user_prompt},
381
+ ],
382
+ temperature=0.7,
383
+ max_tokens=1800,
384
+ )
385
+ generated_text = response.choices[0].message.content or ""
386
+
387
+ messages: List[Dict[str, Any]] = []
388
+ lines = generated_text.split("\n")
389
+ current_n: Optional[int] = None
390
+ current_body: List[str] = []
391
+
392
+ def flush():
393
+ nonlocal current_n, current_body
394
+ if current_n is not None and current_body:
395
+ body = "\n".join(current_body).strip()
396
+ body = (
397
+ body.replace("{{first_name}}", first_name)
398
+ .replace("{{company}}", company)
399
+ .replace("{{sender_name}}", "Anna")
400
+ )
401
+ messages.append(
402
+ {
403
+ "email_number": current_n,
404
+ "subject": "",
405
+ "body": body,
406
+ }
407
+ )
408
+ current_body = []
409
+
410
+ for line in lines:
411
+ ls = line.strip()
412
+ low = ls.lower()
413
+ if low.startswith("message ") and any(c.isdigit() for c in ls):
414
+ flush()
415
+ digits = "".join(filter(str.isdigit, ls))
416
+ current_n = int(digits) if digits else len(messages) + 1
417
+ current_body = []
418
+ elif current_n is not None:
419
+ current_body.append(line)
420
+
421
+ flush()
422
+
423
+ if not messages:
424
+ chunk = generated_text.strip()
425
+ chunk = (
426
+ chunk.replace("{{first_name}}", first_name)
427
+ .replace("{{company}}", company)
428
+ .replace("{{sender_name}}", "Anna")
429
+ )
430
+ messages.append({"email_number": 1, "subject": "", "body": chunk})
431
+
432
+ out = []
433
+ for m in messages:
434
+ out.append(
435
+ {
436
+ "first_name": first_name or "",
437
+ "last_name": last_name or "",
438
+ "email": email,
439
+ "company": company or "",
440
+ "title": title or "",
441
+ "product": product_name,
442
+ "email_number": m["email_number"],
443
+ "subject": "",
444
+ "email_content": m["body"] or "",
445
+ }
446
+ )
447
+ return out
448
+ except Exception as e:
449
+ logger.warning("Error generating LinkedIn sequence: %s", e)
450
+ fn = safe_str(contact.get("first_name") or contact.get("First Name") or "there")
451
+ company = safe_str(
452
+ contact.get("company") or contact.get("Company") or contact.get("Organization") or "your company"
453
+ )
454
+ email = safe_str(contact.get("email") or contact.get("Email") or "")
455
+ return [
456
+ {
457
+ "first_name": fn,
458
+ "last_name": safe_str(contact.get("last_name") or contact.get("Last Name") or ""),
459
+ "email": email,
460
+ "company": company,
461
+ "title": safe_str(contact.get("title") or contact.get("Title") or ""),
462
+ "product": product_name,
463
+ "email_number": 1,
464
+ "subject": "",
465
+ "email_content": f"Hi {fn},\n\nQuick note on {product_name} for {company} — happy to compare notes if useful.\n\nAnna",
466
+ }
467
+ ]
468
+
469
+
470
  def _strip_json_fences(text: str) -> str:
471
  t = (text or "").strip()
472
  if not t.startswith("```"):
backend/app/main.py CHANGED
@@ -52,7 +52,7 @@ from .models import (
52
  WonBillingPayload,
53
  )
54
  from .gmail_invite import send_invite_email_via_gmail
55
- from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
56
  from .smartlead_client import SmartleadClient
57
  from .auth_routes import router as auth_router
58
  from .tenant_deps import TenantContext, get_tenant_context
@@ -1754,16 +1754,31 @@ async def save_prompts(request: PromptSaveRequest, t: TenantContext = Depends(ge
1754
  Prompt.file_id == request.file_id,
1755
  ).delete()
1756
 
1757
- # Save new prompts
1758
  for product_name, prompt_template in request.prompts.items():
1759
  prompt = Prompt(
1760
  tenant_id=t.tenant_id,
1761
  file_id=request.file_id,
1762
  product_name=product_name,
1763
  prompt_template=prompt_template,
 
1764
  )
1765
  db.add(prompt)
1766
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1767
  db.commit()
1768
  return {"message": "Prompts saved successfully"}
1769
  except Exception as e:
@@ -1785,20 +1800,44 @@ async def generation_status(file_id: str = Query(...), t: TenantContext = Depend
1785
  if not db_file:
1786
  raise HTTPException(status_code=404, detail="File not found")
1787
  total_contacts = db_file.contact_count or 0
1788
- completed = (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1789
  db.query(GeneratedSequence.sequence_id)
1790
  .filter(
1791
  GeneratedSequence.tenant_id == t.tenant_id,
1792
  GeneratedSequence.file_id == file_id,
 
1793
  )
1794
  .distinct()
1795
  .count()
1796
  )
 
 
1797
  return {
1798
  "file_id": file_id,
1799
  "total_contacts": total_contacts,
1800
- "completed_count": completed,
1801
- "is_complete": total_contacts > 0 and completed >= total_contacts,
 
1802
  }
1803
 
1804
 
@@ -1828,6 +1867,7 @@ async def get_sequences(file_id: str = Query(...), t: TenantContext = Depends(ge
1828
  "product": seq.product,
1829
  "subject": seq.subject,
1830
  "emailContent": seq.email_content,
 
1831
  })
1832
  return {"sequences": out}
1833
 
@@ -1863,23 +1903,44 @@ async def generate_sequences(
1863
  .filter(Prompt.tenant_id == tenant_id, Prompt.file_id == file_id)
1864
  .all()
1865
  )
1866
- prompt_dict = {p.product_name: p.prompt_template for p in prompts}
1867
-
1868
- if not prompt_dict:
 
 
 
 
 
 
 
 
 
1869
  yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
1870
  return
1871
-
1872
- products = list(prompt_dict.keys())
 
 
 
1873
  if reset:
1874
  db.query(GeneratedSequence).filter(
1875
  GeneratedSequence.tenant_id == tenant_id,
1876
  GeneratedSequence.file_id == file_id,
1877
  ).delete()
1878
  db.commit()
1879
-
1880
  total_contacts = len(df)
 
 
 
 
 
 
 
 
 
 
1881
  sequence_id = 1
1882
-
1883
  for idx, row in df.iterrows():
1884
  existing = (
1885
  db.query(GeneratedSequence)
@@ -1887,6 +1948,7 @@ async def generate_sequences(
1887
  GeneratedSequence.tenant_id == tenant_id,
1888
  GeneratedSequence.file_id == file_id,
1889
  GeneratedSequence.sequence_id == sequence_id,
 
1890
  )
1891
  .order_by(GeneratedSequence.email_number)
1892
  .all()
@@ -1904,18 +1966,18 @@ async def generate_sequences(
1904
  "product": seq.product,
1905
  "subject": seq.subject,
1906
  "emailContent": seq.email_content,
 
1907
  }
1908
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
1909
- progress = min(100, max(0, (sequence_id / total_contacts) * 100)) if total_contacts > 0 else 0
1910
- yield f"data: {json.dumps({'type': 'progress', 'progress': float(progress)})}\n\n"
1911
  sequence_id += 1
1912
  await asyncio.sleep(0.05)
1913
  continue
1914
-
1915
  contact = row.to_dict()
1916
- product_name = products[sequence_id % len(products)]
1917
- prompt_template = prompt_dict[product_name]
1918
-
1919
  loop = asyncio.get_event_loop()
1920
  with concurrent.futures.ThreadPoolExecutor() as executor:
1921
  sequence_data_list = await loop.run_in_executor(
@@ -1925,13 +1987,14 @@ async def generate_sequences(
1925
  prompt_template,
1926
  product_name,
1927
  )
1928
-
1929
  for seq_data in sequence_data_list:
1930
  db_sequence = GeneratedSequence(
1931
  tenant_id=tenant_id,
1932
  file_id=file_id,
1933
  sequence_id=sequence_id,
1934
  email_number=seq_data["email_number"],
 
1935
  first_name=seq_data["first_name"],
1936
  last_name=seq_data["last_name"],
1937
  email=seq_data["email"],
@@ -1942,9 +2005,9 @@ async def generate_sequences(
1942
  email_content=seq_data["email_content"],
1943
  )
1944
  db.add(db_sequence)
1945
-
1946
  db.commit()
1947
-
1948
  for seq_data in sequence_data_list:
1949
  sequence_response = {
1950
  "id": sequence_id,
@@ -1957,14 +2020,104 @@ async def generate_sequences(
1957
  "product": seq_data["product"],
1958
  "subject": seq_data["subject"],
1959
  "emailContent": seq_data["email_content"],
 
1960
  }
1961
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
1962
-
1963
- progress = min(100, max(0, (sequence_id / total_contacts) * 100)) if total_contacts > 0 else 0
1964
- yield f"data: {json.dumps({'type': 'progress', 'progress': float(progress)})}\n\n"
1965
  sequence_id += 1
1966
  await asyncio.sleep(0.1)
1967
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1968
  yield f"data: {json.dumps({'type': 'complete'})}\n\n"
1969
 
1970
  except Exception as e:
@@ -1984,13 +2137,16 @@ async def download_sequences(file_id: str = Query(...), t: TenantContext = Depen
1984
  .filter(
1985
  GeneratedSequence.tenant_id == t.tenant_id,
1986
  GeneratedSequence.file_id == file_id,
 
1987
  )
1988
  .order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
1989
  .all()
1990
  )
1991
-
1992
  if not sequences:
1993
- raise HTTPException(status_code=404, detail="No sequences found")
 
 
 
1994
 
1995
  # Group sequences by contact
1996
  contacts = {}
 
52
  WonBillingPayload,
53
  )
54
  from .gmail_invite import send_invite_email_via_gmail
55
+ from .gpt_service import generate_email_sequence, generate_linkedin_sequence, enrich_manual_contact_profile
56
  from .smartlead_client import SmartleadClient
57
  from .auth_routes import router as auth_router
58
  from .tenant_deps import TenantContext, get_tenant_context
 
1754
  Prompt.file_id == request.file_id,
1755
  ).delete()
1756
 
1757
+ # Save email prompts
1758
  for product_name, prompt_template in request.prompts.items():
1759
  prompt = Prompt(
1760
  tenant_id=t.tenant_id,
1761
  file_id=request.file_id,
1762
  product_name=product_name,
1763
  prompt_template=prompt_template,
1764
+ prompt_kind="email",
1765
  )
1766
  db.add(prompt)
1767
+ # Optional LinkedIn prompts (same product names)
1768
+ li = request.linkedin_prompts or {}
1769
+ for product_name, prompt_template in li.items():
1770
+ if not (prompt_template or "").strip():
1771
+ continue
1772
+ db.add(
1773
+ Prompt(
1774
+ tenant_id=t.tenant_id,
1775
+ file_id=request.file_id,
1776
+ product_name=product_name,
1777
+ prompt_template=prompt_template,
1778
+ prompt_kind="linkedin",
1779
+ )
1780
+ )
1781
+
1782
  db.commit()
1783
  return {"message": "Prompts saved successfully"}
1784
  except Exception as e:
 
1800
  if not db_file:
1801
  raise HTTPException(status_code=404, detail="File not found")
1802
  total_contacts = db_file.contact_count or 0
1803
+ has_linkedin = (
1804
+ db.query(Prompt.id)
1805
+ .filter(
1806
+ Prompt.tenant_id == t.tenant_id,
1807
+ Prompt.file_id == file_id,
1808
+ Prompt.prompt_kind == "linkedin",
1809
+ )
1810
+ .first()
1811
+ is not None
1812
+ )
1813
+ email_done = (
1814
+ db.query(GeneratedSequence.sequence_id)
1815
+ .filter(
1816
+ GeneratedSequence.tenant_id == t.tenant_id,
1817
+ GeneratedSequence.file_id == file_id,
1818
+ GeneratedSequence.channel == "email",
1819
+ )
1820
+ .distinct()
1821
+ .count()
1822
+ )
1823
+ li_done = (
1824
  db.query(GeneratedSequence.sequence_id)
1825
  .filter(
1826
  GeneratedSequence.tenant_id == t.tenant_id,
1827
  GeneratedSequence.file_id == file_id,
1828
+ GeneratedSequence.channel == "linkedin",
1829
  )
1830
  .distinct()
1831
  .count()
1832
  )
1833
+ completed_units = email_done + (li_done if has_linkedin else 0)
1834
+ expected_units = total_contacts * (2 if has_linkedin else 1)
1835
  return {
1836
  "file_id": file_id,
1837
  "total_contacts": total_contacts,
1838
+ "completed_count": completed_units,
1839
+ "is_complete": total_contacts > 0 and completed_units >= expected_units,
1840
+ "has_linkedin_prompts": has_linkedin,
1841
  }
1842
 
1843
 
 
1867
  "product": seq.product,
1868
  "subject": seq.subject,
1869
  "emailContent": seq.email_content,
1870
+ "channel": getattr(seq, "channel", None) or "email",
1871
  })
1872
  return {"sequences": out}
1873
 
 
1903
  .filter(Prompt.tenant_id == tenant_id, Prompt.file_id == file_id)
1904
  .all()
1905
  )
1906
+ email_prompt_dict = {
1907
+ p.product_name: p.prompt_template
1908
+ for p in prompts
1909
+ if (getattr(p, "prompt_kind", None) or "email") == "email"
1910
+ }
1911
+ linkedin_prompt_dict = {
1912
+ p.product_name: p.prompt_template
1913
+ for p in prompts
1914
+ if getattr(p, "prompt_kind", None) == "linkedin"
1915
+ }
1916
+
1917
+ if not email_prompt_dict:
1918
  yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
1919
  return
1920
+
1921
+ products = list(email_prompt_dict.keys())
1922
+ li_products = list(linkedin_prompt_dict.keys())
1923
+ run_linkedin = len(li_products) > 0
1924
+
1925
  if reset:
1926
  db.query(GeneratedSequence).filter(
1927
  GeneratedSequence.tenant_id == tenant_id,
1928
  GeneratedSequence.file_id == file_id,
1929
  ).delete()
1930
  db.commit()
1931
+
1932
  total_contacts = len(df)
1933
+
1934
+ def progress_pct(seq_id: int, *, linkedin_phase: bool) -> float:
1935
+ if total_contacts <= 0:
1936
+ return 0.0
1937
+ if run_linkedin and not linkedin_phase:
1938
+ return min(100.0, max(0.0, (seq_id / total_contacts) * 50.0))
1939
+ if run_linkedin and linkedin_phase:
1940
+ return min(100.0, max(0.0, 50.0 + (seq_id / total_contacts) * 50.0))
1941
+ return min(100.0, max(0.0, (seq_id / total_contacts) * 100.0))
1942
+
1943
  sequence_id = 1
 
1944
  for idx, row in df.iterrows():
1945
  existing = (
1946
  db.query(GeneratedSequence)
 
1948
  GeneratedSequence.tenant_id == tenant_id,
1949
  GeneratedSequence.file_id == file_id,
1950
  GeneratedSequence.sequence_id == sequence_id,
1951
+ GeneratedSequence.channel == "email",
1952
  )
1953
  .order_by(GeneratedSequence.email_number)
1954
  .all()
 
1966
  "product": seq.product,
1967
  "subject": seq.subject,
1968
  "emailContent": seq.email_content,
1969
+ "channel": "email",
1970
  }
1971
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
1972
+ yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=False)})}\n\n"
 
1973
  sequence_id += 1
1974
  await asyncio.sleep(0.05)
1975
  continue
1976
+
1977
  contact = row.to_dict()
1978
+ product_name = products[(sequence_id - 1) % len(products)]
1979
+ prompt_template = email_prompt_dict[product_name]
1980
+
1981
  loop = asyncio.get_event_loop()
1982
  with concurrent.futures.ThreadPoolExecutor() as executor:
1983
  sequence_data_list = await loop.run_in_executor(
 
1987
  prompt_template,
1988
  product_name,
1989
  )
1990
+
1991
  for seq_data in sequence_data_list:
1992
  db_sequence = GeneratedSequence(
1993
  tenant_id=tenant_id,
1994
  file_id=file_id,
1995
  sequence_id=sequence_id,
1996
  email_number=seq_data["email_number"],
1997
+ channel="email",
1998
  first_name=seq_data["first_name"],
1999
  last_name=seq_data["last_name"],
2000
  email=seq_data["email"],
 
2005
  email_content=seq_data["email_content"],
2006
  )
2007
  db.add(db_sequence)
2008
+
2009
  db.commit()
2010
+
2011
  for seq_data in sequence_data_list:
2012
  sequence_response = {
2013
  "id": sequence_id,
 
2020
  "product": seq_data["product"],
2021
  "subject": seq_data["subject"],
2022
  "emailContent": seq_data["email_content"],
2023
+ "channel": "email",
2024
  }
2025
  yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
2026
+
2027
+ yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=False)})}\n\n"
 
2028
  sequence_id += 1
2029
  await asyncio.sleep(0.1)
2030
+
2031
+ if run_linkedin:
2032
+ yield f"data: {json.dumps({'type': 'phase', 'phase': 'linkedin', 'message': 'Generating LinkedIn sequences'})}\n\n"
2033
+ sequence_id = 1
2034
+ for idx, row in df.iterrows():
2035
+ existing_li = (
2036
+ db.query(GeneratedSequence)
2037
+ .filter(
2038
+ GeneratedSequence.tenant_id == tenant_id,
2039
+ GeneratedSequence.file_id == file_id,
2040
+ GeneratedSequence.sequence_id == sequence_id,
2041
+ GeneratedSequence.channel == "linkedin",
2042
+ )
2043
+ .order_by(GeneratedSequence.email_number)
2044
+ .all()
2045
+ )
2046
+ if existing_li:
2047
+ for seq in existing_li:
2048
+ sequence_response = {
2049
+ "id": seq.sequence_id,
2050
+ "emailNumber": seq.email_number,
2051
+ "firstName": seq.first_name,
2052
+ "lastName": seq.last_name,
2053
+ "email": seq.email,
2054
+ "company": seq.company,
2055
+ "title": seq.title or "",
2056
+ "product": seq.product,
2057
+ "subject": seq.subject or "",
2058
+ "emailContent": seq.email_content,
2059
+ "channel": "linkedin",
2060
+ }
2061
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
2062
+ yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=True)})}\n\n"
2063
+ sequence_id += 1
2064
+ await asyncio.sleep(0.05)
2065
+ continue
2066
+
2067
+ contact = row.to_dict()
2068
+ li_product = li_products[(sequence_id - 1) % len(li_products)]
2069
+ li_template = linkedin_prompt_dict[li_product]
2070
+
2071
+ loop = asyncio.get_event_loop()
2072
+ with concurrent.futures.ThreadPoolExecutor() as executor:
2073
+ li_list = await loop.run_in_executor(
2074
+ executor,
2075
+ generate_linkedin_sequence,
2076
+ contact,
2077
+ li_template,
2078
+ li_product,
2079
+ )
2080
+
2081
+ for seq_data in li_list:
2082
+ db_sequence = GeneratedSequence(
2083
+ tenant_id=tenant_id,
2084
+ file_id=file_id,
2085
+ sequence_id=sequence_id,
2086
+ email_number=seq_data["email_number"],
2087
+ channel="linkedin",
2088
+ first_name=seq_data["first_name"],
2089
+ last_name=seq_data["last_name"],
2090
+ email=seq_data["email"],
2091
+ company=seq_data["company"],
2092
+ title=seq_data.get("title", ""),
2093
+ product=seq_data["product"],
2094
+ subject=seq_data.get("subject") or "",
2095
+ email_content=seq_data["email_content"],
2096
+ )
2097
+ db.add(db_sequence)
2098
+
2099
+ db.commit()
2100
+
2101
+ for seq_data in li_list:
2102
+ sequence_response = {
2103
+ "id": sequence_id,
2104
+ "emailNumber": seq_data["email_number"],
2105
+ "firstName": seq_data["first_name"],
2106
+ "lastName": seq_data["last_name"],
2107
+ "email": seq_data["email"],
2108
+ "company": seq_data["company"],
2109
+ "title": seq_data.get("title", ""),
2110
+ "product": seq_data["product"],
2111
+ "subject": seq_data.get("subject") or "",
2112
+ "emailContent": seq_data["email_content"],
2113
+ "channel": "linkedin",
2114
+ }
2115
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
2116
+
2117
+ yield f"data: {json.dumps({'type': 'progress', 'progress': progress_pct(sequence_id, linkedin_phase=True)})}\n\n"
2118
+ sequence_id += 1
2119
+ await asyncio.sleep(0.1)
2120
+
2121
  yield f"data: {json.dumps({'type': 'complete'})}\n\n"
2122
 
2123
  except Exception as e:
 
2137
  .filter(
2138
  GeneratedSequence.tenant_id == t.tenant_id,
2139
  GeneratedSequence.file_id == file_id,
2140
+ GeneratedSequence.channel == "email",
2141
  )
2142
  .order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
2143
  .all()
2144
  )
 
2145
  if not sequences:
2146
+ raise HTTPException(
2147
+ status_code=404,
2148
+ detail="No email sequences found (CSV export is email-only; view LinkedIn in the app).",
2149
+ )
2150
 
2151
  # Group sequences by contact
2152
  contacts = {}
backend/app/models.py CHANGED
@@ -13,6 +13,7 @@ class PromptSaveRequest(BaseModel):
13
  file_id: str
14
  prompts: Dict[str, str]
15
  products: List[str]
 
16
 
17
 
18
  class SequenceResponse(BaseModel):
 
13
  file_id: str
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):
frontend/src/App.jsx CHANGED
@@ -1,5 +1,6 @@
1
  import React from "react";
2
  import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
 
3
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
4
  import Contacts from "./pages/Contacts";
5
  import Leads from "./pages/Leads";
@@ -11,6 +12,7 @@ import "./index.css";
11
  export default function App() {
12
  return (
13
  <BrowserRouter>
 
14
  <Routes>
15
  <Route path="/" element={<EmailSequenceGenerator />} />
16
  <Route path="/contacts" element={<Contacts />} />
@@ -20,6 +22,7 @@ export default function App() {
20
  <Route path="/settings" element={<Settings />} />
21
  <Route path="/history" element={<Navigate to="/leads" replace />} />
22
  </Routes>
 
23
  </BrowserRouter>
24
  );
25
  }
 
1
  import React from "react";
2
  import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
3
+ import { GeneratorWorkflowProvider } from "./context/GeneratorWorkflowContext";
4
  import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
5
  import Contacts from "./pages/Contacts";
6
  import Leads from "./pages/Leads";
 
12
  export default function App() {
13
  return (
14
  <BrowserRouter>
15
+ <GeneratorWorkflowProvider>
16
  <Routes>
17
  <Route path="/" element={<EmailSequenceGenerator />} />
18
  <Route path="/contacts" element={<Contacts />} />
 
22
  <Route path="/settings" element={<Settings />} />
23
  <Route path="/history" element={<Navigate to="/leads" replace />} />
24
  </Routes>
25
+ </GeneratorWorkflowProvider>
26
  </BrowserRouter>
27
  );
28
  }
frontend/src/components/prompts/PromptEditor.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import React, { useState, useEffect } from 'react';
2
- import { FileText, Save, RotateCcw, Sparkles, CheckCircle2 } from 'lucide-react';
3
  import { Button } from "@/components/ui/button";
4
  import { Textarea } from "@/components/ui/textarea";
5
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
@@ -780,7 +780,50 @@ Best,
780
  {{sender_name}}`,
781
  };
782
 
783
- export default function PromptEditor({ selectedProducts, prompts, onPromptsChange, onSaveComplete }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
784
  const [activeTab, setActiveTab] = useState(selectedProducts[0]?.name || '');
785
  const [savedStatus, setSavedStatus] = useState({});
786
  const [localPrompts, setLocalPrompts] = useState({});
@@ -795,23 +838,24 @@ export default function PromptEditor({ selectedProducts, prompts, onPromptsChang
795
  }, [selectedProducts, activeTab]);
796
 
797
  useEffect(() => {
798
- // Initialize prompts for selected products.
799
- // Priority: saved prompt > product default template > generic fallback.
800
  const newPrompts = {};
801
- selectedProducts.forEach(product => {
802
  const savedPrompt = prompts[product.name];
803
- const defaultTemplate = DEFAULT_TEMPLATES[product.name];
804
 
805
  if (savedPrompt) {
806
  newPrompts[product.name] = savedPrompt;
807
  } else if (defaultTemplate) {
808
  newPrompts[product.name] = defaultTemplate;
 
 
809
  } else {
810
  newPrompts[product.name] = `Subject: {{first_name}}, let's talk about ${product.name}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${product.name} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`;
811
  }
812
  });
813
  setLocalPrompts(newPrompts);
814
- }, [selectedProducts, prompts]);
815
 
816
  const handlePromptChange = (productName, value) => {
817
  setLocalPrompts(prev => ({
@@ -850,8 +894,12 @@ export default function PromptEditor({ selectedProducts, prompts, onPromptsChang
850
  };
851
 
852
  const handleRestoreDefault = (productName) => {
853
- const defaultTemplate = DEFAULT_TEMPLATES[productName] ||
854
- `Subject: {{first_name}}, let's talk about ${productName}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${productName} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`;
 
 
 
 
855
  handlePromptChange(productName, defaultTemplate);
856
  };
857
 
@@ -895,10 +943,18 @@ export default function PromptEditor({ selectedProducts, prompts, onPromptsChang
895
  <div className="border-b border-slate-100 bg-slate-50/50 px-6 py-4 flex items-center justify-between">
896
  <div className="flex items-center gap-3">
897
  <div className="rounded-lg bg-violet-100 p-2">
898
- <Sparkles className="h-4 w-4 text-violet-600" />
 
 
 
 
899
  </div>
900
  <div>
901
- <h4 className="font-semibold text-slate-800">Email Template</h4>
 
 
 
 
902
  <p className="text-xs text-slate-500">
903
  Use variables: {"{{first_name}}"}, {"{{company}}"}, {"{{sender_name}}"}
904
  </p>
@@ -937,7 +993,11 @@ export default function PromptEditor({ selectedProducts, prompts, onPromptsChang
937
  <Textarea
938
  value={localPrompts[product.name] || ''}
939
  onChange={(e) => handlePromptChange(product.name, e.target.value)}
940
- placeholder="Enter your email template here..."
 
 
 
 
941
  className="min-h-[320px] font-mono text-sm leading-relaxed resize-none
942
  border-slate-200 focus:border-violet-300 focus:ring-violet-200"
943
  />
 
1
  import React, { useState, useEffect } from 'react';
2
+ import { FileText, Save, RotateCcw, Sparkles, CheckCircle2, Linkedin } from 'lucide-react';
3
  import { Button } from "@/components/ui/button";
4
  import { Textarea } from "@/components/ui/textarea";
5
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
 
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.
787
+ Sender first name is always Anna. Never use the prospect as the sender.
788
+ No fake LinkedIn "I saw your post" personalization. Use only provided fields.
789
+ Tone: calm, peer-to-peer, non-salesy, no emojis, no em dashes.
790
+
791
+ Output format (strict) — 3 messages:
792
+ Message 1
793
+ [connection note or first DM: ≤300 characters if connection note; otherwise short paragraph]
794
+
795
+ Message 2
796
+ [follow-up DM, new angle]
797
+
798
+ Message 3
799
+ [light check-in, one question]
800
+
801
+ Use variables in your reasoning: {{first_name}}, {{company}}, {{sender_name}} but output final text with real first name filled in.`,
802
+
803
+ 'Sales Order Processing': `🔒 LINKEDIN SYSTEM PROMPT
804
+ You write concise LinkedIn DMs for order-ops / AR professionals. Sender: Anna. Plain language, operational focus, one question per message. No hype.
805
+ Output Message 1, 2, 3 as labeled blocks.`,
806
+
807
+ 'Document Management': `🔒 LINKEDIN SYSTEM PROMPT
808
+ Short LinkedIn sequence (3 messages) for document / records leaders. Calm, practical, non-marketing. Sender Anna. Use Message 1/2/3 format.`,
809
+
810
+ 'Invoice Processing': `🔒 LINKEDIN SYSTEM PROMPT
811
+ 3-step LinkedIn outreach for invoice / AP operations. Under 120 words per message. Message 1, 2, 3 format. Anna as sender.`,
812
+
813
+ 'Expense Management': `🔒 LINKEDIN SYSTEM PROMPT
814
+ LinkedIn DMs for finance teams about expense workflows. 3 messages, professional, no buzzwords. Message 1, 2, 3.`,
815
+
816
+ 'Procurement Automation': `🔒 LINKEDIN SYSTEM PROMPT
817
+ Procurement-focused LinkedIn sequence in 3 messages. Practical questions only. Label Message 1, 2, 3.`,
818
+ };
819
+
820
+ export default function PromptEditor({
821
+ selectedProducts,
822
+ prompts,
823
+ onPromptsChange,
824
+ onSaveComplete,
825
+ variant = 'email',
826
+ }) {
827
  const [activeTab, setActiveTab] = useState(selectedProducts[0]?.name || '');
828
  const [savedStatus, setSavedStatus] = useState({});
829
  const [localPrompts, setLocalPrompts] = useState({});
 
838
  }, [selectedProducts, activeTab]);
839
 
840
  useEffect(() => {
841
+ const library = variant === 'linkedin' ? LINKEDIN_DEFAULT_TEMPLATES : DEFAULT_TEMPLATES;
 
842
  const newPrompts = {};
843
+ selectedProducts.forEach((product) => {
844
  const savedPrompt = prompts[product.name];
845
+ const defaultTemplate = library[product.name];
846
 
847
  if (savedPrompt) {
848
  newPrompts[product.name] = savedPrompt;
849
  } else if (defaultTemplate) {
850
  newPrompts[product.name] = defaultTemplate;
851
+ } else if (variant === 'linkedin') {
852
+ newPrompts[product.name] = `🔒 LINKEDIN SYSTEM PROMPT\n\nGenerate a 3-message LinkedIn sequence for ${product.name}.\nLabel: Message 1, Message 2, Message 3.\nUse {{first_name}}, {{company}}. Sender: Anna.`;
853
  } else {
854
  newPrompts[product.name] = `Subject: {{first_name}}, let's talk about ${product.name}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${product.name} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`;
855
  }
856
  });
857
  setLocalPrompts(newPrompts);
858
+ }, [selectedProducts, prompts, variant]);
859
 
860
  const handlePromptChange = (productName, value) => {
861
  setLocalPrompts(prev => ({
 
894
  };
895
 
896
  const handleRestoreDefault = (productName) => {
897
+ const library = variant === 'linkedin' ? LINKEDIN_DEFAULT_TEMPLATES : DEFAULT_TEMPLATES;
898
+ const defaultTemplate =
899
+ library[productName] ||
900
+ (variant === 'linkedin'
901
+ ? `🔒 LINKEDIN SYSTEM PROMPT\n\nGenerate a 3-message LinkedIn sequence for ${productName}.\nLabel: Message 1, Message 2, Message 3.\nUse {{first_name}}, {{company}}. Sender: Anna.`
902
+ : `Subject: {{first_name}}, let's talk about ${productName}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${productName} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`);
903
  handlePromptChange(productName, defaultTemplate);
904
  };
905
 
 
943
  <div className="border-b border-slate-100 bg-slate-50/50 px-6 py-4 flex items-center justify-between">
944
  <div className="flex items-center gap-3">
945
  <div className="rounded-lg bg-violet-100 p-2">
946
+ {variant === 'linkedin' ? (
947
+ <Linkedin className="h-4 w-4 text-violet-600" />
948
+ ) : (
949
+ <Sparkles className="h-4 w-4 text-violet-600" />
950
+ )}
951
  </div>
952
  <div>
953
+ <h4 className="font-semibold text-slate-800">
954
+ {variant === 'linkedin'
955
+ ? 'LinkedIn sequence prompt'
956
+ : 'Email Template'}
957
+ </h4>
958
  <p className="text-xs text-slate-500">
959
  Use variables: {"{{first_name}}"}, {"{{company}}"}, {"{{sender_name}}"}
960
  </p>
 
993
  <Textarea
994
  value={localPrompts[product.name] || ''}
995
  onChange={(e) => handlePromptChange(product.name, e.target.value)}
996
+ placeholder={
997
+ variant === 'linkedin'
998
+ ? 'Enter your LinkedIn sequence system prompt…'
999
+ : 'Enter your email template here...'
1000
+ }
1001
  className="min-h-[320px] font-mono text-sm leading-relaxed resize-none
1002
  border-slate-200 focus:border-violet-300 focus:ring-violet-200"
1003
  />
frontend/src/components/sequences/SequenceCard.jsx CHANGED
@@ -9,8 +9,8 @@ export default function SequenceCard({ contact, index }) {
9
  const [copiedEmailNum, setCopiedEmailNum] = useState(null);
10
  const [expandedEmailNum, setExpandedEmailNum] = useState(null);
11
 
12
- // Group emails by contact
13
  const emails = contact.emails || [];
 
14
  const firstEmail = emails[0] || contact;
15
 
16
  const handleCopy = (emailContent, emailNum) => {
@@ -48,9 +48,15 @@ export default function SequenceCard({ contact, index }) {
48
  <Mail className="h-3.5 w-3.5" />
49
  {firstEmail.email}
50
  </span>
51
- {emails.length > 1 && (
52
  <span className="text-violet-600 font-medium">
53
- {emails.length} emails
 
 
 
 
 
 
54
  </span>
55
  )}
56
  </div>
@@ -77,7 +83,7 @@ export default function SequenceCard({ contact, index }) {
77
  >
78
  <div className="px-5 py-4 bg-slate-50/50 space-y-4">
79
  {emails.map((email, emailIdx) => (
80
- <div key={emailIdx} className="bg-white rounded-lg border border-slate-200 p-4">
81
  <div className="flex items-center justify-between mb-3">
82
  <div className="flex items-center gap-2">
83
  <h5 className="text-sm font-medium text-slate-600">
@@ -120,6 +126,45 @@ export default function SequenceCard({ contact, index }) {
120
  </div>
121
  </div>
122
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
  </motion.div>
125
  )}
 
9
  const [copiedEmailNum, setCopiedEmailNum] = useState(null);
10
  const [expandedEmailNum, setExpandedEmailNum] = useState(null);
11
 
 
12
  const emails = contact.emails || [];
13
+ const linkedin = contact.linkedin || [];
14
  const firstEmail = emails[0] || contact;
15
 
16
  const handleCopy = (emailContent, emailNum) => {
 
48
  <Mail className="h-3.5 w-3.5" />
49
  {firstEmail.email}
50
  </span>
51
+ {(emails.length > 0 || linkedin.length > 0) && (
52
  <span className="text-violet-600 font-medium">
53
+ {emails.length > 0
54
+ ? `${emails.length} email${emails.length === 1 ? '' : 's'}`
55
+ : null}
56
+ {emails.length > 0 && linkedin.length > 0 ? ' · ' : null}
57
+ {linkedin.length > 0
58
+ ? `${linkedin.length} LinkedIn`
59
+ : null}
60
  </span>
61
  )}
62
  </div>
 
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">
 
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
  )}
frontend/src/components/sequences/SequenceViewer.jsx CHANGED
@@ -1,179 +1,39 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
  import { Download, Mail, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
3
- import { Button } from "@/components/ui/button";
4
- import { Input } from "@/components/ui/input";
5
- import { Progress } from "@/components/ui/progress";
6
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
7
  import { motion, AnimatePresence } from 'framer-motion';
8
  import SequenceCard from './SequenceCard';
9
  import { apiFetch } from '@/lib/api';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
- function applySequenceToContacts(prev, sequence, contactCount, setProgress) {
12
- const existingContact = prev.find(c =>
13
- c.firstName === sequence.firstName &&
14
- c.lastName === sequence.lastName &&
15
- c.email === sequence.email
16
- );
17
- let updatedContacts;
18
- if (existingContact) {
19
- existingContact.emails.push({
20
- emailNumber: sequence.emailNumber || existingContact.emails.length + 1,
21
- subject: sequence.subject,
22
- emailContent: sequence.emailContent
23
- });
24
- updatedContacts = [...prev];
25
- } else {
26
- updatedContacts = [...prev, {
27
- id: sequence.id,
28
- firstName: sequence.firstName,
29
- lastName: sequence.lastName,
30
- email: sequence.email,
31
- company: sequence.company,
32
- title: sequence.title,
33
- product: sequence.product,
34
- emails: [{
35
- emailNumber: sequence.emailNumber || 1,
36
- subject: sequence.subject,
37
- emailContent: sequence.emailContent
38
- }]
39
- }];
40
- }
41
- const progressValue = contactCount > 0
42
- ? Math.min(100, Math.max(0, (updatedContacts.length / contactCount) * 100))
43
- : 0;
44
- setProgress(progressValue);
45
- return updatedContacts;
46
- }
47
-
48
- export default function SequenceViewer({ isGenerating, generationRunId, contactCount, selectedProducts, uploadedFile, prompts, onComplete }) {
49
- const [sequences, setSequences] = useState([]);
50
- const [contacts, setContacts] = useState([]);
51
- const [progress, setProgress] = useState(0);
52
  const [searchQuery, setSearchQuery] = useState('');
53
  const [filterProduct, setFilterProduct] = useState('all');
54
- const [isComplete, setIsComplete] = useState(false);
55
  const [displayedCount, setDisplayedCount] = useState(50);
56
- const [reconnectKey, setReconnectKey] = useState(0);
57
- const prevRunIdRef = useRef(null);
58
-
59
- useEffect(() => {
60
- if (!isGenerating || !uploadedFile?.fileId) return;
61
-
62
- const isNewRun = prevRunIdRef.current !== generationRunId;
63
- if (isNewRun) {
64
- prevRunIdRef.current = generationRunId;
65
- setSequences([]);
66
- setContacts([]);
67
- setProgress(0);
68
- setIsComplete(false);
69
- }
70
-
71
- const reset = isNewRun ? 1 : 0;
72
- const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`;
73
- const eventSource = new EventSource(url);
74
-
75
- eventSource.onmessage = (event) => {
76
- try {
77
- const data = JSON.parse(event.data);
78
- if (data.type === 'sequence') {
79
- const seq = data.sequence;
80
- setSequences(prev => {
81
- if (prev.some(s => s.id === seq.id && s.emailNumber === seq.emailNumber)) return prev;
82
- return [...prev, seq];
83
- });
84
- setContacts(prev => {
85
- const existing = prev.find(c => c.email === seq.email);
86
- if (existing?.emails.some(e => e.emailNumber === seq.emailNumber)) return prev;
87
- return applySequenceToContacts(prev, seq, contactCount, setProgress);
88
- });
89
- } else if (data.type === 'progress') {
90
- setProgress(data.progress);
91
- } else if (data.type === 'complete') {
92
- setIsComplete(true);
93
- onComplete?.();
94
- eventSource.close();
95
- } else if (data.type === 'error') {
96
- console.error('Generation error:', data.error);
97
- alert('Error generating sequences: ' + data.error);
98
- eventSource.close();
99
- }
100
- } catch (err) {
101
- console.error('Error parsing SSE data:', err);
102
- }
103
- };
104
-
105
- eventSource.onerror = () => {
106
- eventSource.close();
107
- if (!isComplete) setReconnectKey(k => k + 1);
108
- };
109
-
110
- return () => eventSource.close();
111
- }, [isGenerating, uploadedFile?.fileId, generationRunId, contactCount, reconnectKey, onComplete, isComplete]);
112
-
113
- useEffect(() => {
114
- if (!isGenerating || !uploadedFile?.fileId || reconnectKey === 0) return;
115
- let cancelled = false;
116
- (async () => {
117
- try {
118
- const [statusRes, seqRes] = await Promise.all([
119
- apiFetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
120
- apiFetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`)
121
- ]);
122
- if (cancelled) return;
123
- if (statusRes.ok && seqRes.ok) {
124
- const status = await statusRes.json();
125
- const { sequences: list } = await seqRes.json();
126
- if (status.is_complete) {
127
- setIsComplete(true);
128
- onComplete?.();
129
- }
130
- if (list?.length > 0) {
131
- const byContact = new Map();
132
- list.forEach(seq => {
133
- const key = seq.email;
134
- if (!byContact.has(key)) {
135
- byContact.set(key, {
136
- id: seq.id,
137
- firstName: seq.firstName,
138
- lastName: seq.lastName,
139
- email: seq.email,
140
- company: seq.company,
141
- title: seq.title,
142
- product: seq.product,
143
- emails: []
144
- });
145
- }
146
- byContact.get(key).emails.push({
147
- emailNumber: seq.emailNumber,
148
- subject: seq.subject,
149
- emailContent: seq.emailContent
150
- });
151
- });
152
- const arr = [...byContact.values()];
153
- arr.sort((a, b) => (a.id || 0) - (b.id || 0));
154
- setSequences(list);
155
- setContacts(arr);
156
- const p = status.total_contacts > 0 ? Math.min(100, (arr.length / status.total_contacts) * 100) : 0;
157
- setProgress(p);
158
- }
159
- }
160
- } catch (e) {
161
- if (!cancelled) console.error('Reconnect fetch error:', e);
162
- }
163
- })();
164
- return () => { cancelled = true; };
165
- }, [reconnectKey, isGenerating, uploadedFile?.fileId, contactCount, onComplete]);
166
 
167
  useEffect(() => {
168
- if (!isGenerating || !uploadedFile?.fileId) return;
169
- const onVisible = () => {
170
- if (document.visibilityState === 'visible') setReconnectKey(k => k + 1);
171
- };
172
- document.addEventListener('visibilitychange', onVisible);
173
- return () => document.removeEventListener('visibilitychange', onVisible);
174
- }, [isGenerating, uploadedFile?.fileId]);
175
 
176
  const handleDownload = async () => {
 
177
  try {
178
  const response = await apiFetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
179
  if (response.ok) {
@@ -193,38 +53,38 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
193
  }
194
  };
195
 
196
- const filteredContacts = contacts.filter(contact => {
197
- const matchesSearch = searchQuery === '' ||
 
198
  contact.firstName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
199
  contact.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
200
  contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
201
  contact.email?.toLowerCase().includes(searchQuery.toLowerCase());
202
-
203
  const matchesFilter = filterProduct === 'all' || contact.product === filterProduct;
204
-
205
  return matchesSearch && matchesFilter;
206
  });
207
 
208
- // Reset pagination when search/filter changes
209
- useEffect(() => {
210
- setDisplayedCount(50);
211
- }, [searchQuery, filterProduct]);
212
-
213
- // Pagination: only show first N contacts to avoid browser performance issues
214
  const displayedContacts = filteredContacts.slice(0, displayedCount);
215
  const hasMore = filteredContacts.length > displayedCount;
216
 
217
  const loadMore = () => {
218
- setDisplayedCount(prev => Math.min(prev + 50, filteredContacts.length));
219
  };
220
 
 
 
 
 
 
 
221
  return (
222
  <div className="w-full">
223
- {/* Progress Header */}
224
  <div className="rounded-2xl border border-slate-200 bg-white p-6 mb-6">
225
  <div className="flex items-center justify-between mb-4">
226
  <div className="flex items-center gap-3">
227
- {isComplete ? (
228
  <div className="rounded-xl bg-green-100 p-3">
229
  <CheckCircle2 className="h-6 w-6 text-green-600" />
230
  </div>
@@ -235,27 +95,24 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
235
  )}
236
  <div>
237
  <h3 className="font-semibold text-slate-800">
238
- {isComplete ? 'Generation Complete!' : 'Generating Email Sequences...'}
239
  </h3>
240
  <p className="text-sm text-slate-500">
241
- {contacts.length} of {contactCount} contacts, {sequences.length} total emails generated
 
242
  </p>
243
  </div>
244
  </div>
245
- {isComplete && (
246
- <Button
247
- onClick={handleDownload}
248
- className="bg-green-600 hover:bg-green-700"
249
- >
250
- <Download className="h-4 w-4 mr-2" />
251
- Download CSV for Outreaches
252
- </Button>
253
- )}
254
  </div>
255
- <Progress value={progress} className="h-2" />
256
  </div>
257
 
258
- {/* Filters */}
259
  {sequences.length > 0 && (
260
  <motion.div
261
  initial={{ opacity: 0, y: -10 }}
@@ -278,7 +135,7 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
278
  </SelectTrigger>
279
  <SelectContent>
280
  <SelectItem value="all">All Products</SelectItem>
281
- {selectedProducts.map(product => (
282
  <SelectItem key={product.id} value={product.name}>
283
  {product.name}
284
  </SelectItem>
@@ -288,20 +145,19 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
288
  </motion.div>
289
  )}
290
 
291
- {/* Sequence List - Optimized for high volume with pagination */}
292
  <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
293
  <AnimatePresence>
294
  {displayedContacts.map((contact, index) => (
295
- <SequenceCard key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`} contact={contact} index={index} />
 
 
 
 
296
  ))}
297
  </AnimatePresence>
298
  {hasMore && (
299
  <div className="text-center py-4">
300
- <Button
301
- variant="outline"
302
- onClick={loadMore}
303
- className="mx-auto"
304
- >
305
  Load More ({filteredContacts.length - displayedCount} remaining)
306
  </Button>
307
  </div>
@@ -313,14 +169,13 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
313
  )}
314
  </div>
315
 
316
- {/* Empty State */}
317
- {!isGenerating && contacts.length === 0 && (
318
  <div className="text-center py-16">
319
  <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
320
  <Mail className="h-8 w-8 text-slate-300" />
321
  </div>
322
  <h3 className="text-lg font-semibold text-slate-400 mb-2">No sequences yet</h3>
323
- <p className="text-sm text-slate-400">Click "Generate Sequences" to start</p>
324
  </div>
325
  )}
326
  </div>
 
1
+ import React, { useState, useEffect } from 'react';
2
  import { Download, Mail, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
+ import { Input } from '@/components/ui/input';
5
+ import { Progress } from '@/components/ui/progress';
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
  import { motion, AnimatePresence } from 'framer-motion';
8
  import SequenceCard from './SequenceCard';
9
  import { apiFetch } from '@/lib/api';
10
+ import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
11
+
12
+
13
+ export default function SequenceViewer() {
14
+ const {
15
+ isGenerating,
16
+ generationComplete,
17
+ selectedProducts,
18
+ uploadedFile,
19
+ includeLinkedin,
20
+ genPhase,
21
+ contacts,
22
+ sequences,
23
+ progress,
24
+ contactCount,
25
+ } = useGeneratorWorkflow();
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const [searchQuery, setSearchQuery] = useState('');
28
  const [filterProduct, setFilterProduct] = useState('all');
 
29
  const [displayedCount, setDisplayedCount] = useState(50);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  useEffect(() => {
32
+ setDisplayedCount(50);
33
+ }, [searchQuery, filterProduct]);
 
 
 
 
 
34
 
35
  const handleDownload = async () => {
36
+ if (!uploadedFile?.fileId) return;
37
  try {
38
  const response = await apiFetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
39
  if (response.ok) {
 
53
  }
54
  };
55
 
56
+ const filteredContacts = contacts.filter((contact) => {
57
+ const matchesSearch =
58
+ searchQuery === '' ||
59
  contact.firstName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
60
  contact.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
61
  contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
62
  contact.email?.toLowerCase().includes(searchQuery.toLowerCase());
63
+
64
  const matchesFilter = filterProduct === 'all' || contact.product === filterProduct;
65
+
66
  return matchesSearch && matchesFilter;
67
  });
68
 
 
 
 
 
 
 
69
  const displayedContacts = filteredContacts.slice(0, displayedCount);
70
  const hasMore = filteredContacts.length > displayedCount;
71
 
72
  const loadMore = () => {
73
+ setDisplayedCount((prev) => Math.min(prev + 50, filteredContacts.length));
74
  };
75
 
76
+ const showProgress = isGenerating || !generationComplete;
77
+ const phaseLabel =
78
+ includeLinkedin && genPhase === 'linkedin' && isGenerating
79
+ ? 'LinkedIn sequences'
80
+ : 'Email sequences';
81
+
82
  return (
83
  <div className="w-full">
 
84
  <div className="rounded-2xl border border-slate-200 bg-white p-6 mb-6">
85
  <div className="flex items-center justify-between mb-4">
86
  <div className="flex items-center gap-3">
87
+ {generationComplete ? (
88
  <div className="rounded-xl bg-green-100 p-3">
89
  <CheckCircle2 className="h-6 w-6 text-green-600" />
90
  </div>
 
95
  )}
96
  <div>
97
  <h3 className="font-semibold text-slate-800">
98
+ {generationComplete ? 'Generation complete!' : `Generating ${phaseLabel}…`}
99
  </h3>
100
  <p className="text-sm text-slate-500">
101
+ {contacts.length} contacts · {sequences.length} generated rows
102
+ {contactCount ? ` · ~${contactCount} contacts in file` : ''}
103
  </p>
104
  </div>
105
  </div>
106
+ {generationComplete && (
107
+ <Button onClick={handleDownload} className="bg-green-600 hover:bg-green-700">
108
+ <Download className="h-4 w-4 mr-2" />
109
+ Download CSV
110
+ </Button>
111
+ )}
 
 
 
112
  </div>
113
+ {showProgress ? <Progress value={progress} className="h-2" /> : null}
114
  </div>
115
 
 
116
  {sequences.length > 0 && (
117
  <motion.div
118
  initial={{ opacity: 0, y: -10 }}
 
135
  </SelectTrigger>
136
  <SelectContent>
137
  <SelectItem value="all">All Products</SelectItem>
138
+ {selectedProducts.map((product) => (
139
  <SelectItem key={product.id} value={product.name}>
140
  {product.name}
141
  </SelectItem>
 
145
  </motion.div>
146
  )}
147
 
 
148
  <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
149
  <AnimatePresence>
150
  {displayedContacts.map((contact, index) => (
151
+ <SequenceCard
152
+ key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`}
153
+ contact={contact}
154
+ index={index}
155
+ />
156
  ))}
157
  </AnimatePresence>
158
  {hasMore && (
159
  <div className="text-center py-4">
160
+ <Button variant="outline" onClick={loadMore} className="mx-auto">
 
 
 
 
161
  Load More ({filteredContacts.length - displayedCount} remaining)
162
  </Button>
163
  </div>
 
169
  )}
170
  </div>
171
 
172
+ {!isGenerating && contacts.length === 0 && sequences.length === 0 && (
 
173
  <div className="text-center py-16">
174
  <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
175
  <Mail className="h-8 w-8 text-slate-300" />
176
  </div>
177
  <h3 className="text-lg font-semibold text-slate-400 mb-2">No sequences yet</h3>
178
+ <p className="text-sm text-slate-400">Click &quot;Generate Sequences&quot; to start</p>
179
  </div>
180
  )}
181
  </div>
frontend/src/components/workspace/WonBillingModal.jsx CHANGED
@@ -110,10 +110,6 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
110
  setLocalError('Contact person name is required.');
111
  return;
112
  }
113
- if (!noteToCustomer.trim()) {
114
- setLocalError('Note to customer is required.');
115
- return;
116
- }
117
  if (!noteToAccounts.trim()) {
118
  setLocalError('Note to our accounts is required.');
119
  return;
@@ -125,6 +121,18 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
125
  setLocalError('Each line must have a product or service name.');
126
  return;
127
  }
 
 
 
 
 
 
 
 
 
 
 
 
128
  const q = parseNum(row.qty);
129
  const r = parseNum(row.rate);
130
  if (!Number.isFinite(q) || q <= 0) {
@@ -207,11 +215,15 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
207
 
208
  <div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
209
  <div>
210
- <label className="mb-1 block text-xs font-medium text-slate-600">PO number</label>
 
 
211
  <Input value={poNumber} onChange={(e) => setPoNumber(e.target.value)} className="bg-white" />
212
  </div>
213
  <div>
214
- <label className="mb-1 block text-xs font-medium text-slate-600">Customer legal name</label>
 
 
215
  <Input
216
  value={customerLegalName}
217
  onChange={(e) => setCustomerLegalName(e.target.value)}
@@ -219,7 +231,9 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
219
  />
220
  </div>
221
  <div className="sm:col-span-2">
222
- <label className="mb-1 block text-xs font-medium text-slate-600">Customer address</label>
 
 
223
  <Textarea
224
  value={customerAddress}
225
  onChange={(e) => setCustomerAddress(e.target.value)}
@@ -228,7 +242,9 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
228
  />
229
  </div>
230
  <div>
231
- <label className="mb-1 block text-xs font-medium text-slate-600">Contact person name</label>
 
 
232
  <Input
233
  value={contactPersonName}
234
  onChange={(e) => setContactPersonName(e.target.value)}
@@ -256,7 +272,9 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
256
  />
257
  </div>
258
  <div className="sm:col-span-2">
259
- <label className="mb-1 block text-xs font-medium text-slate-600">Note to our accounts</label>
 
 
260
  <Textarea
261
  value={noteToAccounts}
262
  onChange={(e) => setNoteToAccounts(e.target.value)}
@@ -285,9 +303,15 @@ export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gm
285
  <th className="w-8 px-1 py-2" aria-hidden />
286
  <th className="w-8 px-1 py-2 text-center">#</th>
287
  <th className="min-w-[140px] px-2 py-2">Product / service</th>
288
- <th className="min-w-[120px] px-2 py-2">Description</th>
289
- <th className="min-w-[130px] px-2 py-2">Billing interval</th>
290
- <th className="w-24 px-2 py-2">Currency</th>
 
 
 
 
 
 
291
  <th className="w-20 px-2 py-2 text-right">Qty</th>
292
  <th className="w-24 px-2 py-2 text-right">Rate</th>
293
  <th className="w-28 px-2 py-2 text-right">Amount</th>
 
110
  setLocalError('Contact person name is required.');
111
  return;
112
  }
 
 
 
 
113
  if (!noteToAccounts.trim()) {
114
  setLocalError('Note to our accounts is required.');
115
  return;
 
121
  setLocalError('Each line must have a product or service name.');
122
  return;
123
  }
124
+ if (!row.description.trim()) {
125
+ setLocalError('Each line must have a description.');
126
+ return;
127
+ }
128
+ if (!row.billing_interval) {
129
+ setLocalError('Each line must have a billing interval.');
130
+ return;
131
+ }
132
+ if (!row.currency) {
133
+ setLocalError('Each line must have a currency.');
134
+ return;
135
+ }
136
  const q = parseNum(row.qty);
137
  const r = parseNum(row.rate);
138
  if (!Number.isFinite(q) || q <= 0) {
 
215
 
216
  <div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
217
  <div>
218
+ <label className="mb-1 block text-xs font-medium text-slate-600">
219
+ PO number <span className="text-red-600">*</span>
220
+ </label>
221
  <Input value={poNumber} onChange={(e) => setPoNumber(e.target.value)} className="bg-white" />
222
  </div>
223
  <div>
224
+ <label className="mb-1 block text-xs font-medium text-slate-600">
225
+ Customer legal name <span className="text-red-600">*</span>
226
+ </label>
227
  <Input
228
  value={customerLegalName}
229
  onChange={(e) => setCustomerLegalName(e.target.value)}
 
231
  />
232
  </div>
233
  <div className="sm:col-span-2">
234
+ <label className="mb-1 block text-xs font-medium text-slate-600">
235
+ Customer address <span className="text-red-600">*</span>
236
+ </label>
237
  <Textarea
238
  value={customerAddress}
239
  onChange={(e) => setCustomerAddress(e.target.value)}
 
242
  />
243
  </div>
244
  <div>
245
+ <label className="mb-1 block text-xs font-medium text-slate-600">
246
+ Contact person name <span className="text-red-600">*</span>
247
+ </label>
248
  <Input
249
  value={contactPersonName}
250
  onChange={(e) => setContactPersonName(e.target.value)}
 
272
  />
273
  </div>
274
  <div className="sm:col-span-2">
275
+ <label className="mb-1 block text-xs font-medium text-slate-600">
276
+ Note to our accounts <span className="text-red-600">*</span>
277
+ </label>
278
  <Textarea
279
  value={noteToAccounts}
280
  onChange={(e) => setNoteToAccounts(e.target.value)}
 
303
  <th className="w-8 px-1 py-2" aria-hidden />
304
  <th className="w-8 px-1 py-2 text-center">#</th>
305
  <th className="min-w-[140px] px-2 py-2">Product / service</th>
306
+ <th className="min-w-[120px] px-2 py-2">
307
+ Description <span className="text-red-600">*</span>
308
+ </th>
309
+ <th className="min-w-[130px] px-2 py-2">
310
+ Billing interval <span className="text-red-600">*</span>
311
+ </th>
312
+ <th className="w-24 px-2 py-2">
313
+ Currency <span className="text-red-600">*</span>
314
+ </th>
315
  <th className="w-20 px-2 py-2 text-right">Qty</th>
316
  <th className="w-24 px-2 py-2 text-right">Rate</th>
317
  <th className="w-28 px-2 py-2 text-right">Amount</th>
frontend/src/context/GeneratorWorkflowContext.jsx ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
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);
67
+ const [selectedProducts, setSelectedProducts] = useState([]);
68
+ const [prompts, setPrompts] = useState({});
69
+ const [linkedinPrompts, setLinkedinPrompts] = useState({});
70
+ const [includeLinkedin, setIncludeLinkedin] = useState(false);
71
+ const [isGenerating, setIsGenerating] = useState(false);
72
+ const [generationComplete, setGenerationComplete] = useState(false);
73
+ const [generationRunId, setGenerationRunId] = useState(0);
74
+ const [genPhase, setGenPhase] = useState('email');
75
+ const [contacts, setContacts] = useState([]);
76
+ const [sequences, setSequences] = useState([]);
77
+ const [progress, setProgress] = useState(0);
78
+ const [streamComplete, setStreamComplete] = useState(false);
79
+ const [reconnectKey, setReconnectKey] = useState(0);
80
+
81
+ const prevRunIdRef = useRef(null);
82
+ const eventSourceRef = useRef(null);
83
+
84
+ const resetWorkflow = useCallback(() => {
85
+ if (eventSourceRef.current) {
86
+ eventSourceRef.current.close();
87
+ eventSourceRef.current = null;
88
+ }
89
+ setStep(1);
90
+ setUploadedFile(null);
91
+ setSelectedProducts([]);
92
+ setPrompts({});
93
+ setLinkedinPrompts({});
94
+ setIncludeLinkedin(false);
95
+ setIsGenerating(false);
96
+ setGenerationComplete(false);
97
+ setGenerationRunId(0);
98
+ setGenPhase('email');
99
+ setContacts([]);
100
+ setSequences([]);
101
+ setProgress(0);
102
+ setStreamComplete(false);
103
+ prevRunIdRef.current = null;
104
+ }, []);
105
+
106
+ const beginGeneration = useCallback((runIdIncrement = true) => {
107
+ setStreamComplete(false);
108
+ setGenerationComplete(false);
109
+ setContacts([]);
110
+ setSequences([]);
111
+ setProgress(0);
112
+ setGenPhase('email');
113
+ setIsGenerating(true);
114
+ if (runIdIncrement) {
115
+ setGenerationRunId((r) => r + 1);
116
+ }
117
+ }, []);
118
+
119
+ const contactCount = uploadedFile?.contactCount || 0;
120
+
121
+ useEffect(() => {
122
+ if (!isGenerating || !uploadedFile?.fileId) return;
123
+
124
+ const isNewRun = prevRunIdRef.current !== generationRunId;
125
+ if (isNewRun) {
126
+ prevRunIdRef.current = generationRunId;
127
+ setContacts([]);
128
+ setSequences([]);
129
+ setProgress(0);
130
+ setStreamComplete(false);
131
+ }
132
+
133
+ const reset = isNewRun ? 1 : 0;
134
+ const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`;
135
+ const eventSource = new EventSource(url);
136
+ eventSourceRef.current = eventSource;
137
+
138
+ eventSource.onmessage = (event) => {
139
+ try {
140
+ const data = JSON.parse(event.data);
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') {
154
+ if (data.phase === 'linkedin') setGenPhase('linkedin');
155
+ } else if (data.type === 'complete') {
156
+ setStreamComplete(true);
157
+ setIsGenerating(false);
158
+ setGenerationComplete(true);
159
+ eventSource.close();
160
+ eventSourceRef.current = null;
161
+ } else if (data.type === 'error') {
162
+ console.error('Generation error:', data.error);
163
+ alert('Error generating sequences: ' + data.error);
164
+ eventSource.close();
165
+ eventSourceRef.current = null;
166
+ setIsGenerating(false);
167
+ }
168
+ } catch (err) {
169
+ console.error('Error parsing SSE data:', err);
170
+ }
171
+ };
172
+
173
+ eventSource.onerror = () => {
174
+ eventSource.close();
175
+ eventSourceRef.current = null;
176
+ if (!streamComplete) setReconnectKey((k) => k + 1);
177
+ };
178
+
179
+ return () => {
180
+ eventSource.close();
181
+ if (eventSourceRef.current === eventSource) eventSourceRef.current = null;
182
+ };
183
+ }, [isGenerating, uploadedFile?.fileId, generationRunId]);
184
+
185
+ useEffect(() => {
186
+ if (!isGenerating || !uploadedFile?.fileId || reconnectKey === 0) return;
187
+ let cancelled = false;
188
+ (async () => {
189
+ try {
190
+ const [statusRes, seqRes] = await Promise.all([
191
+ apiFetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
192
+ apiFetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
193
+ ]);
194
+ if (cancelled) return;
195
+ if (statusRes.ok && seqRes.ok) {
196
+ const status = await statusRes.json();
197
+ const { sequences: list } = await seqRes.json();
198
+ if (status.is_complete) {
199
+ setStreamComplete(true);
200
+ setIsGenerating(false);
201
+ setGenerationComplete(true);
202
+ }
203
+ if (list?.length > 0) {
204
+ const byContact = new Map();
205
+ list.forEach((seq) => {
206
+ const key = seq.email;
207
+ const ch = seq.channel || 'email';
208
+ if (!byContact.has(key)) {
209
+ byContact.set(key, {
210
+ id: seq.id,
211
+ firstName: seq.firstName,
212
+ lastName: seq.lastName,
213
+ email: seq.email,
214
+ company: seq.company,
215
+ title: seq.title,
216
+ product: seq.product,
217
+ emails: [],
218
+ linkedin: [],
219
+ });
220
+ }
221
+ const row = {
222
+ emailNumber: seq.emailNumber,
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
+ }
237
+ });
238
+ const arr = [...byContact.values()];
239
+ arr.sort((a, b) => (a.id || 0) - (b.id || 0));
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
+ );
249
+ }
250
+ }
251
+ }
252
+ } catch (e) {
253
+ if (!cancelled) console.error('Reconnect fetch error:', e);
254
+ }
255
+ })();
256
+ return () => {
257
+ cancelled = true;
258
+ };
259
+ }, [reconnectKey, isGenerating, uploadedFile?.fileId, contactCount]);
260
+
261
+ useEffect(() => {
262
+ if (!isGenerating || !uploadedFile?.fileId) return;
263
+ const onVisible = () => {
264
+ if (document.visibilityState === 'visible') setReconnectKey((k) => k + 1);
265
+ };
266
+ document.addEventListener('visibilitychange', onVisible);
267
+ return () => document.removeEventListener('visibilitychange', onVisible);
268
+ }, [isGenerating, uploadedFile?.fileId]);
269
+
270
+ const value = useMemo(
271
+ () => ({
272
+ step,
273
+ setStep,
274
+ uploadedFile,
275
+ setUploadedFile,
276
+ selectedProducts,
277
+ setSelectedProducts,
278
+ prompts,
279
+ setPrompts,
280
+ linkedinPrompts,
281
+ setLinkedinPrompts,
282
+ includeLinkedin,
283
+ setIncludeLinkedin,
284
+ isGenerating,
285
+ generationComplete,
286
+ generationRunId,
287
+ beginGeneration,
288
+ setIsGenerating,
289
+ setGenerationComplete,
290
+ resetWorkflow,
291
+ contacts,
292
+ sequences,
293
+ progress,
294
+ genPhase,
295
+ streamComplete,
296
+ contactCount,
297
+ }),
298
+ [
299
+ step,
300
+ uploadedFile,
301
+ selectedProducts,
302
+ prompts,
303
+ linkedinPrompts,
304
+ includeLinkedin,
305
+ isGenerating,
306
+ generationComplete,
307
+ generationRunId,
308
+ beginGeneration,
309
+ resetWorkflow,
310
+ contacts,
311
+ sequences,
312
+ progress,
313
+ genPhase,
314
+ streamComplete,
315
+ contactCount,
316
+ ]
317
+ );
318
+
319
+ return (
320
+ <GeneratorWorkflowContext.Provider value={value}>{children}</GeneratorWorkflowContext.Provider>
321
+ );
322
+ }
323
+
324
+ export function useGeneratorWorkflow() {
325
+ const ctx = useContext(GeneratorWorkflowContext);
326
+ if (!ctx) {
327
+ throw new Error('useGeneratorWorkflow must be used within GeneratorWorkflowProvider');
328
+ }
329
+ return ctx;
330
+ }
frontend/src/pages/EmailSequenceGenerator.jsx CHANGED
@@ -1,6 +1,6 @@
1
- import React, { useState, useRef } from 'react';
2
  import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react';
3
- import { Button } from "@/components/ui/button";
4
  import { motion, AnimatePresence } from 'framer-motion';
5
 
6
  import UploadStep from '@/components/upload/UploadStep';
@@ -9,45 +9,63 @@ import PromptEditor from '@/components/prompts/PromptEditor';
9
  import SequenceViewer from '@/components/sequences/SequenceViewer';
10
  import AppShell from '@/components/layout/AppShell';
11
  import { apiFetch } from '@/lib/api';
 
12
 
13
  export default function EmailSequenceGenerator() {
14
- const [step, setStep] = useState(1);
15
- const [uploadedFile, setUploadedFile] = useState(null);
16
- const [selectedProducts, setSelectedProducts] = useState([]);
17
- const [prompts, setPrompts] = useState({});
18
- const [isGenerating, setIsGenerating] = useState(false);
19
- const [generationComplete, setGenerationComplete] = useState(false);
20
- const [generationRunId, setGenerationRunId] = useState(0);
 
 
 
 
 
 
 
 
 
 
 
 
21
  const generateButtonRef = useRef(null);
22
 
23
  const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
24
- const canProceedToStep3 = Object.keys(prompts).length > 0;
 
 
 
 
 
25
 
26
  const scrollToGenerateButton = () => {
27
  if (generateButtonRef.current) {
28
- generateButtonRef.current.scrollIntoView({
29
- behavior: 'smooth',
30
- block: 'center'
31
  });
32
  }
33
  };
34
 
35
  const handleGenerate = async () => {
36
- if (!uploadedFile?.fileId || selectedProducts.length === 0 || Object.keys(prompts).length === 0) {
37
  alert('Please complete all steps before generating sequences.');
38
  return;
39
  }
40
 
41
- // Save prompts to backend first, then start generation (avoids "No prompts found" race)
42
  try {
43
  const res = await apiFetch('/api/save-prompts', {
44
  method: 'POST',
45
  headers: { 'Content-Type': 'application/json' },
46
  body: JSON.stringify({
47
  file_id: uploadedFile.fileId,
48
- prompts: prompts,
49
- products: selectedProducts.map(p => p.name)
50
- })
 
51
  });
52
  if (!res.ok) {
53
  const err = await res.json().catch(() => ({}));
@@ -59,23 +77,8 @@ export default function EmailSequenceGenerator() {
59
  return;
60
  }
61
 
62
- setGenerationRunId((r) => r + 1);
63
  setStep(3);
64
- setIsGenerating(true);
65
- };
66
-
67
- const handleGenerationComplete = () => {
68
- setIsGenerating(false);
69
- setGenerationComplete(true);
70
- };
71
-
72
- const handleReset = () => {
73
- setStep(1);
74
- setUploadedFile(null);
75
- setSelectedProducts([]);
76
- setPrompts({});
77
- setIsGenerating(false);
78
- setGenerationComplete(false);
79
  };
80
 
81
  return (
@@ -86,7 +89,7 @@ export default function EmailSequenceGenerator() {
86
  step > 1 ? (
87
  <Button
88
  variant="ghost"
89
- onClick={handleReset}
90
  className="text-slate-500 hover:text-slate-700"
91
  >
92
  Start Over
@@ -94,191 +97,207 @@ export default function EmailSequenceGenerator() {
94
  ) : null
95
  }
96
  >
97
- {/* Progress Steps */}
98
- <div className="mb-10">
99
- <div className="flex items-center justify-center gap-4">
100
- {[
101
- { num: 1, label: 'Upload & Select' },
102
- { num: 2, label: 'Configure Prompts' },
103
- { num: 3, label: 'Generate & Export' }
104
- ].map((s, idx) => (
105
- <React.Fragment key={s.num}>
106
- <div className="flex items-center gap-2">
107
- <div className={`
108
  h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold
109
  transition-all duration-300
110
- ${step >= s.num
111
- ? 'bg-violet-600 text-white shadow-lg shadow-violet-200'
112
- : 'bg-slate-100 text-slate-400'
 
113
  }
114
- `}>
115
- {s.num}
116
- </div>
117
- <span className={`text-sm font-medium hidden sm:block ${
118
- step >= s.num ? 'text-slate-800' : 'text-slate-400'
119
- }`}>
120
- {s.label}
121
- </span>
122
  </div>
123
- {idx < 2 && (
124
- <div className={`h-0.5 w-12 rounded-full transition-colors duration-300 ${
 
 
 
 
 
 
 
 
 
125
  step > s.num ? 'bg-violet-600' : 'bg-slate-200'
126
- }`} />
127
- )}
128
- </React.Fragment>
129
- ))}
130
- </div>
131
  </div>
 
132
 
133
- <AnimatePresence mode="wait">
134
- {/* Step 1: Upload & Product Selection */}
135
- {step === 1 && (
136
- <motion.div
137
- key="step1"
138
- initial={{ opacity: 0, x: -20 }}
139
- animate={{ opacity: 1, x: 0 }}
140
- exit={{ opacity: 0, x: 20 }}
141
- transition={{ duration: 0.3 }}
142
- className="space-y-8"
143
- >
144
- <div className="text-center mb-8">
145
- <h2 className="text-2xl font-bold text-slate-800 mb-2">
146
- Upload Your Contacts
147
- </h2>
148
- <p className="text-slate-500">
149
- Import your Apollo CSV and select the products for your outreach campaign
150
- </p>
151
- </div>
152
 
153
- <UploadStep
154
- onFileUploaded={setUploadedFile}
155
- uploadedFile={uploadedFile}
156
- onRemoveFile={() => setUploadedFile(null)}
157
- />
158
 
159
- {uploadedFile && (
160
- <motion.div
161
- initial={{ opacity: 0, y: 20 }}
162
- animate={{ opacity: 1, y: 0 }}
163
- transition={{ delay: 0.2 }}
164
- >
165
- <ProductSelector
166
- selectedProducts={selectedProducts}
167
- onProductsChange={setSelectedProducts}
168
- />
169
- </motion.div>
170
- )}
171
 
172
- <div className="flex justify-end pt-4">
173
- <Button
174
- onClick={() => setStep(2)}
175
- disabled={!canProceedToStep2}
176
- className="bg-violet-600 hover:bg-violet-700 px-6"
177
- >
178
- Continue to Prompts
179
- <ArrowRight className="h-4 w-4 ml-2" />
180
- </Button>
181
- </div>
182
- </motion.div>
183
- )}
184
 
185
- {/* Step 2: Prompt Configuration */}
186
- {step === 2 && (
187
- <motion.div
188
- key="step2"
189
- initial={{ opacity: 0, x: -20 }}
190
- animate={{ opacity: 1, x: 0 }}
191
- exit={{ opacity: 0, x: 20 }}
192
- transition={{ duration: 0.3 }}
193
- className="space-y-8"
194
- >
195
- <div className="text-center mb-8">
196
- <h2 className="text-2xl font-bold text-slate-800 mb-2">
197
- Customize Your Email Templates
198
- </h2>
199
- <p className="text-slate-500">
200
- Edit the prompt templates for each product. The AI will personalize these for each contact.
201
- </p>
202
- </div>
 
 
 
 
 
 
 
 
203
 
204
- <PromptEditor
205
- selectedProducts={selectedProducts}
206
- prompts={prompts}
207
- onPromptsChange={setPrompts}
208
- onSaveComplete={scrollToGenerateButton}
 
209
  />
 
 
 
 
 
 
 
 
 
 
210
 
211
- <div className="flex justify-between pt-4">
212
- <Button
213
- variant="outline"
214
- onClick={() => setStep(1)}
215
- className="px-6"
216
- >
217
- <ArrowLeft className="h-4 w-4 mr-2" />
218
- Back
219
- </Button>
220
- <Button
221
- ref={generateButtonRef}
222
- onClick={handleGenerate}
223
- disabled={!canProceedToStep3}
224
- className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700
225
- hover:to-purple-700 px-8 shadow-lg shadow-violet-200"
226
- >
227
- <Sparkles className="h-4 w-4 mr-2" />
228
- Generate Sequences
229
- </Button>
230
  </div>
231
- </motion.div>
232
- )}
233
 
234
- {/* Step 3: Generation & Results */}
235
- {step === 3 && (
236
- <motion.div
237
- key="step3"
238
- initial={{ opacity: 0, x: -20 }}
239
- animate={{ opacity: 1, x: 0 }}
240
- exit={{ opacity: 0, x: 20 }}
241
- transition={{ duration: 0.3 }}
242
- className="space-y-8"
243
- >
244
- <div className="text-center mb-8">
245
- <h2 className="text-2xl font-bold text-slate-800 mb-2">
246
- {generationComplete ? 'Your Sequences Are Ready!' : 'Generating Personalized Emails'}
247
- </h2>
248
- <p className="text-slate-500">
249
- {generationComplete
250
- ? 'Review your sequences below and download when ready'
251
- : 'Our AI is crafting personalized emails for each contact'
252
- }
253
- </p>
254
- </div>
255
 
256
- <SequenceViewer
257
- isGenerating={isGenerating}
258
- generationRunId={generationRunId}
259
- contactCount={uploadedFile?.contactCount || 50}
260
- selectedProducts={selectedProducts}
261
- uploadedFile={uploadedFile}
262
- prompts={prompts}
263
- onComplete={handleGenerationComplete}
264
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
- {!isGenerating && (
267
- <div className="flex justify-start pt-4">
268
- <Button
269
- variant="outline"
270
- onClick={() => setStep(2)}
271
- className="px-6"
272
- >
273
- <ArrowLeft className="h-4 w-4 mr-2" />
274
- Edit Templates
275
- </Button>
276
- </div>
277
- )}
278
- </motion.div>
279
- )}
280
- </AnimatePresence>
281
- {/* Footer */}
282
  <footer className="border-t border-slate-100 mt-16">
283
  <div className="w-full px-4 sm:px-5 md:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6">
284
  <p className="text-center text-sm text-slate-400">
@@ -287,22 +306,11 @@ export default function EmailSequenceGenerator() {
287
  </div>
288
  </footer>
289
 
290
- {/* Custom Scrollbar Styles */}
291
  <style>{`
292
- .custom-scrollbar::-webkit-scrollbar {
293
- width: 6px;
294
- }
295
- .custom-scrollbar::-webkit-scrollbar-track {
296
- background: #f1f5f9;
297
- border-radius: 3px;
298
- }
299
- .custom-scrollbar::-webkit-scrollbar-thumb {
300
- background: #cbd5e1;
301
- border-radius: 3px;
302
- }
303
- .custom-scrollbar::-webkit-scrollbar-thumb:hover {
304
- background: #94a3b8;
305
- }
306
  `}</style>
307
  </AppShell>
308
  );
 
1
+ import React, { useRef, useMemo } from 'react';
2
  import { Sparkles, ArrowRight, ArrowLeft } from 'lucide-react';
3
+ import { Button } from '@/components/ui/button';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
 
6
  import UploadStep from '@/components/upload/UploadStep';
 
9
  import SequenceViewer from '@/components/sequences/SequenceViewer';
10
  import AppShell from '@/components/layout/AppShell';
11
  import { apiFetch } from '@/lib/api';
12
+ import { useGeneratorWorkflow } from '@/context/GeneratorWorkflowContext';
13
 
14
  export default function EmailSequenceGenerator() {
15
+ const {
16
+ step,
17
+ setStep,
18
+ uploadedFile,
19
+ setUploadedFile,
20
+ selectedProducts,
21
+ setSelectedProducts,
22
+ prompts,
23
+ setPrompts,
24
+ linkedinPrompts,
25
+ setLinkedinPrompts,
26
+ includeLinkedin,
27
+ setIncludeLinkedin,
28
+ isGenerating,
29
+ generationComplete,
30
+ beginGeneration,
31
+ resetWorkflow,
32
+ } = useGeneratorWorkflow();
33
+
34
  const generateButtonRef = useRef(null);
35
 
36
  const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
37
+ const canProceedToStep3 = useMemo(() => {
38
+ const emailOk = selectedProducts.length > 0 && selectedProducts.every((p) => (prompts[p.name] || '').trim());
39
+ if (!emailOk) return false;
40
+ if (!includeLinkedin) return true;
41
+ return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim());
42
+ }, [selectedProducts, prompts, linkedinPrompts, includeLinkedin]);
43
 
44
  const scrollToGenerateButton = () => {
45
  if (generateButtonRef.current) {
46
+ generateButtonRef.current.scrollIntoView({
47
+ behavior: 'smooth',
48
+ block: 'center',
49
  });
50
  }
51
  };
52
 
53
  const handleGenerate = async () => {
54
+ if (!uploadedFile?.fileId || !canProceedToStep3) {
55
  alert('Please complete all steps before generating sequences.');
56
  return;
57
  }
58
 
 
59
  try {
60
  const res = await apiFetch('/api/save-prompts', {
61
  method: 'POST',
62
  headers: { 'Content-Type': 'application/json' },
63
  body: JSON.stringify({
64
  file_id: uploadedFile.fileId,
65
+ prompts,
66
+ products: selectedProducts.map((p) => p.name),
67
+ linkedin_prompts: includeLinkedin ? linkedinPrompts : {},
68
+ }),
69
  });
70
  if (!res.ok) {
71
  const err = await res.json().catch(() => ({}));
 
77
  return;
78
  }
79
 
 
80
  setStep(3);
81
+ beginGeneration(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  };
83
 
84
  return (
 
89
  step > 1 ? (
90
  <Button
91
  variant="ghost"
92
+ onClick={resetWorkflow}
93
  className="text-slate-500 hover:text-slate-700"
94
  >
95
  Start Over
 
97
  ) : null
98
  }
99
  >
100
+ <div className="mb-10">
101
+ <div className="flex items-center justify-center gap-4">
102
+ {[
103
+ { num: 1, label: 'Upload & Select' },
104
+ { num: 2, label: 'Configure Prompts' },
105
+ { num: 3, label: 'Generate & Export' },
106
+ ].map((s, idx) => (
107
+ <React.Fragment key={s.num}>
108
+ <div className="flex items-center gap-2">
109
+ <div
110
+ className={`
111
  h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold
112
  transition-all duration-300
113
+ ${
114
+ step >= s.num
115
+ ? 'bg-violet-600 text-white shadow-lg shadow-violet-200'
116
+ : 'bg-slate-100 text-slate-400'
117
  }
118
+ `}
119
+ >
120
+ {s.num}
 
 
 
 
 
121
  </div>
122
+ <span
123
+ className={`text-sm font-medium hidden sm:block ${
124
+ step >= s.num ? 'text-slate-800' : 'text-slate-400'
125
+ }`}
126
+ >
127
+ {s.label}
128
+ </span>
129
+ </div>
130
+ {idx < 2 && (
131
+ <div
132
+ className={`h-0.5 w-12 rounded-full transition-colors duration-300 ${
133
  step > s.num ? 'bg-violet-600' : 'bg-slate-200'
134
+ }`}
135
+ />
136
+ )}
137
+ </React.Fragment>
138
+ ))}
139
  </div>
140
+ </div>
141
 
142
+ <AnimatePresence mode="wait">
143
+ {step === 1 && (
144
+ <motion.div
145
+ key="step1"
146
+ initial={{ opacity: 0, x: -20 }}
147
+ animate={{ opacity: 1, x: 0 }}
148
+ exit={{ opacity: 0, x: 20 }}
149
+ transition={{ duration: 0.3 }}
150
+ className="space-y-8"
151
+ >
152
+ <div className="text-center mb-8">
153
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">Upload Your Contacts</h2>
154
+ <p className="text-slate-500">
155
+ Import your Apollo CSV and select the products for your outreach campaign
156
+ </p>
157
+ </div>
 
 
 
158
 
159
+ <UploadStep
160
+ onFileUploaded={setUploadedFile}
161
+ uploadedFile={uploadedFile}
162
+ onRemoveFile={() => setUploadedFile(null)}
163
+ />
164
 
165
+ {uploadedFile && (
166
+ <motion.div
167
+ initial={{ opacity: 0, y: 20 }}
168
+ animate={{ opacity: 1, y: 0 }}
169
+ transition={{ delay: 0.2 }}
170
+ >
171
+ <ProductSelector
172
+ selectedProducts={selectedProducts}
173
+ onProductsChange={setSelectedProducts}
174
+ />
175
+ </motion.div>
176
+ )}
177
 
178
+ <div className="flex justify-end pt-4">
179
+ <Button
180
+ onClick={() => setStep(2)}
181
+ disabled={!canProceedToStep2}
182
+ className="bg-violet-600 hover:bg-violet-700 px-6"
183
+ >
184
+ Continue to Prompts
185
+ <ArrowRight className="h-4 w-4 ml-2" />
186
+ </Button>
187
+ </div>
188
+ </motion.div>
189
+ )}
190
 
191
+ {step === 2 && (
192
+ <motion.div
193
+ key="step2"
194
+ initial={{ opacity: 0, x: -20 }}
195
+ animate={{ opacity: 1, x: 0 }}
196
+ exit={{ opacity: 0, x: 20 }}
197
+ transition={{ duration: 0.3 }}
198
+ className="space-y-8"
199
+ >
200
+ <div className="text-center mb-8">
201
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
202
+ Customize Your Email Templates
203
+ </h2>
204
+ <p className="text-slate-500">
205
+ Edit the prompt templates for each product. The AI will personalize these for each
206
+ contact.
207
+ </p>
208
+ </div>
209
+
210
+ <PromptEditor
211
+ selectedProducts={selectedProducts}
212
+ prompts={prompts}
213
+ onPromptsChange={setPrompts}
214
+ onSaveComplete={scrollToGenerateButton}
215
+ variant="email"
216
+ />
217
 
218
+ <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
219
+ <input
220
+ type="checkbox"
221
+ className="mt-1 h-4 w-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
222
+ checked={includeLinkedin}
223
+ onChange={(e) => setIncludeLinkedin(e.target.checked)}
224
  />
225
+ <span>
226
+ <span className="font-medium text-slate-800">
227
+ Also generate LinkedIn sequences for the same contacts
228
+ </span>
229
+ <span className="mt-1 block text-sm text-slate-500">
230
+ Runs after email generation. Configure LinkedIn system prompts below — same edit /
231
+ save flow as email.
232
+ </span>
233
+ </span>
234
+ </label>
235
 
236
+ {includeLinkedin ? (
237
+ <div className="space-y-2">
238
+ <h3 className="text-lg font-semibold text-slate-800">LinkedIn prompts</h3>
239
+ <PromptEditor
240
+ selectedProducts={selectedProducts}
241
+ prompts={linkedinPrompts}
242
+ onPromptsChange={setLinkedinPrompts}
243
+ onSaveComplete={scrollToGenerateButton}
244
+ variant="linkedin"
245
+ />
 
 
 
 
 
 
 
 
 
246
  </div>
247
+ ) : null}
 
248
 
249
+ <div className="flex justify-between pt-4">
250
+ <Button variant="outline" onClick={() => setStep(1)} className="px-6">
251
+ <ArrowLeft className="h-4 w-4 mr-2" />
252
+ Back
253
+ </Button>
254
+ <Button
255
+ ref={generateButtonRef}
256
+ onClick={handleGenerate}
257
+ disabled={!canProceedToStep3}
258
+ className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700 hover:to-purple-700 px-8 shadow-lg shadow-violet-200"
259
+ >
260
+ <Sparkles className="h-4 w-4 mr-2" />
261
+ Generate Sequences
262
+ </Button>
263
+ </div>
264
+ </motion.div>
265
+ )}
 
 
 
 
266
 
267
+ {step === 3 && (
268
+ <motion.div
269
+ key="step3"
270
+ initial={{ opacity: 0, x: -20 }}
271
+ animate={{ opacity: 1, x: 0 }}
272
+ exit={{ opacity: 0, x: 20 }}
273
+ transition={{ duration: 0.3 }}
274
+ className="space-y-8"
275
+ >
276
+ <div className="text-center mb-8">
277
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
278
+ {generationComplete ? 'Your Sequences Are Ready!' : 'Generating…'}
279
+ </h2>
280
+ <p className="text-slate-500">
281
+ {generationComplete
282
+ ? 'Review your sequences below and download when ready'
283
+ : 'You can navigate to other pages — generation continues in the background.'}
284
+ </p>
285
+ </div>
286
+
287
+ <SequenceViewer />
288
+
289
+ {!isGenerating && (
290
+ <div className="flex justify-start pt-4">
291
+ <Button variant="outline" onClick={() => setStep(2)} className="px-6">
292
+ <ArrowLeft className="h-4 w-4 mr-2" />
293
+ Edit Templates
294
+ </Button>
295
+ </div>
296
+ )}
297
+ </motion.div>
298
+ )}
299
+ </AnimatePresence>
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  <footer className="border-t border-slate-100 mt-16">
302
  <div className="w-full px-4 sm:px-5 md:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6">
303
  <p className="text-center text-sm text-slate-400">
 
306
  </div>
307
  </footer>
308
 
 
309
  <style>{`
310
+ .custom-scrollbar::-webkit-scrollbar { width: 6px; }
311
+ .custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; border-radius: 3px; }
312
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
313
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
 
 
 
 
 
 
 
 
 
 
314
  `}</style>
315
  </AppShell>
316
  );