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