Seth commited on
Commit
5301ae9
·
1 Parent(s): b74f084
Dockerfile CHANGED
@@ -10,6 +10,9 @@ RUN npm run build
10
  FROM python:3.11-slim
11
  WORKDIR /app
12
 
 
 
 
13
  # Backend deps
14
  COPY backend/requirements.txt /app/backend/requirements.txt
15
  RUN pip install --no-cache-dir -r /app/backend/requirements.txt
 
10
  FROM python:3.11-slim
11
  WORKDIR /app
12
 
13
+ # Create data directory for persistent storage
14
+ RUN mkdir -p /data/uploads
15
+
16
  # Backend deps
17
  COPY backend/requirements.txt /app/backend/requirements.txt
18
  RUN pip install --no-cache-dir -r /app/backend/requirements.txt
README.md CHANGED
@@ -8,4 +8,43 @@ pinned: false
8
  license: mit
9
  ---
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
8
  license: mit
9
  ---
10
 
11
+ # EMAILOUT - AI-Powered Email Sequence Generator
12
+
13
+ A Hugging Face Space application for generating personalized email outreach sequences using GPT.
14
+
15
+ ## Features
16
+
17
+ - 📤 Upload CSV files from Apollo with contact information
18
+ - 🎯 Select and customize products for outreach campaigns
19
+ - ✏️ Edit email prompt templates for each product
20
+ - 🤖 AI-powered sequence generation using GPT
21
+ - 📊 Real-time streaming of generated sequences
22
+ - 💾 Download sequences as CSV for use in Klenty and other email tools
23
+
24
+ ## Setup
25
+
26
+ 1. Set your OpenAI API key as a secret in Hugging Face Spaces:
27
+ - Go to Settings → Secrets
28
+ - Add `OPENAI_API_KEY` with your API key value
29
+
30
+ 2. The app will automatically:
31
+ - Create SQLite database in `/data/emailout.db`
32
+ - Store uploaded CSV files in `/data/uploads/`
33
+ - Generate and store email sequences
34
+
35
+ ## Usage
36
+
37
+ 1. **Upload CSV**: Upload your Apollo CSV file with contacts
38
+ 2. **Select Products**: Choose which products to focus on for outreach
39
+ 3. **Configure Prompts**: Customize email templates for each product
40
+ 4. **Generate**: Let AI create personalized sequences for each contact
41
+ 5. **Download**: Export sequences as CSV for your email tools
42
+
43
+ ## Tech Stack
44
+
45
+ - **Frontend**: React + Vite + Tailwind CSS + Framer Motion
46
+ - **Backend**: FastAPI + Python
47
+ - **AI**: OpenAI GPT API
48
+ - **Database**: SQLite (Hugging Face Spaces persistent storage)
49
+
50
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
SETUP.md ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EMAILOUT Setup Guide
2
+
3
+ ## Prerequisites
4
+
5
+ 1. OpenAI API Key - Get one from https://platform.openai.com/api-keys
6
+
7
+ ## Hugging Face Spaces Setup
8
+
9
+ 1. **Create a new Space**:
10
+ - Go to https://huggingface.co/spaces
11
+ - Click "Create new Space"
12
+ - Select "Docker" as the SDK
13
+ - Name it "EMAILOUT" (or your preferred name)
14
+
15
+ 2. **Add OpenAI API Key as Secret**:
16
+ - Go to your Space Settings
17
+ - Navigate to "Secrets" section
18
+ - Add a new secret:
19
+ - Key: `OPENAI_API_KEY`
20
+ - Value: Your OpenAI API key
21
+
22
+ 3. **Upload Files**:
23
+ - Upload all project files to your Space
24
+ - The Dockerfile will handle the build process
25
+
26
+ 4. **Deploy**:
27
+ - The Space will automatically build and deploy
28
+ - Monitor the logs for any build errors
29
+
30
+ ## Local Development
31
+
32
+ 1. **Install Dependencies**:
33
+ ```bash
34
+ # Backend
35
+ cd backend
36
+ pip install -r requirements.txt
37
+
38
+ # Frontend
39
+ cd ../frontend
40
+ npm install
41
+ ```
42
+
43
+ 2. **Set Environment Variables**:
44
+ ```bash
45
+ export OPENAI_API_KEY=your_api_key_here
46
+ ```
47
+
48
+ 3. **Run Backend**:
49
+ ```bash
50
+ cd backend
51
+ uvicorn app.main:app --reload --port 8000
52
+ ```
53
+
54
+ 4. **Run Frontend**:
55
+ ```bash
56
+ cd frontend
57
+ npm run dev
58
+ ```
59
+
60
+ ## Project Structure
61
+
62
+ ```
63
+ EMAILOUT/
64
+ ├── backend/
65
+ │ ├── app/
66
+ │ │ ├── __init__.py
67
+ │ │ ├── main.py # FastAPI application
68
+ │ │ ├── database.py # SQLite database models
69
+ │ │ ├── models.py # Pydantic models
70
+ │ │ └── gpt_service.py # OpenAI GPT integration
71
+ │ └── requirements.txt
72
+ ├── frontend/
73
+ │ ├── src/
74
+ │ │ ├── pages/
75
+ │ │ │ └── EmailSequenceGenerator.jsx
76
+ │ │ ├── components/
77
+ │ │ │ ├── upload/
78
+ │ │ │ ├── products/
79
+ │ │ │ ├── prompts/
80
+ │ │ │ ├── sequences/
81
+ │ │ │ └── ui/
82
+ │ │ └── ...
83
+ │ └── package.json
84
+ ├── Dockerfile
85
+ └── README.md
86
+ ```
87
+
88
+ ## Features
89
+
90
+ - ✅ CSV Upload from Apollo
91
+ - ✅ Product Selection with Custom Products
92
+ - ✅ Prompt Template Editor
93
+ - ✅ GPT-Powered Sequence Generation
94
+ - ✅ Real-time Streaming Results
95
+ - ✅ CSV Export for Klenty/Outreach
96
+
97
+ ## API Endpoints
98
+
99
+ - `POST /api/upload-csv` - Upload CSV file
100
+ - `POST /api/save-prompts` - Save prompt templates
101
+ - `GET /api/generate-sequences` - Generate sequences (SSE stream)
102
+ - `GET /api/download-sequences` - Download sequences as CSV
103
+
104
+ ## Database
105
+
106
+ SQLite database is stored at `/data/emailout.db` (persistent in HF Spaces).
107
+
108
+ Uploaded files are stored at `/data/uploads/`.
backend/app/database.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, JSON
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ from datetime import datetime
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # SQLite database path (Hugging Face Spaces provides persistent storage)
9
+ DATA_DIR = Path("/data")
10
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
11
+ DB_PATH = os.getenv("HF_DATABASE_PATH", str(DATA_DIR / "emailout.db"))
12
+
13
+ engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
14
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
15
+ Base = declarative_base()
16
+
17
+
18
+ class UploadedFile(Base):
19
+ __tablename__ = "uploaded_files"
20
+
21
+ id = Column(Integer, primary_key=True, index=True)
22
+ file_id = Column(String, unique=True, index=True)
23
+ filename = Column(String)
24
+ contact_count = Column(Integer)
25
+ file_path = Column(String)
26
+ created_at = Column(DateTime, default=datetime.utcnow)
27
+
28
+
29
+ class Prompt(Base):
30
+ __tablename__ = "prompts"
31
+
32
+ id = Column(Integer, primary_key=True, index=True)
33
+ file_id = Column(String, index=True)
34
+ product_name = Column(String)
35
+ prompt_template = Column(Text)
36
+ created_at = Column(DateTime, default=datetime.utcnow)
37
+
38
+
39
+ class GeneratedSequence(Base):
40
+ __tablename__ = "generated_sequences"
41
+
42
+ id = Column(Integer, primary_key=True, index=True)
43
+ file_id = Column(String, index=True)
44
+ sequence_id = Column(Integer)
45
+ first_name = Column(String)
46
+ last_name = Column(String)
47
+ email = Column(String)
48
+ company = Column(String)
49
+ title = Column(String)
50
+ product = Column(String)
51
+ subject = Column(String)
52
+ email_content = Column(Text)
53
+ created_at = Column(DateTime, default=datetime.utcnow)
54
+
55
+
56
+ # Create tables
57
+ Base.metadata.create_all(bind=engine)
58
+
59
+
60
+ def get_db():
61
+ db = SessionLocal()
62
+ try:
63
+ yield db
64
+ finally:
65
+ db.close()
backend/app/gpt_service.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from openai import OpenAI
3
+ from typing import Dict, List
4
+ import re
5
+
6
+ # Initialize OpenAI client
7
+ client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
8
+
9
+
10
+ def generate_email_sequence(contact: Dict, prompt_template: str, product_name: str) -> Dict:
11
+ """
12
+ Generate a personalized email sequence for a contact using GPT.
13
+ """
14
+ try:
15
+ # Extract variables from contact
16
+ first_name = contact.get("first_name", contact.get("First Name", ""))
17
+ last_name = contact.get("last_name", contact.get("Last Name", ""))
18
+ company = contact.get("company", contact.get("Company", contact.get("Organization", "")))
19
+ email = contact.get("email", contact.get("Email", ""))
20
+ title = contact.get("title", contact.get("Title", contact.get("Job Title", "")))
21
+
22
+ # Prepare the prompt for GPT
23
+ system_prompt = """You are an expert email outreach specialist. Your task is to personalize email templates for B2B sales outreach.
24
+ Replace template variables with actual contact information and make the email sound natural and personalized.
25
+ Return the email in the same format as the template, with subject line and body separated."""
26
+
27
+ user_prompt = f"""Given this contact information:
28
+ - Name: {first_name} {last_name}
29
+ - Company: {company}
30
+ - Title: {title}
31
+ - Email: {email}
32
+ - Product Focus: {product_name}
33
+
34
+ Personalize this email template:
35
+ {prompt_template}
36
+
37
+ Replace all variables like {{first_name}}, {{company}}, {{sender_name}} with the actual information.
38
+ Make it sound natural and personalized. Keep the same structure and format.
39
+ If sender_name is not provided, use "Alex Thompson" as the sender name."""
40
+
41
+ response = client.chat.completions.create(
42
+ model="gpt-4o-mini", # Using gpt-4o-mini for cost efficiency, can be changed to gpt-4
43
+ messages=[
44
+ {"role": "system", "content": system_prompt},
45
+ {"role": "user", "content": user_prompt}
46
+ ],
47
+ temperature=0.7,
48
+ max_tokens=1000
49
+ )
50
+
51
+ generated_text = response.choices[0].message.content
52
+
53
+ # Parse the generated email to extract subject and body
54
+ lines = generated_text.split('\n')
55
+ subject = ""
56
+ email_content = ""
57
+
58
+ # Extract subject line
59
+ for i, line in enumerate(lines):
60
+ if line.strip().startswith("Subject:"):
61
+ subject = line.replace("Subject:", "").strip()
62
+ email_content = '\n'.join(lines[i+1:]).strip()
63
+ break
64
+
65
+ # If no subject found, use first line or generate one
66
+ if not subject:
67
+ if lines:
68
+ subject = lines[0].replace("Subject:", "").strip()
69
+ email_content = '\n'.join(lines[1:]).strip()
70
+ else:
71
+ subject = f"{first_name}, let's talk about {product_name}"
72
+ email_content = generated_text
73
+
74
+ # Replace any remaining template variables
75
+ email_content = email_content.replace("{{first_name}}", first_name)
76
+ email_content = email_content.replace("{{company}}", company)
77
+ email_content = email_content.replace("{{sender_name}}", "Alex Thompson")
78
+
79
+ subject = subject.replace("{{first_name}}", first_name)
80
+ subject = subject.replace("{{company}}", company)
81
+ subject = subject.replace("{{sender_name}}", "Alex Thompson")
82
+
83
+ return {
84
+ "first_name": first_name,
85
+ "last_name": last_name,
86
+ "email": email,
87
+ "company": company,
88
+ "title": title,
89
+ "product": product_name,
90
+ "subject": subject,
91
+ "email_content": email_content
92
+ }
93
+
94
+ except Exception as e:
95
+ print(f"Error generating sequence: {e}")
96
+ # Return a fallback email
97
+ first_name = contact.get("first_name", contact.get("First Name", "there"))
98
+ company = contact.get("company", contact.get("Company", contact.get("Organization", "your company")))
99
+
100
+ return {
101
+ "first_name": first_name,
102
+ "last_name": contact.get("last_name", contact.get("Last Name", "")),
103
+ "email": contact.get("email", contact.get("Email", "")),
104
+ "company": company,
105
+ "title": contact.get("title", contact.get("Title", contact.get("Job Title", ""))),
106
+ "product": product_name,
107
+ "subject": f"{first_name}, let's talk about {product_name}",
108
+ "email_content": f"Hi {first_name},\n\nI wanted to reach out about how {product_name} could benefit {company}.\n\nBest,\nAlex Thompson"
109
+ }
backend/app/main.py CHANGED
@@ -1,8 +1,22 @@
1
- from fastapi import FastAPI
2
- from fastapi.responses import FileResponse
3
  from fastapi.staticfiles import StaticFiles
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  app = FastAPI()
8
 
