Seth commited on
Commit ·
5301ae9
1
Parent(s): b74f084
update
Browse files- Dockerfile +3 -0
- README.md +39 -0
- SETUP.md +108 -0
- backend/app/database.py +65 -0
- backend/app/gpt_service.py +109 -0
- backend/app/main.py +234 -2
- backend/app/models.py +27 -0
- backend/requirements.txt +5 -0
- frontend/package.json +10 -2
- frontend/postcss.config.js +6 -0
- frontend/src/App.jsx +4 -20
- frontend/src/components/UserNotRegisteredError.jsx +31 -0
- frontend/src/components/products/ProductSelector.jsx +184 -0
- frontend/src/components/prompts/PromptEditor.jsx +268 -0
- frontend/src/components/sequences/SequenceCard.jsx +107 -0
- frontend/src/components/sequences/SequenceViewer.jsx +188 -0
- frontend/src/components/ui/badge.jsx +26 -0
- frontend/src/components/ui/button.jsx +36 -0
- frontend/src/components/ui/input.jsx +19 -0
- frontend/src/components/ui/progress.jsx +23 -0
- frontend/src/components/ui/select.jsx +95 -0
- frontend/src/components/ui/tabs.jsx +74 -0
- frontend/src/components/ui/textarea.jsx +18 -0
- frontend/src/components/upload/UploadStep.jsx +159 -0
- frontend/src/index.css +37 -0
- frontend/src/lib/utils.js +6 -0
- frontend/src/main.jsx +1 -0
- frontend/src/pages/EmailSequenceGenerator.jsx +302 -0
- frontend/tailwind.config.js +11 -0
- frontend/vite.config.js +6 -0
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
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export default function App() {
|
| 4 |
-
|
| 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"
|