@@ -14,6 +28,10 @@ app.add_middleware(
14
  allow_headers=["*"],
15
  )
16
 
 
 
 
 
17
  # ---- API ----
18
  @app.get("/api/health")
19
  def health():
@@ -23,6 +41,220 @@ def health():
23
  def hello():
24
  return {"message": "Hello from FastAPI"}
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  # ---- Frontend static serving ----
27
  FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
28
  INDEX_FILE = FRONTEND_DIST / "index.html"
 
1
+ from fastapi import FastAPI, UploadFile, File, Depends, HTTPException, Query
2
+ from fastapi.responses import FileResponse, StreamingResponse
3
  from fastapi.staticfiles import StaticFiles
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from pathlib import Path
6
+ from sqlalchemy.orm import Session
7
+ import pandas as pd
8
+ import uuid
9
+ import os
10
+ import csv
11
+ import io
12
+ import concurrent.futures
13
+ from typing import Dict, List
14
+ import json
15
+ import asyncio
16
+
17
+ from app.database import get_db, UploadedFile, Prompt, GeneratedSequence
18
+ from app.models import UploadResponse, PromptSaveRequest, SequenceResponse
19
+ from app.gpt_service import generate_email_sequence
20
 
21
  app = FastAPI()
22
 
 
28
  allow_headers=["*"],
29
  )
30
 
31
+ # Create uploads directory
32
+ UPLOAD_DIR = Path("/data/uploads")
33
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
34
+
35
  # ---- API ----
36
  @app.get("/api/health")
37
  def health():
 
41
  def hello():
42
  return {"message": "Hello from FastAPI"}
43
 
44
+
45
+ @app.post("/api/upload-csv")
46
+ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)):
47
+ """Upload and parse CSV file from Apollo"""
48
+ try:
49
+ # Generate unique file ID
50
+ file_id = str(uuid.uuid4())
51
+ file_path = UPLOAD_DIR / f"{file_id}.csv"
52
+
53
+ # Save file
54
+ content = await file.read()
55
+ with open(file_path, "wb") as f:
56
+ f.write(content)
57
+
58
+ # Parse CSV to count contacts
59
+ df = pd.read_csv(file_path)
60
+ contact_count = len(df)
61
+
62
+ # Save to database
63
+ db_file = UploadedFile(
64
+ file_id=file_id,
65
+ filename=file.filename,
66
+ contact_count=contact_count,
67
+ file_path=str(file_path)
68
+ )
69
+ db.add(db_file)
70
+ db.commit()
71
+
72
+ return {
73
+ "file_id": file_id,
74
+ "contact_count": contact_count,
75
+ "message": "File uploaded successfully"
76
+ }
77
+ except Exception as e:
78
+ raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")
79
+
80
+
81
+ @app.post("/api/save-prompts")
82
+ async def save_prompts(request: PromptSaveRequest, db: Session = Depends(get_db)):
83
+ """Save prompt templates for products"""
84
+ try:
85
+ # Delete existing prompts for this file
86
+ db.query(Prompt).filter(Prompt.file_id == request.file_id).delete()
87
+
88
+ # Save new prompts
89
+ for product_name, prompt_template in request.prompts.items():
90
+ prompt = Prompt(
91
+ file_id=request.file_id,
92
+ product_name=product_name,
93
+ prompt_template=prompt_template
94
+ )
95
+ db.add(prompt)
96
+
97
+ db.commit()
98
+ return {"message": "Prompts saved successfully"}
99
+ except Exception as e:
100
+ raise HTTPException(status_code=500, detail=f"Error saving prompts: {str(e)}")
101
+
102
+
103
+ @app.get("/api/generate-sequences")
104
+ async def generate_sequences(
105
+ file_id: str = Query(...),
106
+ db: Session = Depends(get_db)
107
+ ):
108
+ """Generate email sequences using GPT with Server-Sent Events streaming"""
109
+
110
+ async def event_generator():
111
+ try:
112
+ # Get uploaded file
113
+ db_file = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
114
+ if not db_file:
115
+ yield f"data: {json.dumps({'type': 'error', 'error': 'File not found'})}\n\n"
116
+ return
117
+
118
+ # Read CSV
119
+ df = pd.read_csv(db_file.file_path)
120
+
121
+ # Get prompts for this file
122
+ prompts = db.query(Prompt).filter(Prompt.file_id == file_id).all()
123
+ prompt_dict = {p.product_name: p.prompt_template for p in prompts}
124
+
125
+ if not prompt_dict:
126
+ yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
127
+ return
128
+
129
+ # Get products from prompts
130
+ products = list(prompt_dict.keys())
131
+
132
+ # Clear existing sequences
133
+ db.query(GeneratedSequence).filter(GeneratedSequence.file_id == file_id).delete()
134
+ db.commit()
135
+
136
+ sequence_id = 1
137
+ total_contacts = len(df)
138
+
139
+ # Process each contact
140
+ for idx, row in df.iterrows():
141
+ # Convert row to dict
142
+ contact = row.to_dict()
143
+
144
+ # Select product (round-robin or random)
145
+ product_name = products[sequence_id % len(products)]
146
+ prompt_template = prompt_dict[product_name]
147
+
148
+ # Generate sequence using GPT (run in executor to avoid blocking)
149
+ loop = asyncio.get_event_loop()
150
+ with concurrent.futures.ThreadPoolExecutor() as executor:
151
+ sequence_data = await loop.run_in_executor(
152
+ executor,
153
+ generate_email_sequence,
154
+ contact,
155
+ prompt_template,
156
+ product_name
157
+ )
158
+
159
+ # Save to database
160
+ db_sequence = GeneratedSequence(
161
+ file_id=file_id,
162
+ sequence_id=sequence_id,
163
+ first_name=sequence_data["first_name"],
164
+ last_name=sequence_data["last_name"],
165
+ email=sequence_data["email"],
166
+ company=sequence_data["company"],
167
+ title=sequence_data.get("title", ""),
168
+ product=sequence_data["product"],
169
+ subject=sequence_data["subject"],
170
+ email_content=sequence_data["email_content"]
171
+ )
172
+ db.add(db_sequence)
173
+ db.commit()
174
+
175
+ # Stream the sequence
176
+ sequence_response = {
177
+ "id": sequence_id,
178
+ "firstName": sequence_data["first_name"],
179
+ "lastName": sequence_data["last_name"],
180
+ "email": sequence_data["email"],
181
+ "company": sequence_data["company"],
182
+ "title": sequence_data.get("title", ""),
183
+ "product": sequence_data["product"],
184
+ "subject": sequence_data["subject"],
185
+ "emailContent": sequence_data["email_content"]
186
+ }
187
+
188
+ yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
189
+
190
+ # Send progress update
191
+ progress = (sequence_id / total_contacts) * 100
192
+ yield f"data: {json.dumps({'type': 'progress', 'progress': progress})}\n\n"
193
+
194
+ sequence_id += 1
195
+
196
+ # Small delay to prevent overwhelming the API
197
+ await asyncio.sleep(0.1)
198
+
199
+ # Send completion
200
+ yield f"data: {json.dumps({'type': 'complete'})}\n\n"
201
+
202
+ except Exception as e:
203
+ yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
204
+
205
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
206
+
207
+
208
+ @app.get("/api/download-sequences")
209
+ async def download_sequences(file_id: str = Query(...), db: Session = Depends(get_db)):
210
+ """Download generated sequences as CSV"""
211
+ try:
212
+ # Get all sequences for this file
213
+ sequences = db.query(GeneratedSequence).filter(
214
+ GeneratedSequence.file_id == file_id
215
+ ).order_by(GeneratedSequence.sequence_id).all()
216
+
217
+ if not sequences:
218
+ raise HTTPException(status_code=404, detail="No sequences found")
219
+
220
+ # Create CSV in memory
221
+ output = io.StringIO()
222
+ writer = csv.writer(output)
223
+
224
+ # Write header
225
+ writer.writerow([
226
+ 'First Name', 'Last Name', 'Email', 'Company', 'Title',
227
+ 'Product', 'Subject', 'Email Content'
228
+ ])
229
+
230
+ # Write rows
231
+ for seq in sequences:
232
+ writer.writerow([
233
+ seq.first_name,
234
+ seq.last_name,
235
+ seq.email,
236
+ seq.company,
237
+ seq.title or '',
238
+ seq.product,
239
+ seq.subject,
240
+ seq.email_content.replace('\n', ' ').replace('\r', '')
241
+ ])
242
+
243
+ output.seek(0)
244
+
245
+ # Return as downloadable file
246
+ return StreamingResponse(
247
+ iter([output.getvalue()]),
248
+ media_type="text/csv",
249
+ headers={
250
+ "Content-Disposition": f"attachment; filename=email_sequences_{file_id}.csv"
251
+ }
252
+ )
253
+
254
+ except Exception as e:
255
+ raise HTTPException(status_code=500, detail=f"Error downloading sequences: {str(e)}")
256
+
257
+
258
  # ---- Frontend static serving ----
259
  FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
260
  INDEX_FILE = FRONTEND_DIST / "index.html"
backend/app/models.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Dict
3
+ from datetime import datetime
4
+
5
+
6
+ class UploadResponse(BaseModel):
7
+ file_id: str
8
+ contact_count: int
9
+ message: str
10
+
11
+
12
+ class PromptSaveRequest(BaseModel):
13
+ file_id: str
14
+ prompts: Dict[str, str]
15
+ products: List[str]
16
+
17
+
18
+ class SequenceResponse(BaseModel):
19
+ id: int
20
+ firstName: str
21
+ lastName: str
22
+ email: str
23
+ company: str
24
+ title: Optional[str] = None
25
+ product: str
26
+ subject: str
27
+ emailContent: str
backend/requirements.txt CHANGED
@@ -1,2 +1,7 @@
1
  fastapi
2
  uvicorn
 
 
 
 
 
 
1
  fastapi
2
  uvicorn
3
+ python-multipart
4
+ openai
5
+ pandas
6
+ aiofiles
7
+ sqlalchemy
frontend/package.json CHANGED
@@ -10,10 +10,18 @@
10
  },
11
  "dependencies": {
12
  "react": "^18.3.1",
13
- "react-dom": "^18.3.1"
 
 
 
 
 
14
  },
15
  "devDependencies": {
16
  "@vitejs/plugin-react": "^4.3.1",
17
- "vite": "^5.4.2"
 
 
 
18
  }
19
  }
 
10
  },
11
  "dependencies": {
12
  "react": "^18.3.1",
13
+ "react-dom": "^18.3.1",
14
+ "framer-motion": "^11.0.0",
15
+ "lucide-react": "^0.344.0",
16
+ "class-variance-authority": "^0.7.0",
17
+ "clsx": "^2.1.0",
18
+ "tailwind-merge": "^2.2.0"
19
  },
20
  "devDependencies": {
21
  "@vitejs/plugin-react": "^4.3.1",
22
+ "vite": "^5.4.2",
23
+ "tailwindcss": "^3.4.1",
24
+ "postcss": "^8.4.35",
25
+ "autoprefixer": "^10.4.17"
26
  }
27
  }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/src/App.jsx CHANGED
@@ -1,23 +1,7 @@
1
- import React, { useEffect, useState } from "react";
 
 
2
 
3
  export default function App() {
4
- const [apiMsg, setApiMsg] = useState("");
5
-
6
- useEffect(() => {
7
- fetch("/api/hello")
8
- .then((r) => r.json())
9
- .then((d) => setApiMsg(d.message))
10
- .catch(() => setApiMsg("API not reachable yet"));
11
- }, []);
12
-
13
- return (
14
- <div style={{ fontFamily: "system-ui", padding: 24, lineHeight: 1.5 }}>
15
- <h1>EMAILOUT - React + FastAPI (Docker, HF Spaces)</h1>
16
- <p>This is a plain starter page. Customize freely.</p>
17
-
18
- <div style={{ marginTop: 16, padding: 12, border: "1px solid #ddd", borderRadius: 8 }}>
19
- <strong>API says:</strong> {apiMsg}
20
- </div>
21
- </div>
22
- );
23
  }
 
1
+ import React from "react";
2
+ import EmailSequenceGenerator from "./pages/EmailSequenceGenerator";
3
+ import "./index.css";
4
 
5
  export default function App() {
6
+ return <EmailSequenceGenerator />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
frontend/src/components/UserNotRegisteredError.jsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const UserNotRegisteredError = () => {
4
+ return (
5
+ <div className="flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-white to-slate-50">
6
+ <div className="max-w-md w-full p-8 bg-white rounded-lg shadow-lg border border-slate-100">
7
+ <div className="text-center">
8
+ <div className="inline-flex items-center justify-center w-16 h-16 mb-6 rounded-full bg-orange-100">
9
+ <svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
10
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
11
+ </svg>
12
+ </div>
13
+ <h1 className="text-3xl font-bold text-slate-900 mb-4">Access Restricted</h1>
14
+ <p className="text-slate-600 mb-8">
15
+ You are not registered to use this application. Please contact the app administrator to request access.
16
+ </p>
17
+ <div className="p-4 bg-slate-50 rounded-md text-sm text-slate-600">
18
+ <p>If you believe this is an error, you can:</p>
19
+ <ul className="list-disc list-inside mt-2 space-y-1">
20
+ <li>Verify you are logged in with the correct account</li>
21
+ <li>Contact the app administrator for access</li>
22
+ <li>Try logging out and back in again</li>
23
+ </ul>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export default UserNotRegisteredError;
frontend/src/components/products/ProductSelector.jsx ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Check, Plus, X, Package } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import { motion, AnimatePresence } from 'framer-motion';
7
+
8
+ const DEFAULT_PRODUCTS = [
9
+ { id: 1, name: 'Accounts Payable Automation', color: 'violet' },
10
+ { id: 2, name: 'Sales Order Processing', color: 'blue' },
11
+ { id: 3, name: 'Document Management', color: 'emerald' },
12
+ { id: 4, name: 'Invoice Processing', color: 'amber' },
13
+ { id: 5, name: 'Expense Management', color: 'rose' },
14
+ { id: 6, name: 'Procurement Automation', color: 'cyan' },
15
+ ];
16
+
17
+ const colorClasses = {
18
+ violet: 'bg-violet-100 text-violet-700 border-violet-200 hover:bg-violet-200',
19
+ blue: 'bg-blue-100 text-blue-700 border-blue-200 hover:bg-blue-200',
20
+ emerald: 'bg-emerald-100 text-emerald-700 border-emerald-200 hover:bg-emerald-200',
21
+ amber: 'bg-amber-100 text-amber-700 border-amber-200 hover:bg-amber-200',
22
+ rose: 'bg-rose-100 text-rose-700 border-rose-200 hover:bg-rose-200',
23
+ cyan: 'bg-cyan-100 text-cyan-700 border-cyan-200 hover:bg-cyan-200',
24
+ slate: 'bg-slate-100 text-slate-700 border-slate-200 hover:bg-slate-200',
25
+ };
26
+
27
+ const selectedColorClasses = {
28
+ violet: 'bg-violet-600 text-white border-violet-600',
29
+ blue: 'bg-blue-600 text-white border-blue-600',
30
+ emerald: 'bg-emerald-600 text-white border-emerald-600',
31
+ amber: 'bg-amber-600 text-white border-amber-600',
32
+ rose: 'bg-rose-600 text-white border-rose-600',
33
+ cyan: 'bg-cyan-600 text-white border-cyan-600',
34
+ slate: 'bg-slate-600 text-white border-slate-600',
35
+ };
36
+
37
+ export default function ProductSelector({ selectedProducts, onProductsChange }) {
38
+ const [products, setProducts] = useState(DEFAULT_PRODUCTS);
39
+ const [showAddNew, setShowAddNew] = useState(false);
40
+ const [newProductName, setNewProductName] = useState('');
41
+
42
+ const toggleProduct = (product) => {
43
+ if (selectedProducts.find(p => p.id === product.id)) {
44
+ onProductsChange(selectedProducts.filter(p => p.id !== product.id));
45
+ } else {
46
+ onProductsChange([...selectedProducts, product]);
47
+ }
48
+ };
49
+
50
+ const addNewProduct = () => {
51
+ if (newProductName.trim()) {
52
+ const colors = Object.keys(colorClasses);
53
+ const newProduct = {
54
+ id: Date.now(),
55
+ name: newProductName.trim(),
56
+ color: colors[Math.floor(Math.random() * colors.length)]
57
+ };
58
+ setProducts([...products, newProduct]);
59
+ setNewProductName('');
60
+ setShowAddNew(false);
61
+ }
62
+ };
63
+
64
+ const removeProduct = (productId) => {
65
+ setProducts(products.filter(p => p.id !== productId));
66
+ onProductsChange(selectedProducts.filter(p => p.id !== productId));
67
+ };
68
+
69
+ return (
70
+ <div className="w-full">
71
+ <div className="flex items-center justify-between mb-4">
72
+ <div className="flex items-center gap-2">
73
+ <Package className="h-5 w-5 text-slate-400" />
74
+ <span className="text-sm font-medium text-slate-600">
75
+ Select products for outreach
76
+ </span>
77
+ </div>
78
+ {selectedProducts.length > 0 && (
79
+ <Badge variant="secondary" className="bg-violet-100 text-violet-700">
80
+ {selectedProducts.length} selected
81
+ </Badge>
82
+ )}
83
+ </div>
84
+
85
+ <div className="flex flex-wrap gap-2 mb-4">
86
+ <AnimatePresence>
87
+ {products.map((product) => {
88
+ const isSelected = selectedProducts.find(p => p.id === product.id);
89
+ return (
90
+ <motion.button
91
+ key={product.id}
92
+ initial={{ opacity: 0, scale: 0.8 }}
93
+ animate={{ opacity: 1, scale: 1 }}
94
+ exit={{ opacity: 0, scale: 0.8 }}
95
+ whileHover={{ scale: 1.02 }}
96
+ whileTap={{ scale: 0.98 }}
97
+ onClick={() => toggleProduct(product)}
98
+ className={`
99
+ group relative flex items-center gap-2 px-4 py-2.5 rounded-xl
100
+ border transition-all duration-200 font-medium text-sm
101
+ ${isSelected
102
+ ? selectedColorClasses[product.color]
103
+ : colorClasses[product.color]
104
+ }
105
+ `}
106
+ >
107
+ {isSelected && (
108
+ <Check className="h-4 w-4" />
109
+ )}
110
+ <span>{product.name}</span>
111
+ {!DEFAULT_PRODUCTS.find(p => p.id === product.id) && (
112
+ <button
113
+ onClick={(e) => {
114
+ e.stopPropagation();
115
+ removeProduct(product.id);
116
+ }}
117
+ className={`
118
+ ml-1 rounded-full p-0.5 transition-colors
119
+ ${isSelected
120
+ ? 'hover:bg-white/20'
121
+ : 'hover:bg-black/10'
122
+ }
123
+ `}
124
+ >
125
+ <X className="h-3 w-3" />
126
+ </button>
127
+ )}
128
+ </motion.button>
129
+ );
130
+ })}
131
+ </AnimatePresence>
132
+
133
+ <AnimatePresence>
134
+ {showAddNew ? (
135
+ <motion.div
136
+ initial={{ opacity: 0, width: 0 }}
137
+ animate={{ opacity: 1, width: 'auto' }}
138
+ exit={{ opacity: 0, width: 0 }}
139
+ className="flex items-center gap-2"
140
+ >
141
+ <Input
142
+ value={newProductName}
143
+ onChange={(e) => setNewProductName(e.target.value)}
144
+ placeholder="Product name..."
145
+ className="h-10 w-48 text-sm"
146
+ onKeyDown={(e) => e.key === 'Enter' && addNewProduct()}
147
+ autoFocus
148
+ />
149
+ <Button
150
+ size="sm"
151
+ onClick={addNewProduct}
152
+ className="bg-violet-600 hover:bg-violet-700"
153
+ >
154
+ Add
155
+ </Button>
156
+ <Button
157
+ size="sm"
158
+ variant="ghost"
159
+ onClick={() => {
160
+ setShowAddNew(false);
161
+ setNewProductName('');
162
+ }}
163
+ >
164
+ Cancel
165
+ </Button>
166
+ </motion.div>
167
+ ) : (
168
+ <motion.button
169
+ initial={{ opacity: 0 }}
170
+ animate={{ opacity: 1 }}
171
+ onClick={() => setShowAddNew(true)}
172
+ className="flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 border-dashed
173
+ border-slate-200 text-slate-400 hover:border-violet-300 hover:text-violet-500
174
+ transition-colors duration-200 text-sm font-medium"
175
+ >
176
+ <Plus className="h-4 w-4" />
177
+ <span>Add Product</span>
178
+ </motion.button>
179
+ )}
180
+ </AnimatePresence>
181
+ </div>
182
+ </div>
183
+ );
184
+ }
frontend/src/components/prompts/PromptEditor.jsx ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { FileText, Save, RotateCcw, Sparkles, CheckCircle2 } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { Textarea } from "@/components/ui/textarea";
5
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
6
+ import { motion, AnimatePresence } from 'framer-motion';
7
+
8
+ const DEFAULT_TEMPLATES = {
9
+ 'Accounts Payable Automation': `Subject: Transform Your AP Process, {{first_name}}
10
+
11
+ Hi {{first_name}},
12
+
13
+ I noticed {{company}} is scaling fast—congrats! With growth often comes invoice chaos.
14
+
15
+ Our AP Automation solution helps companies like yours:
16
+ • Cut invoice processing time by 80%
17
+ • Eliminate manual data entry errors
18
+ • Get real-time visibility into cash flow
19
+
20
+ Would you be open to a quick 15-minute call to see if this could work for {{company}}?
21
+
22
+ Best,
23
+ {{sender_name}}`,
24
+
25
+ 'Sales Order Processing': `Subject: Faster Order-to-Cash for {{company}}
26
+
27
+ Hi {{first_name}},
28
+
29
+ Managing sales orders manually? I've seen how that slows down teams at growing companies like {{company}}.
30
+
31
+ Our Sales Order Processing solution automates:
32
+ • Order capture from any channel
33
+ • Inventory checks and allocation
34
+ • Fulfillment workflows
35
+
36
+ Companies see 60% faster order processing on average.
37
+
38
+ Worth a quick chat?
39
+
40
+ Best,
41
+ {{sender_name}}`,
42
+
43
+ 'Document Management': `Subject: {{first_name}}, stop losing documents
44
+
45
+ Hi {{first_name}},
46
+
47
+ Quick question: How much time does your team at {{company}} spend searching for documents?
48
+
49
+ Our Document Management system gives you:
50
+ • Instant search across all files
51
+ • Automated organization and tagging
52
+ • Secure access controls
53
+
54
+ Most teams save 5+ hours per week.
55
+
56
+ Interested in seeing a quick demo?
57
+
58
+ Best,
59
+ {{sender_name}}`,
60
+
61
+ 'Invoice Processing': `Subject: {{company}}'s invoice backlog solved
62
+
63
+ Hi {{first_name}},
64
+
65
+ Processing invoices shouldn't take your team's entire day.
66
+
67
+ Our Invoice Processing solution uses AI to:
68
+ • Extract data from any invoice format
69
+ • Auto-match with POs and receipts
70
+ • Route for approval automatically
71
+
72
+ Result: 90% less manual work.
73
+
74
+ Can I show you how it works?
75
+
76
+ Best,
77
+ {{sender_name}}`,
78
+
79
+ 'Expense Management': `Subject: Expense reports without the headache
80
+
81
+ Hi {{first_name}},
82
+
83
+ Chasing receipts and approvals is nobody's favorite task at {{company}}.
84
+
85
+ Our Expense Management platform:
86
+ • Captures receipts via mobile
87
+ • Enforces policies automatically
88
+ • Syncs with your accounting system
89
+
90
+ Teams close books 3 days faster on average.
91
+
92
+ Quick call to discuss?
93
+
94
+ Best,
95
+ {{sender_name}}`,
96
+
97
+ 'Procurement Automation': `Subject: Smarter purchasing for {{company}}
98
+
99
+ Hi {{first_name}},
100
+
101
+ Is your procurement process as efficient as it could be?
102
+
103
+ Our Procurement Automation helps:
104
+ • Streamline purchase requests
105
+ • Manage vendor relationships
106
+ • Track spend in real-time
107
+
108
+ Companies typically save 15% on procurement costs.
109
+
110
+ Would love to show you how.
111
+
112
+ Best,
113
+ {{sender_name}}`,
114
+ };
115
+
116
+ export default function PromptEditor({ selectedProducts, prompts, onPromptsChange }) {
117
+ const [activeTab, setActiveTab] = useState(selectedProducts[0]?.name || '');
118
+ const [savedStatus, setSavedStatus] = useState({});
119
+ const [localPrompts, setLocalPrompts] = useState({});
120
+
121
+ useEffect(() => {
122
+ if (selectedProducts.length > 0 && !activeTab) {
123
+ setActiveTab(selectedProducts[0].name);
124
+ }
125
+ if (selectedProducts.length > 0 && !selectedProducts.find(p => p.name === activeTab)) {
126
+ setActiveTab(selectedProducts[0].name);
127
+ }
128
+ }, [selectedProducts, activeTab]);
129
+
130
+ useEffect(() => {
131
+ // Initialize prompts for selected products
132
+ const newPrompts = {};
133
+ selectedProducts.forEach(product => {
134
+ newPrompts[product.name] = prompts[product.name] || DEFAULT_TEMPLATES[product.name] ||
135
+ `Subject: {{first_name}}, let's talk about ${product.name}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${product.name} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`;
136
+ });
137
+ setLocalPrompts(newPrompts);
138
+ }, [selectedProducts]);
139
+
140
+ const handlePromptChange = (productName, value) => {
141
+ setLocalPrompts(prev => ({
142
+ ...prev,
143
+ [productName]: value
144
+ }));
145
+ setSavedStatus(prev => ({
146
+ ...prev,
147
+ [productName]: false
148
+ }));
149
+ };
150
+
151
+ const handleSave = (productName) => {
152
+ onPromptsChange({
153
+ ...prompts,
154
+ [productName]: localPrompts[productName]
155
+ });
156
+ setSavedStatus(prev => ({
157
+ ...prev,
158
+ [productName]: true
159
+ }));
160
+ setTimeout(() => {
161
+ setSavedStatus(prev => ({
162
+ ...prev,
163
+ [productName]: false
164
+ }));
165
+ }, 2000);
166
+ };
167
+
168
+ const handleReset = (productName) => {
169
+ const defaultTemplate = DEFAULT_TEMPLATES[productName] ||
170
+ `Subject: {{first_name}}, let's talk about ${productName}\n\nHi {{first_name}},\n\nI wanted to reach out about how ${productName} could benefit {{company}}.\n\n[Your personalized message here]\n\nBest,\n{{sender_name}}`;
171
+ handlePromptChange(productName, defaultTemplate);
172
+ };
173
+
174
+ if (selectedProducts.length === 0) {
175
+ return (
176
+ <div className="rounded-2xl border border-slate-200 bg-slate-50/50 p-12 text-center">
177
+ <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
178
+ <FileText className="h-8 w-8 text-slate-300" />
179
+ </div>
180
+ <h3 className="text-lg font-semibold text-slate-400 mb-2">No products selected</h3>
181
+ <p className="text-sm text-slate-400">Select at least one product above to configure the prompt template</p>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ return (
187
+ <div className="w-full">
188
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
189
+ <TabsList className="w-full h-auto flex-wrap justify-start gap-2 bg-transparent p-0 mb-4">
190
+ {selectedProducts.map((product) => (
191
+ <TabsTrigger
192
+ key={product.id}
193
+ value={product.name}
194
+ className="rounded-lg px-4 py-2 text-sm font-medium transition-all bg-transparent data-[state=active]:bg-violet-600 data-[state=active]:text-white"
195
+ >
196
+ {product.name}
197
+ </TabsTrigger>
198
+ ))}
199
+ </TabsList>
200
+
201
+ <AnimatePresence mode="wait">
202
+ {selectedProducts.map((product) => (
203
+ <TabsContent key={product.id} value={product.name} className="mt-0">
204
+ <motion.div
205
+ initial={{ opacity: 0, y: 10 }}
206
+ animate={{ opacity: 1, y: 0 }}
207
+ exit={{ opacity: 0, y: -10 }}
208
+ transition={{ duration: 0.2 }}
209
+ className="rounded-2xl border border-slate-200 bg-white overflow-hidden"
210
+ >
211
+ <div className="border-b border-slate-100 bg-slate-50/50 px-6 py-4 flex items-center justify-between">
212
+ <div className="flex items-center gap-3">
213
+ <div className="rounded-lg bg-violet-100 p-2">
214
+ <Sparkles className="h-4 w-4 text-violet-600" />
215
+ </div>
216
+ <div>
217
+ <h4 className="font-semibold text-slate-800">Email Template</h4>
218
+ <p className="text-xs text-slate-500">
219
+ Use variables: {"{{first_name}}"}, {"{{company}}"}, {"{{sender_name}}"}
220
+ </p>
221
+ </div>
222
+ </div>
223
+ <div className="flex items-center gap-2">
224
+ <Button
225
+ variant="ghost"
226
+ size="sm"
227
+ onClick={() => handleReset(product.name)}
228
+ className="text-slate-500 hover:text-slate-700"
229
+ >
230
+ <RotateCcw className="h-4 w-4 mr-1" />
231
+ Reset
232
+ </Button>
233
+ <Button
234
+ size="sm"
235
+ onClick={() => handleSave(product.name)}
236
+ className="bg-violet-600 hover:bg-violet-700"
237
+ >
238
+ {savedStatus[product.name] ? (
239
+ <>
240
+ <CheckCircle2 className="h-4 w-4 mr-1" />
241
+ Saved!
242
+ </>
243
+ ) : (
244
+ <>
245
+ <Save className="h-4 w-4 mr-1" />
246
+ Save Template
247
+ </>
248
+ )}
249
+ </Button>
250
+ </div>
251
+ </div>
252
+ <div className="p-6">
253
+ <Textarea
254
+ value={localPrompts[product.name] || ''}
255
+ onChange={(e) => handlePromptChange(product.name, e.target.value)}
256
+ placeholder="Enter your email template here..."
257
+ className="min-h-[320px] font-mono text-sm leading-relaxed resize-none
258
+ border-slate-200 focus:border-violet-300 focus:ring-violet-200"
259
+ />
260
+ </div>
261
+ </motion.div>
262
+ </TabsContent>
263
+ ))}
264
+ </AnimatePresence>
265
+ </Tabs>
266
+ </div>
267
+ );
268
+ }
frontend/src/components/sequences/SequenceCard.jsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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";
5
+ import { motion } from 'framer-motion';
6
+
7
+ export default function SequenceCard({ sequence, index }) {
8
+ const [isExpanded, setIsExpanded] = useState(index < 3);
9
+ const [copied, setCopied] = useState(false);
10
+
11
+ const handleCopy = () => {
12
+ navigator.clipboard.writeText(sequence.emailContent);
13
+ setCopied(true);
14
+ setTimeout(() => setCopied(false), 2000);
15
+ };
16
+
17
+ return (
18
+ <motion.div
19
+ initial={{ opacity: 0, y: 20 }}
20
+ animate={{ opacity: 1, y: 0 }}
21
+ transition={{ duration: 0.3, delay: index * 0.05 }}
22
+ className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:shadow-md transition-shadow"
23
+ >
24
+ <div
25
+ className="px-5 py-4 flex items-center justify-between cursor-pointer hover:bg-slate-50/50 transition-colors"
26
+ onClick={() => setIsExpanded(!isExpanded)}
27
+ >
28
+ <div className="flex items-center gap-4">
29
+ <div className="h-10 w-10 rounded-full bg-gradient-to-br from-violet-500 to-purple-600
30
+ flex items-center justify-center text-white font-semibold text-sm">
31
+ {sequence.firstName?.[0]}{sequence.lastName?.[0]}
32
+ </div>
33
+ <div>
34
+ <h4 className="font-semibold text-slate-800">
35
+ {sequence.firstName} {sequence.lastName}
36
+ </h4>
37
+ <div className="flex items-center gap-3 text-sm text-slate-500">
38
+ <span className="flex items-center gap-1">
39
+ <Building2 className="h-3.5 w-3.5" />
40
+ {sequence.company}
41
+ </span>
42
+ <span className="flex items-center gap-1">
43
+ <Mail className="h-3.5 w-3.5" />
44
+ {sequence.email}
45
+ </span>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ <div className="flex items-center gap-3">
50
+ <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100">
51
+ {sequence.product}
52
+ </Badge>
53
+ {isExpanded ? (
54
+ <ChevronUp className="h-5 w-5 text-slate-400" />
55
+ ) : (
56
+ <ChevronDown className="h-5 w-5 text-slate-400" />
57
+ )}
58
+ </div>
59
+ </div>
60
+
61
+ {isExpanded && (
62
+ <motion.div
63
+ initial={{ opacity: 0, height: 0 }}
64
+ animate={{ opacity: 1, height: 'auto' }}
65
+ exit={{ opacity: 0, height: 0 }}
66
+ className="border-t border-slate-100"
67
+ >
68
+ <div className="px-5 py-4 bg-slate-50/50">
69
+ <div className="flex items-center justify-between mb-3">
70
+ <h5 className="text-sm font-medium text-slate-600">Generated Email</h5>
71
+ <Button
72
+ variant="ghost"
73
+ size="sm"
74
+ onClick={(e) => {
75
+ e.stopPropagation();
76
+ handleCopy();
77
+ }}
78
+ className="h-8 text-slate-500 hover:text-violet-600"
79
+ >
80
+ {copied ? (
81
+ <>
82
+ <CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
83
+ Copied!
84
+ </>
85
+ ) : (
86
+ <>
87
+ <Copy className="h-4 w-4 mr-1" />
88
+ Copy
89
+ </>
90
+ )}
91
+ </Button>
92
+ </div>
93
+ <div className="bg-white rounded-lg border border-slate-200 p-4">
94
+ <div className="mb-3 pb-3 border-b border-slate-100">
95
+ <span className="text-xs text-slate-400 uppercase tracking-wide">Subject</span>
96
+ <p className="text-sm font-medium text-slate-800 mt-1">{sequence.subject}</p>
97
+ </div>
98
+ <div className="text-sm text-slate-700 whitespace-pre-wrap leading-relaxed">
99
+ {sequence.emailContent}
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </motion.div>
104
+ )}
105
+ </motion.div>
106
+ );
107
+ }
frontend/src/components/sequences/SequenceViewer.jsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Download, Mail, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { Progress } from "@/components/ui/progress";
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
7
+ import { motion, AnimatePresence } from 'framer-motion';
8
+ import SequenceCard from './SequenceCard';
9
+
10
+ export default function SequenceViewer({ isGenerating, contactCount, selectedProducts, uploadedFile, prompts, onComplete }) {
11
+ const [sequences, setSequences] = useState([]);
12
+ const [progress, setProgress] = useState(0);
13
+ const [searchQuery, setSearchQuery] = useState('');
14
+ const [filterProduct, setFilterProduct] = useState('all');
15
+ const [isComplete, setIsComplete] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (isGenerating && uploadedFile?.fileId) {
19
+ setSequences([]);
20
+ setProgress(0);
21
+ setIsComplete(false);
22
+
23
+ // Start streaming sequences from API
24
+ const eventSource = new EventSource(`/api/generate-sequences?file_id=${uploadedFile.fileId}`, {
25
+ withCredentials: false
26
+ });
27
+
28
+ eventSource.onmessage = (event) => {
29
+ try {
30
+ const data = JSON.parse(event.data);
31
+
32
+ if (data.type === 'sequence') {
33
+ setSequences(prev => [...prev, data.sequence]);
34
+ setProgress((data.sequence.id / contactCount) * 100);
35
+ } else if (data.type === 'progress') {
36
+ setProgress(data.progress);
37
+ } else if (data.type === 'complete') {
38
+ setIsComplete(true);
39
+ onComplete?.();
40
+ eventSource.close();
41
+ } else if (data.type === 'error') {
42
+ console.error('Generation error:', data.error);
43
+ alert('Error generating sequences: ' + data.error);
44
+ eventSource.close();
45
+ }
46
+ } catch (error) {
47
+ console.error('Error parsing SSE data:', error);
48
+ }
49
+ };
50
+
51
+ eventSource.onerror = (error) => {
52
+ console.error('SSE error:', error);
53
+ eventSource.close();
54
+ if (!isComplete) {
55
+ alert('Connection error. Please try again.');
56
+ }
57
+ };
58
+
59
+ return () => {
60
+ eventSource.close();
61
+ };
62
+ }
63
+ }, [isGenerating, uploadedFile, contactCount, selectedProducts, prompts, onComplete, isComplete]);
64
+
65
+ const handleDownload = async () => {
66
+ try {
67
+ const response = await fetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
68
+ if (response.ok) {
69
+ const blob = await response.blob();
70
+ const url = URL.createObjectURL(blob);
71
+ const a = document.createElement('a');
72
+ a.href = url;
73
+ a.download = 'email_sequences.csv';
74
+ a.click();
75
+ URL.revokeObjectURL(url);
76
+ } else {
77
+ alert('Failed to download CSV. Please try again.');
78
+ }
79
+ } catch (error) {
80
+ console.error('Download error:', error);
81
+ alert('Error downloading CSV. Please try again.');
82
+ }
83
+ };
84
+
85
+ const filteredSequences = sequences.filter(seq => {
86
+ const matchesSearch = searchQuery === '' ||
87
+ seq.firstName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
88
+ seq.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
89
+ seq.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
90
+ seq.email?.toLowerCase().includes(searchQuery.toLowerCase());
91
+
92
+ const matchesFilter = filterProduct === 'all' || seq.product === filterProduct;
93
+
94
+ return matchesSearch && matchesFilter;
95
+ });
96
+
97
+ return (
98
+ <div className="w-full">
99
+ {/* Progress Header */}
100
+ <div className="rounded-2xl border border-slate-200 bg-white p-6 mb-6">
101
+ <div className="flex items-center justify-between mb-4">
102
+ <div className="flex items-center gap-3">
103
+ {isComplete ? (
104
+ <div className="rounded-xl bg-green-100 p-3">
105
+ <CheckCircle2 className="h-6 w-6 text-green-600" />
106
+ </div>
107
+ ) : (
108
+ <div className="rounded-xl bg-violet-100 p-3">
109
+ <Loader2 className="h-6 w-6 text-violet-600 animate-spin" />
110
+ </div>
111
+ )}
112
+ <div>
113
+ <h3 className="font-semibold text-slate-800">
114
+ {isComplete ? 'Generation Complete!' : 'Generating Email Sequences...'}
115
+ </h3>
116
+ <p className="text-sm text-slate-500">
117
+ {sequences.length} of {contactCount} sequences generated
118
+ </p>
119
+ </div>
120
+ </div>
121
+ {isComplete && (
122
+ <Button
123
+ onClick={handleDownload}
124
+ className="bg-green-600 hover:bg-green-700"
125
+ >
126
+ <Download className="h-4 w-4 mr-2" />
127
+ Download CSV for Klenty
128
+ </Button>
129
+ )}
130
+ </div>
131
+ <Progress value={progress} className="h-2" />
132
+ </div>
133
+
134
+ {/* Filters */}
135
+ {sequences.length > 0 && (
136
+ <motion.div
137
+ initial={{ opacity: 0, y: -10 }}
138
+ animate={{ opacity: 1, y: 0 }}
139
+ className="flex flex-col sm:flex-row gap-3 mb-6"
140
+ >
141
+ <div className="relative flex-1">
142
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
143
+ <Input
144
+ placeholder="Search contacts..."
145
+ value={searchQuery}
146
+ onChange={(e) => setSearchQuery(e.target.value)}
147
+ className="pl-10"
148
+ />
149
+ </div>
150
+ <Select value={filterProduct} onValueChange={setFilterProduct}>
151
+ <SelectTrigger className="w-full sm:w-48">
152
+ <Filter className="h-4 w-4 mr-2 text-slate-400" />
153
+ <SelectValue placeholder="Filter by product" />
154
+ </SelectTrigger>
155
+ <SelectContent>
156
+ <SelectItem value="all">All Products</SelectItem>
157
+ {selectedProducts.map(product => (
158
+ <SelectItem key={product.id} value={product.name}>
159
+ {product.name}
160
+ </SelectItem>
161
+ ))}
162
+ </SelectContent>
163
+ </Select>
164
+ </motion.div>
165
+ )}
166
+
167
+ {/* Sequence List */}
168
+ <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar">
169
+ <AnimatePresence>
170
+ {filteredSequences.map((sequence, index) => (
171
+ <SequenceCard key={sequence.id} sequence={sequence} index={index} />
172
+ ))}
173
+ </AnimatePresence>
174
+ </div>
175
+
176
+ {/* Empty State */}
177
+ {!isGenerating && sequences.length === 0 && (
178
+ <div className="text-center py-16">
179
+ <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4">
180
+ <Mail className="h-8 w-8 text-slate-300" />
181
+ </div>
182
+ <h3 className="text-lg font-semibold text-slate-400 mb-2">No sequences yet</h3>
183
+ <p className="text-sm text-slate-400">Click "Generate Sequences" to start</p>
184
+ </div>
185
+ )}
186
+ </div>
187
+ );
188
+ }
frontend/src/components/ui/badge.jsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Badge = React.forwardRef(({ className, variant = "default", ...props }, ref) => {
5
+ const variants = {
6
+ default: "border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80",
7
+ secondary: "border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80",
8
+ destructive: "border-transparent bg-red-500 text-slate-50 hover:bg-red-500/80",
9
+ outline: "text-slate-950 border-slate-200",
10
+ }
11
+
12
+ return (
13
+ <div
14
+ ref={ref}
15
+ className={cn(
16
+ "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2",
17
+ variants[variant],
18
+ className
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+ })
24
+ Badge.displayName = "Badge"
25
+
26
+ export { Badge }
frontend/src/components/ui/button.jsx ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Button = React.forwardRef(({ className, variant = "default", size = "default", ...props }, ref) => {
5
+ const variants = {
6
+ default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90",
7
+ destructive: "bg-red-500 text-slate-50 hover:bg-red-500/90",
8
+ outline: "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900",
9
+ secondary: "bg-slate-100 text-slate-900 hover:bg-slate-100/80",
10
+ ghost: "hover:bg-slate-100 hover:text-slate-900",
11
+ link: "text-slate-900 underline-offset-4 hover:underline",
12
+ }
13
+
14
+ const sizes = {
15
+ default: "h-10 px-4 py-2",
16
+ sm: "h-9 rounded-md px-3",
17
+ lg: "h-11 rounded-md px-8",
18
+ icon: "h-10 w-10",
19
+ }
20
+
21
+ return (
22
+ <button
23
+ className={cn(
24
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 disabled:pointer-events-none disabled:opacity-50",
25
+ variants[variant],
26
+ sizes[size],
27
+ className
28
+ )}
29
+ ref={ref}
30
+ {...props}
31
+ />
32
+ )
33
+ })
34
+ Button.displayName = "Button"
35
+
36
+ export { Button }
frontend/src/components/ui/input.jsx ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
5
+ return (
6
+ <input
7
+ type={type}
8
+ className={cn(
9
+ "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
10
+ className
11
+ )}
12
+ ref={ref}
13
+ {...props}
14
+ />
15
+ )
16
+ })
17
+ Input.displayName = "Input"
18
+
19
+ export { Input }
frontend/src/components/ui/progress.jsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Progress = React.forwardRef(({ className, value, ...props }, ref) => {
5
+ return (
6
+ <div
7
+ ref={ref}
8
+ className={cn(
9
+ "relative h-4 w-full overflow-hidden rounded-full bg-slate-200",
10
+ className
11
+ )}
12
+ {...props}
13
+ >
14
+ <div
15
+ className="h-full w-full flex-1 bg-violet-600 transition-all"
16
+ style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
17
+ />
18
+ </div>
19
+ )
20
+ })
21
+ Progress.displayName = "Progress"
22
+
23
+ export { Progress }
frontend/src/components/ui/select.jsx ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+ import { ChevronDown } from "lucide-react"
4
+
5
+ const SelectContext = React.createContext(null)
6
+
7
+ const Select = ({ value, onValueChange, children, ...props }) => {
8
+ const [isOpen, setIsOpen] = React.useState(false)
9
+ const [internalValue, setInternalValue] = React.useState("")
10
+ const currentValue = value !== undefined ? value : internalValue
11
+ const handleValueChange = (newValue) => {
12
+ if (onValueChange) {
13
+ onValueChange(newValue)
14
+ } else {
15
+ setInternalValue(newValue)
16
+ }
17
+ setIsOpen(false)
18
+ }
19
+
20
+ return (
21
+ <SelectContext.Provider value={{ value: currentValue, onValueChange: handleValueChange, isOpen, setIsOpen }}>
22
+ <div className="relative" {...props}>
23
+ {children}
24
+ </div>
25
+ </SelectContext.Provider>
26
+ )
27
+ }
28
+
29
+ const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => {
30
+ const context = React.useContext(SelectContext)
31
+ return (
32
+ <button
33
+ ref={ref}
34
+ type="button"
35
+ onClick={() => context?.setIsOpen(!context.isOpen)}
36
+ className={cn(
37
+ "flex h-10 w-full items-center justify-between rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
38
+ className
39
+ )}
40
+ {...props}
41
+ >
42
+ {children}
43
+ <ChevronDown className="h-4 w-4 opacity-50" />
44
+ </button>
45
+ )
46
+ })
47
+ SelectTrigger.displayName = "SelectTrigger"
48
+
49
+ const SelectValue = ({ placeholder, ...props }) => {
50
+ const context = React.useContext(SelectContext)
51
+ const displayValue = context?.value || placeholder
52
+ return <span {...props}>{displayValue}</span>
53
+ }
54
+
55
+ const SelectContent = React.forwardRef(({ className, children, ...props }, ref) => {
56
+ const context = React.useContext(SelectContext)
57
+ if (!context?.isOpen) return null
58
+
59
+ return (
60
+ <div
61
+ ref={ref}
62
+ className={cn(
63
+ "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-white text-slate-950 shadow-md",
64
+ className
65
+ )}
66
+ {...props}
67
+ >
68
+ <div className="p-1">{children}</div>
69
+ </div>
70
+ )
71
+ })
72
+ SelectContent.displayName = "SelectContent"
73
+
74
+ const SelectItem = React.forwardRef(({ className, value, children, ...props }, ref) => {
75
+ const context = React.useContext(SelectContext)
76
+ const isSelected = context?.value === value
77
+
78
+ return (
79
+ <div
80
+ ref={ref}
81
+ onClick={() => context?.onValueChange(value)}
82
+ className={cn(
83
+ "relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 px-2 text-sm outline-none hover:bg-slate-100 focus:bg-slate-100",
84
+ isSelected && "bg-slate-100",
85
+ className
86
+ )}
87
+ {...props}
88
+ >
89
+ {children}
90
+ </div>
91
+ )
92
+ })
93
+ SelectItem.displayName = "SelectItem"
94
+
95
+ export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
frontend/src/components/ui/tabs.jsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const TabsContext = React.createContext(null)
5
+
6
+ const Tabs = ({ defaultValue, value, onValueChange, className, children, ...props }) => {
7
+ const [internalValue, setInternalValue] = React.useState(defaultValue || "")
8
+ const currentValue = value !== undefined ? value : internalValue
9
+ const handleValueChange = onValueChange || setInternalValue
10
+
11
+ return (
12
+ <TabsContext.Provider value={{ value: currentValue, onValueChange: handleValueChange }}>
13
+ <div className={cn("w-full", className)} {...props}>
14
+ {children}
15
+ </div>
16
+ </TabsContext.Provider>
17
+ )
18
+ }
19
+
20
+ const TabsList = React.forwardRef(({ className, ...props }, ref) => {
21
+ return (
22
+ <div
23
+ ref={ref}
24
+ className={cn(
25
+ "inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500",
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ )
31
+ })
32
+ TabsList.displayName = "TabsList"
33
+
34
+ const TabsTrigger = React.forwardRef(({ className, value, ...props }, ref) => {
35
+ const context = React.useContext(TabsContext)
36
+ const isActive = context?.value === value
37
+
38
+ return (
39
+ <button
40
+ ref={ref}
41
+ type="button"
42
+ onClick={() => context?.onValueChange(value)}
43
+ data-state={isActive ? "active" : "inactive"}
44
+ className={cn(
45
+ "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
46
+ isActive ? "bg-white text-slate-950 shadow-sm" : "text-slate-500 hover:text-slate-950",
47
+ className
48
+ )}
49
+ {...props}
50
+ />
51
+ )
52
+ })
53
+ TabsTrigger.displayName = "TabsTrigger"
54
+
55
+ const TabsContent = React.forwardRef(({ className, value, ...props }, ref) => {
56
+ const context = React.useContext(TabsContext)
57
+ const isActive = context?.value === value
58
+
59
+ if (!isActive) return null
60
+
61
+ return (
62
+ <div
63
+ ref={ref}
64
+ className={cn(
65
+ "mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2",
66
+ className
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ })
72
+ TabsContent.displayName = "TabsContent"
73
+
74
+ export { Tabs, TabsList, TabsTrigger, TabsContent }
frontend/src/components/ui/textarea.jsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cn } from "@/lib/utils"
3
+
4
+ const Textarea = React.forwardRef(({ className, ...props }, ref) => {
5
+ return (
6
+ <textarea
7
+ className={cn(
8
+ "flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
9
+ className
10
+ )}
11
+ ref={ref}
12
+ {...props}
13
+ />
14
+ )
15
+ })
16
+ Textarea.displayName = "Textarea"
17
+
18
+ export { Textarea }
frontend/src/components/upload/UploadStep.jsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef } from 'react';
2
+ import { Upload, FileSpreadsheet, CheckCircle2, X, Users } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+
6
+ export default function UploadStep({ onFileUploaded, uploadedFile, onRemoveFile }) {
7
+ const [isDragging, setIsDragging] = useState(false);
8
+ const fileInputRef = useRef(null);
9
+
10
+ const handleDragOver = (e) => {
11
+ e.preventDefault();
12
+ setIsDragging(true);
13
+ };
14
+
15
+ const handleDragLeave = (e) => {
16
+ e.preventDefault();
17
+ setIsDragging(false);
18
+ };
19
+
20
+ const handleDrop = async (e) => {
21
+ e.preventDefault();
22
+ setIsDragging(false);
23
+ const file = e.dataTransfer.files[0];
24
+ if (file && file.name.endsWith('.csv')) {
25
+ await handleFileUpload(file);
26
+ }
27
+ };
28
+
29
+ const handleFileSelect = async (e) => {
30
+ const file = e.target.files[0];
31
+ if (file && file.name.endsWith('.csv')) {
32
+ await handleFileUpload(file);
33
+ }
34
+ };
35
+
36
+ const handleFileUpload = async (file) => {
37
+ const formData = new FormData();
38
+ formData.append('file', file);
39
+
40
+ try {
41
+ const response = await fetch('/api/upload-csv', {
42
+ method: 'POST',
43
+ body: formData,
44
+ });
45
+
46
+ if (response.ok) {
47
+ const data = await response.json();
48
+ onFileUploaded({
49
+ name: file.name,
50
+ size: file.size,
51
+ contactCount: data.contact_count,
52
+ fileId: data.file_id
53
+ });
54
+ } else {
55
+ alert('Failed to upload file. Please try again.');
56
+ }
57
+ } catch (error) {
58
+ console.error('Upload error:', error);
59
+ alert('Error uploading file. Please try again.');
60
+ }
61
+ };
62
+
63
+ return (
64
+ <div className="w-full">
65
+ <AnimatePresence mode="wait">
66
+ {!uploadedFile ? (
67
+ <motion.div
68
+ key="upload"
69
+ initial={{ opacity: 0, y: 10 }}
70
+ animate={{ opacity: 1, y: 0 }}
71
+ exit={{ opacity: 0, y: -10 }}
72
+ transition={{ duration: 0.3 }}
73
+ >
74
+ <div
75
+ onDragOver={handleDragOver}
76
+ onDragLeave={handleDragLeave}
77
+ onDrop={handleDrop}
78
+ onClick={() => fileInputRef.current?.click()}
79
+ className={`
80
+ relative cursor-pointer rounded-2xl border-2 border-dashed
81
+ transition-all duration-300 ease-out
82
+ ${isDragging
83
+ ? 'border-violet-500 bg-violet-50 scale-[1.02]'
84
+ : 'border-slate-200 bg-slate-50/50 hover:border-violet-300 hover:bg-violet-50/30'
85
+ }
86
+ `}
87
+ >
88
+ <div className="flex flex-col items-center justify-center py-16 px-8">
89
+ <div className={`
90
+ mb-6 rounded-2xl p-4 transition-all duration-300
91
+ ${isDragging ? 'bg-violet-100' : 'bg-white shadow-sm'}
92
+ `}>
93
+ <Upload className={`
94
+ h-8 w-8 transition-colors duration-300
95
+ ${isDragging ? 'text-violet-600' : 'text-slate-400'}
96
+ `} />
97
+ </div>
98
+ <h3 className="text-lg font-semibold text-slate-800 mb-2">
99
+ Upload your Apollo CSV
100
+ </h3>
101
+ <p className="text-sm text-slate-500 text-center max-w-sm">
102
+ Drag and drop your contact list here, or click to browse
103
+ </p>
104
+ <div className="mt-6 flex items-center gap-2 text-xs text-slate-400">
105
+ <FileSpreadsheet className="h-4 w-4" />
106
+ <span>Supports .csv files exported from Apollo</span>
107
+ </div>
108
+ </div>
109
+ <input
110
+ ref={fileInputRef}
111
+ type="file"
112
+ accept=".csv"
113
+ onChange={handleFileSelect}
114
+ className="hidden"
115
+ />
116
+ </div>
117
+ </motion.div>
118
+ ) : (
119
+ <motion.div
120
+ key="uploaded"
121
+ initial={{ opacity: 0, scale: 0.95 }}
122
+ animate={{ opacity: 1, scale: 1 }}
123
+ exit={{ opacity: 0, scale: 0.95 }}
124
+ transition={{ duration: 0.3 }}
125
+ className="rounded-2xl border border-green-200 bg-gradient-to-br from-green-50 to-emerald-50 p-6"
126
+ >
127
+ <div className="flex items-start justify-between">
128
+ <div className="flex items-start gap-4">
129
+ <div className="rounded-xl bg-green-100 p-3">
130
+ <CheckCircle2 className="h-6 w-6 text-green-600" />
131
+ </div>
132
+ <div>
133
+ <h3 className="font-semibold text-slate-800 mb-1">
134
+ {uploadedFile.name}
135
+ </h3>
136
+ <div className="flex items-center gap-4 text-sm text-slate-500">
137
+ <span>{(uploadedFile.size / 1024).toFixed(1)} KB</span>
138
+ <div className="flex items-center gap-1.5 text-green-600 font-medium">
139
+ <Users className="h-4 w-4" />
140
+ <span>{uploadedFile.contactCount} contacts found</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <Button
146
+ variant="ghost"
147
+ size="icon"
148
+ onClick={onRemoveFile}
149
+ className="h-8 w-8 text-slate-400 hover:text-red-500 hover:bg-red-50"
150
+ >
151
+ <X className="h-4 w-4" />
152
+ </Button>
153
+ </div>
154
+ </motion.div>
155
+ )}
156
+ </AnimatePresence>
157
+ </div>
158
+ );
159
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --background: 0 0% 100%;
8
+ --foreground: 222.2 84% 4.9%;
9
+ --card: 0 0% 100%;
10
+ --card-foreground: 222.2 84% 4.9%;
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 222.2 84% 4.9%;
13
+ --primary: 222.2 47.4% 11.2%;
14
+ --primary-foreground: 210 40% 98%;
15
+ --secondary: 210 40% 96.1%;
16
+ --secondary-foreground: 222.2 47.4% 11.2%;
17
+ --muted: 210 40% 96.1%;
18
+ --muted-foreground: 215.4 16.3% 46.9%;
19
+ --accent: 210 40% 96.1%;
20
+ --accent-foreground: 222.2 47.4% 11.2%;
21
+ --destructive: 0 84.2% 60.2%;
22
+ --destructive-foreground: 210 40% 98%;
23
+ --border: 214.3 31.8% 91.4%;
24
+ --input: 214.3 31.8% 91.4%;
25
+ --ring: 222.2 84% 4.9%;
26
+ --radius: 0.5rem;
27
+ }
28
+ }
29
+
30
+ @layer base {
31
+ * {
32
+ @apply border-border;
33
+ }
34
+ body {
35
+ @apply bg-background text-foreground;
36
+ }
37
+ }
frontend/src/lib/utils.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs) {
5
+ return twMerge(clsx(inputs));
6
+ }
frontend/src/main.jsx CHANGED
@@ -1,6 +1,7 @@
1
  import React from "react";
2
  import ReactDOM from "react-dom/client";
3
  import App from "./App.jsx";
 
4
 
5
  ReactDOM.createRoot(document.getElementById("root")).render(
6
  <React.StrictMode>
 
1
  import React from "react";
2
  import ReactDOM from "react-dom/client";
3
  import App from "./App.jsx";
4
+ import "./index.css";
5
 
6
  ReactDOM.createRoot(document.getElementById("root")).render(
7
  <React.StrictMode>
frontend/src/pages/EmailSequenceGenerator.jsx ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Sparkles, ArrowRight, ArrowLeft, Mail, Zap } from 'lucide-react';
3
+ import { Button } from "@/components/ui/button";
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+
6
+ import UploadStep from '@/components/upload/UploadStep';
7
+ import ProductSelector from '@/components/products/ProductSelector';
8
+ import PromptEditor from '@/components/prompts/PromptEditor';
9
+ import SequenceViewer from '@/components/sequences/SequenceViewer';
10
+
11
+ export default function EmailSequenceGenerator() {
12
+ const [step, setStep] = useState(1);
13
+ const [uploadedFile, setUploadedFile] = useState(null);
14
+ const [selectedProducts, setSelectedProducts] = useState([]);
15
+ const [prompts, setPrompts] = useState({});
16
+ const [isGenerating, setIsGenerating] = useState(false);
17
+ const [generationComplete, setGenerationComplete] = useState(false);
18
+
19
+ const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
20
+ const canProceedToStep3 = Object.keys(prompts).length > 0;
21
+
22
+ const handleGenerate = async () => {
23
+ if (!uploadedFile?.fileId || selectedProducts.length === 0 || Object.keys(prompts).length === 0) {
24
+ alert('Please complete all steps before generating sequences.');
25
+ return;
26
+ }
27
+
28
+ setStep(3);
29
+ setIsGenerating(true);
30
+
31
+ // Save prompts to backend
32
+ try {
33
+ await fetch('/api/save-prompts', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({
37
+ file_id: uploadedFile.fileId,
38
+ prompts: prompts,
39
+ products: selectedProducts.map(p => p.name)
40
+ })
41
+ });
42
+ } catch (error) {
43
+ console.error('Error saving prompts:', error);
44
+ }
45
+ };
46
+
47
+ const handleGenerationComplete = () => {
48
+ setIsGenerating(false);
49
+ setGenerationComplete(true);
50
+ };
51
+
52
+ const handleReset = () => {
53
+ setStep(1);
54
+ setUploadedFile(null);
55
+ setSelectedProducts([]);
56
+ setPrompts({});
57
+ setIsGenerating(false);
58
+ setGenerationComplete(false);
59
+ };
60
+
61
+ return (
62
+ <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-violet-50">
63
+ {/* Header */}
64
+ <header className="border-b border-slate-100 bg-white/80 backdrop-blur-sm sticky top-0 z-50">
65
+ <div className="max-w-6xl mx-auto px-6 py-4">
66
+ <div className="flex items-center justify-between">
67
+ <div className="flex items-center gap-3">
68
+ <div className="h-10 w-10 rounded-xl bg-gradient-to-br from-violet-600 to-purple-600
69
+ flex items-center justify-center shadow-lg shadow-violet-200">
70
+ <Zap className="h-5 w-5 text-white" />
71
+ </div>
72
+ <div>
73
+ <h1 className="font-bold text-slate-800 text-lg">SequenceAI</h1>
74
+ <p className="text-xs text-slate-500">Personalized Email Outreach</p>
75
+ </div>
76
+ </div>
77
+ {step > 1 && (
78
+ <Button
79
+ variant="ghost"
80
+ onClick={handleReset}
81
+ className="text-slate-500 hover:text-slate-700"
82
+ >
83
+ Start Over
84
+ </Button>
85
+ )}
86
+ </div>
87
+ </div>
88
+ </header>
89
+
90
+ <main className="max-w-6xl mx-auto px-6 py-8">
91
+ {/* Progress Steps */}
92
+ <div className="mb-10">
93
+ <div className="flex items-center justify-center gap-4">
94
+ {[
95
+ { num: 1, label: 'Upload & Select' },
96
+ { num: 2, label: 'Configure Prompts' },
97
+ { num: 3, label: 'Generate & Export' }
98
+ ].map((s, idx) => (
99
+ <React.Fragment key={s.num}>
100
+ <div className="flex items-center gap-2">
101
+ <div className={`
102
+ h-8 w-8 rounded-full flex items-center justify-center text-sm font-semibold
103
+ transition-all duration-300
104
+ ${step >= s.num
105
+ ? 'bg-violet-600 text-white shadow-lg shadow-violet-200'
106
+ : 'bg-slate-100 text-slate-400'
107
+ }
108
+ `}>
109
+ {s.num}
110
+ </div>
111
+ <span className={`text-sm font-medium hidden sm:block ${
112
+ step >= s.num ? 'text-slate-800' : 'text-slate-400'
113
+ }`}>
114
+ {s.label}
115
+ </span>
116
+ </div>
117
+ {idx < 2 && (
118
+ <div className={`h-0.5 w-12 rounded-full transition-colors duration-300 ${
119
+ step > s.num ? 'bg-violet-600' : 'bg-slate-200'
120
+ }`} />
121
+ )}
122
+ </React.Fragment>
123
+ ))}
124
+ </div>
125
+ </div>
126
+
127
+ <AnimatePresence mode="wait">
128
+ {/* Step 1: Upload & Product Selection */}
129
+ {step === 1 && (
130
+ <motion.div
131
+ key="step1"
132
+ initial={{ opacity: 0, x: -20 }}
133
+ animate={{ opacity: 1, x: 0 }}
134
+ exit={{ opacity: 0, x: 20 }}
135
+ transition={{ duration: 0.3 }}
136
+ className="space-y-8"
137
+ >
138
+ <div className="text-center mb-8">
139
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
140
+ Upload Your Contacts
141
+ </h2>
142
+ <p className="text-slate-500">
143
+ Import your Apollo CSV and select the products for your outreach campaign
144
+ </p>
145
+ </div>
146
+
147
+ <UploadStep
148
+ onFileUploaded={setUploadedFile}
149
+ uploadedFile={uploadedFile}
150
+ onRemoveFile={() => setUploadedFile(null)}
151
+ />
152
+
153
+ {uploadedFile && (
154
+ <motion.div
155
+ initial={{ opacity: 0, y: 20 }}
156
+ animate={{ opacity: 1, y: 0 }}
157
+ transition={{ delay: 0.2 }}
158
+ >
159
+ <ProductSelector
160
+ selectedProducts={selectedProducts}
161
+ onProductsChange={setSelectedProducts}
162
+ />
163
+ </motion.div>
164
+ )}
165
+
166
+ <div className="flex justify-end pt-4">
167
+ <Button
168
+ onClick={() => setStep(2)}
169
+ disabled={!canProceedToStep2}
170
+ className="bg-violet-600 hover:bg-violet-700 px-6"
171
+ >
172
+ Continue to Prompts
173
+ <ArrowRight className="h-4 w-4 ml-2" />
174
+ </Button>
175
+ </div>
176
+ </motion.div>
177
+ )}
178
+
179
+ {/* Step 2: Prompt Configuration */}
180
+ {step === 2 && (
181
+ <motion.div
182
+ key="step2"
183
+ initial={{ opacity: 0, x: -20 }}
184
+ animate={{ opacity: 1, x: 0 }}
185
+ exit={{ opacity: 0, x: 20 }}
186
+ transition={{ duration: 0.3 }}
187
+ className="space-y-8"
188
+ >
189
+ <div className="text-center mb-8">
190
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
191
+ Customize Your Email Templates
192
+ </h2>
193
+ <p className="text-slate-500">
194
+ Edit the prompt templates for each product. The AI will personalize these for each contact.
195
+ </p>
196
+ </div>
197
+
198
+ <PromptEditor
199
+ selectedProducts={selectedProducts}
200
+ prompts={prompts}
201
+ onPromptsChange={setPrompts}
202
+ />
203
+
204
+ <div className="flex justify-between pt-4">
205
+ <Button
206
+ variant="outline"
207
+ onClick={() => setStep(1)}
208
+ className="px-6"
209
+ >
210
+ <ArrowLeft className="h-4 w-4 mr-2" />
211
+ Back
212
+ </Button>
213
+ <Button
214
+ onClick={handleGenerate}
215
+ disabled={!canProceedToStep3}
216
+ className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700
217
+ hover:to-purple-700 px-8 shadow-lg shadow-violet-200"
218
+ >
219
+ <Sparkles className="h-4 w-4 mr-2" />
220
+ Generate Sequences
221
+ </Button>
222
+ </div>
223
+ </motion.div>
224
+ )}
225
+
226
+ {/* Step 3: Generation & Results */}
227
+ {step === 3 && (
228
+ <motion.div
229
+ key="step3"
230
+ initial={{ opacity: 0, x: -20 }}
231
+ animate={{ opacity: 1, x: 0 }}
232
+ exit={{ opacity: 0, x: 20 }}
233
+ transition={{ duration: 0.3 }}
234
+ className="space-y-8"
235
+ >
236
+ <div className="text-center mb-8">
237
+ <h2 className="text-2xl font-bold text-slate-800 mb-2">
238
+ {generationComplete ? 'Your Sequences Are Ready!' : 'Generating Personalized Emails'}
239
+ </h2>
240
+ <p className="text-slate-500">
241
+ {generationComplete
242
+ ? 'Review your sequences below and download when ready'
243
+ : 'Our AI is crafting personalized emails for each contact'
244
+ }
245
+ </p>
246
+ </div>
247
+
248
+ <SequenceViewer
249
+ isGenerating={isGenerating}
250
+ contactCount={uploadedFile?.contactCount || 50}
251
+ selectedProducts={selectedProducts}
252
+ uploadedFile={uploadedFile}
253
+ prompts={prompts}
254
+ onComplete={handleGenerationComplete}
255
+ />
256
+
257
+ {!isGenerating && (
258
+ <div className="flex justify-start pt-4">
259
+ <Button
260
+ variant="outline"
261
+ onClick={() => setStep(2)}
262
+ className="px-6"
263
+ >
264
+ <ArrowLeft className="h-4 w-4 mr-2" />
265
+ Edit Templates
266
+ </Button>
267
+ </div>
268
+ )}
269
+ </motion.div>
270
+ )}
271
+ </AnimatePresence>
272
+ </main>
273
+
274
+ {/* Footer */}
275
+ <footer className="border-t border-slate-100 mt-16">
276
+ <div className="max-w-6xl mx-auto px-6 py-6">
277
+ <p className="text-center text-sm text-slate-400">
278
+ Powered by AI • Export ready for Klenty, Outreach, and more
279
+ </p>
280
+ </div>
281
+ </footer>
282
+
283
+ {/* Custom Scrollbar Styles */}
284
+ <style>{`
285
+ .custom-scrollbar::-webkit-scrollbar {
286
+ width: 6px;
287
+ }
288
+ .custom-scrollbar::-webkit-scrollbar-track {
289
+ background: #f1f5f9;
290
+ border-radius: 3px;
291
+ }
292
+ .custom-scrollbar::-webkit-scrollbar-thumb {
293
+ background: #cbd5e1;
294
+ border-radius: 3px;
295
+ }
296
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
297
+ background: #94a3b8;
298
+ }
299
+ `}</style>
300
+ </div>
301
+ );
302
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
frontend/vite.config.js CHANGED
@@ -1,9 +1,15 @@
1
  import { defineConfig } from "vite";
2
  import react from "@vitejs/plugin-react";
 
3
 
4
  // IMPORTANT: proxy API calls to backend when running both inside one container
5
  export default defineConfig({
6
  plugins: [react()],
 
 
 
 
 
7
  server: {
8
  proxy: {
9
  "/api": "http://127.0.0.1:8000"
 
1
  import { defineConfig } from "vite";
2
  import react from "@vitejs/plugin-react";
3
+ import path from "path";
4
 
5
  // IMPORTANT: proxy API calls to backend when running both inside one container
6
  export default defineConfig({
7
  plugins: [react()],
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "./src"),
11
+ },
12
+ },
13
  server: {
14
  proxy: {
15
  "/api": "http://127.0.0.1:8000"