Mirror of github.com/Abhisingh18/HunterAI
Browse files- .gitignore +9 -0
- README.md +3 -0
- backend/hunter_backend.py +136 -0
- backend/requirements.txt +11 -0
- backend/services/__init__.py +0 -0
- backend/services/ai_engine.py +79 -0
- backend/services/email_sender.py +65 -0
- backend/services/excel_reader.py +18 -0
- backend/services/resume_parser.py +17 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +37 -0
- frontend/postcss.config.js +6 -0
- frontend/public/vite.svg +1 -0
- frontend/src/App.css +42 -0
- frontend/src/App.jsx +42 -0
- frontend/src/Dashboard.jsx +236 -0
- frontend/src/UploadForm.jsx +105 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/components/Layout.jsx +19 -0
- frontend/src/components/Sidebar.jsx +82 -0
- frontend/src/config.js +1 -0
- frontend/src/index.css +52 -0
- frontend/src/main.jsx +10 -0
- frontend/src/views/DashboardView.jsx +116 -0
- frontend/src/views/GeneratorView.jsx +141 -0
- frontend/src/views/UploadView.jsx +158 -0
- frontend/tailwind.config.js +11 -0
- frontend/vite.config.js +7 -0
- main.py +11 -0
- render.yaml +40 -0
- requirements.txt +1 -0
- test_companies.csv +4 -0
.gitignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
venv/
|
| 2 |
+
node_modules/
|
| 3 |
+
__pycache__/
|
| 4 |
+
.env
|
| 5 |
+
.DS_Store
|
| 6 |
+
uploads/
|
| 7 |
+
*.pdf
|
| 8 |
+
*.xlsx
|
| 9 |
+
backend/uploads/
|
README.md
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#Deployed link: https://hunterai-1.onrender.com
|
| 2 |
+
# HunterAI
|
| 3 |
+
An intelligent AI system that automates job and freelancing cold email outreach by leveraging resume parsing, company data analysis, and a multi-model LLM architecture. The system uses local LLMs (Ollama + Mistral) to generate highly personalized, professional emails with zero API dependency.
|
backend/hunter_backend.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, UploadFile, File, HTTPException
|
| 2 |
+
# Trigger reload
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import time
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
|
| 9 |
+
# Load env vars *before* importing services that might need them
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
from services.resume_parser import parse_resume
|
| 13 |
+
from services.excel_reader import read_company_excel
|
| 14 |
+
from services.ai_engine import generate_cold_email
|
| 15 |
+
from services.email_sender import send_email
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
from typing import List, Dict, Any, Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
app = FastAPI(title="Hunter AI Backend")
|
| 21 |
+
|
| 22 |
+
origins = [
|
| 23 |
+
"http://localhost:5173",
|
| 24 |
+
"http://localhost:3000",
|
| 25 |
+
os.getenv("FRONTEND_URL", "http://localhost:5173"), # Allow Render frontend
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
app.add_middleware(
|
| 29 |
+
CORSMiddleware,
|
| 30 |
+
allow_origins=origins,
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["*"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
UPLOAD_DIR = "uploads"
|
| 37 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
@app.get("/")
|
| 40 |
+
def read_root():
|
| 41 |
+
return "AI Outreach Backend is running 🚀"
|
| 42 |
+
|
| 43 |
+
@app.post("/upload")
|
| 44 |
+
async def upload_files(resume: UploadFile = File(...), company_excel: UploadFile = File(...)):
|
| 45 |
+
resume_path = os.path.join(UPLOAD_DIR, resume.filename)
|
| 46 |
+
excel_path = os.path.join(UPLOAD_DIR, company_excel.filename)
|
| 47 |
+
|
| 48 |
+
with open(resume_path, "wb") as buffer:
|
| 49 |
+
shutil.copyfileobj(resume.file, buffer)
|
| 50 |
+
|
| 51 |
+
with open(excel_path, "wb") as buffer:
|
| 52 |
+
shutil.copyfileobj(company_excel.file, buffer)
|
| 53 |
+
|
| 54 |
+
resume_text = parse_resume(resume_path)
|
| 55 |
+
companies = read_company_excel(excel_path)
|
| 56 |
+
|
| 57 |
+
return {
|
| 58 |
+
"status": "success",
|
| 59 |
+
"resume_filename": resume.filename,
|
| 60 |
+
"excel_filename": company_excel.filename,
|
| 61 |
+
"resume_preview": resume_text[:500] if resume_text else "No text extracted",
|
| 62 |
+
"companies_count": len(companies),
|
| 63 |
+
"first_company_example": companies[0] if companies else None
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
class GenerateRequest(BaseModel):
|
| 67 |
+
resume_filename: str
|
| 68 |
+
excel_filename: str
|
| 69 |
+
|
| 70 |
+
@app.post("/generate-emails")
|
| 71 |
+
async def generate_emails_endpoint(request: GenerateRequest):
|
| 72 |
+
resume_path = os.path.join(UPLOAD_DIR, request.resume_filename)
|
| 73 |
+
excel_path = os.path.join(UPLOAD_DIR, request.excel_filename)
|
| 74 |
+
|
| 75 |
+
if not os.path.exists(resume_path) or not os.path.exists(excel_path):
|
| 76 |
+
raise HTTPException(status_code=404, detail="Files not found")
|
| 77 |
+
|
| 78 |
+
resume_text = parse_resume(resume_path)
|
| 79 |
+
companies = read_company_excel(excel_path)
|
| 80 |
+
|
| 81 |
+
results = []
|
| 82 |
+
# Limit to first 5 for safety in MVP/Demo to avoid burning API quota or time
|
| 83 |
+
for company in companies[:5]:
|
| 84 |
+
email = generate_cold_email(resume_text, company)
|
| 85 |
+
results.append({
|
| 86 |
+
"company": company.get("Company Name", "Unknown"),
|
| 87 |
+
"email": email,
|
| 88 |
+
"hr_email": company.get("Email", "") # Ensure we have the target email
|
| 89 |
+
})
|
| 90 |
+
return {"emails": results}
|
| 91 |
+
|
| 92 |
+
class SendEmailRequest(BaseModel):
|
| 93 |
+
emails: List[Dict[str, str]] # List of { "to": "...", "subject": "...", "body": "..." }
|
| 94 |
+
smtp_email: str
|
| 95 |
+
smtp_password: str
|
| 96 |
+
resume_filename: Optional[str] = None
|
| 97 |
+
|
| 98 |
+
@app.post("/send-bulk-emails")
|
| 99 |
+
async def send_bulk_emails_endpoint(request: SendEmailRequest):
|
| 100 |
+
results = []
|
| 101 |
+
smtp_config = {"email": request.smtp_email, "password": request.smtp_password}
|
| 102 |
+
|
| 103 |
+
# Construct attachment path if provided
|
| 104 |
+
attachment_path = None
|
| 105 |
+
if request.resume_filename:
|
| 106 |
+
attachment_path = os.path.join(UPLOAD_DIR, request.resume_filename)
|
| 107 |
+
|
| 108 |
+
print(f"🚀 Starting bulk email send for {len(request.emails)} recipients...")
|
| 109 |
+
if attachment_path:
|
| 110 |
+
print(f"📎 Including attachment: {request.resume_filename}")
|
| 111 |
+
|
| 112 |
+
for index, item in enumerate(request.emails):
|
| 113 |
+
to_email = item.get("to")
|
| 114 |
+
subject = item.get("subject")
|
| 115 |
+
body = item.get("body")
|
| 116 |
+
|
| 117 |
+
if to_email and subject and body:
|
| 118 |
+
print(f"[{index+1}/{len(request.emails)}] Sending to {to_email}...")
|
| 119 |
+
|
| 120 |
+
# Pass attachment_path to send_email
|
| 121 |
+
success, message = send_email(to_email, subject, body, smtp_config, attachment_path)
|
| 122 |
+
|
| 123 |
+
results.append({"to": to_email, "status": "sent" if success else "failed", "error": message})
|
| 124 |
+
|
| 125 |
+
# Rate limiting: Sleep 2 seconds between emails to avoid spam filters
|
| 126 |
+
if index < len(request.emails) - 1:
|
| 127 |
+
time.sleep(2)
|
| 128 |
+
else:
|
| 129 |
+
results.append({"to": to_email, "status": "failed", "error": "Invalid data"})
|
| 130 |
+
|
| 131 |
+
print("✅ Bulk sending complete.")
|
| 132 |
+
return {"results": results}
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
import uvicorn
|
| 136 |
+
uvicorn.run(app, host="0.0.0.0", port=10000)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
python-multipart
|
| 4 |
+
pandas
|
| 5 |
+
openpyxl
|
| 6 |
+
huggingface_hub
|
| 7 |
+
pypdf
|
| 8 |
+
python-dotenv
|
| 9 |
+
email-validator
|
| 10 |
+
requests
|
| 11 |
+
openai
|
backend/services/__init__.py
ADDED
|
File without changes
|
backend/services/ai_engine.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from openai import OpenAI
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
def generate_cold_email(resume_text: str, company_info: dict, tone="Confident, polite, result-oriented"):
|
| 6 |
+
"""
|
| 7 |
+
Generates a cold email using OpenRouter API.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
# Fetch key dynamically to ensure it's loaded
|
| 11 |
+
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
|
| 12 |
+
|
| 13 |
+
# 1. Map Data
|
| 14 |
+
candidate_profile = resume_text[:4000] # Increased limit slightly
|
| 15 |
+
company_name = company_info.get('Company Name', 'Target Company')
|
| 16 |
+
role = company_info.get('Role', 'Employee')
|
| 17 |
+
tech_stack = company_info.get('Tech Stack', 'Industry standard technologies')
|
| 18 |
+
hr_name = company_info.get('HR Name', 'Hiring Manager')
|
| 19 |
+
|
| 20 |
+
# 2. Construct Master Prompt
|
| 21 |
+
# Enhanced prompt for better personalization
|
| 22 |
+
prompt = f"""
|
| 23 |
+
You are an expert Copywriter and AI Outreach Assistant.
|
| 24 |
+
|
| 25 |
+
OBJECTIVE:
|
| 26 |
+
Write a high-converting, hyper-personalized cold email to {hr_name} at {company_name} for the role of {role}.
|
| 27 |
+
|
| 28 |
+
CANDIDATE PROFILE (RESUME):
|
| 29 |
+
{candidate_profile}
|
| 30 |
+
|
| 31 |
+
TARGET COMPANY DETAILS:
|
| 32 |
+
Company: {company_name}
|
| 33 |
+
Role: {role}
|
| 34 |
+
Tech Stack: {tech_stack}
|
| 35 |
+
|
| 36 |
+
INSTRUCTIONS:
|
| 37 |
+
1. **Analyze the Match**: First, silently identify the *strongest* project or skill from the candidate's resume that DIRECTLY pertains to the company's tech stack ({tech_stack}).
|
| 38 |
+
2. **Hook**: Open with a strong, non-generic hook. Mention why you are interested in {company_name} or a specific achievement of theirs if known (or just general enthusiasm for their mission).
|
| 39 |
+
3. **The "Why Me" (Crucial)**: You MUST include 1-2 sentences explicitly connecting a specific project/skill from the resume to the {role} requirements. "For example, in my project [Project Name], I used [Tech] to achieve [Result], which aligns with your work in [Domain]."
|
| 40 |
+
4. **Tone**: Confident, professional, yet human. Avoid stiff corporate jargon.
|
| 41 |
+
5. **Structure**:
|
| 42 |
+
* **Subject**: Catchy & Relevant (e.g., "{role} Application - [Candidate Name] - [Key Skill] expert")
|
| 43 |
+
* **Salutation**: Dear {hr_name},
|
| 44 |
+
* **Body**: Hook -> "Why Me" (Specific Proof) -> Value Proposition.
|
| 45 |
+
* **Call to Action**: Clear request for a brief chat or interview. Mention attached resume.
|
| 46 |
+
* **Sign-off**: Best regards, [Candidate Name]
|
| 47 |
+
|
| 48 |
+
CONSTRAINTS:
|
| 49 |
+
* Keep it under 150 words.
|
| 50 |
+
* NO spelling errors.
|
| 51 |
+
* NO placeholders like [Insert Here] - use the data provided. If data is missing, generalize intelligently.
|
| 52 |
+
* OUTPUT ONLY the email content.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
return generate_email_via_ai(prompt, OPENROUTER_API_KEY)
|
| 56 |
+
|
| 57 |
+
def generate_email_via_ai(prompt: str, api_key: str) -> str:
|
| 58 |
+
if not api_key:
|
| 59 |
+
return "Error: OPENROUTER_API_KEY not found in environment variables. Please check your .env file."
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
client = OpenAI(
|
| 63 |
+
api_key=api_key,
|
| 64 |
+
base_url="https://openrouter.ai/api/v1"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
response = client.chat.completions.create(
|
| 68 |
+
model="openai/gpt-oss-20b",
|
| 69 |
+
messages=[
|
| 70 |
+
{"role": "system", "content": "You are a professional AI assistant."},
|
| 71 |
+
{"role": "user", "content": prompt}
|
| 72 |
+
],
|
| 73 |
+
temperature=0.5,
|
| 74 |
+
max_tokens=1000 # Increased slightly to ensure full email + reasoning found in some models
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
return response.choices[0].message.content
|
| 78 |
+
except Exception as e:
|
| 79 |
+
return f"Error connecting to OpenRouter: {str(e)}"
|
backend/services/email_sender.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import smtplib
|
| 2 |
+
from email.mime.text import MIMEText
|
| 3 |
+
from email.mime.multipart import MIMEMultipart
|
| 4 |
+
from email.mime.application import MIMEApplication
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
def send_email(to_email, subject, body, smtp_config=None, attachment_path=None):
|
| 8 |
+
# If smtp_config is not provided, try to use env vars (though user provided creds are better)
|
| 9 |
+
# If smtp_config is not provided, try to use env vars (though user provided creds are better)
|
| 10 |
+
# Fix: Ensure we don't use empty strings from config if they are empty
|
| 11 |
+
sender_email = (smtp_config and smtp_config.get("email")) or os.getenv("SMTP_EMAIL")
|
| 12 |
+
password = (smtp_config and smtp_config.get("password")) or os.getenv("SMTP_PASSWORD")
|
| 13 |
+
|
| 14 |
+
if not sender_email or not password:
|
| 15 |
+
return False, "Missing SMTP credentials"
|
| 16 |
+
|
| 17 |
+
msg = MIMEMultipart()
|
| 18 |
+
msg['From'] = sender_email
|
| 19 |
+
msg['To'] = to_email
|
| 20 |
+
msg['Subject'] = subject
|
| 21 |
+
|
| 22 |
+
# Attach body as HTML
|
| 23 |
+
# Converting newlines to <br> if it looks like plain text, or assuming incoming body has basic formatting
|
| 24 |
+
html_body = f"""
|
| 25 |
+
<html>
|
| 26 |
+
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
| 27 |
+
<div style="padding: 20px; border: 1px solid #eee; border-radius: 8px;">
|
| 28 |
+
{body.replace(chr(10), '<br>')}
|
| 29 |
+
</div>
|
| 30 |
+
<p style="font-size: 12px; color: #888; margin-top: 20px;">
|
| 31 |
+
Sent via Hunter AI Agent
|
| 32 |
+
</p>
|
| 33 |
+
</body>
|
| 34 |
+
</html>
|
| 35 |
+
"""
|
| 36 |
+
msg.attach(MIMEText(html_body, 'html'))
|
| 37 |
+
|
| 38 |
+
# Attach resume if provided
|
| 39 |
+
if attachment_path and os.path.exists(attachment_path):
|
| 40 |
+
try:
|
| 41 |
+
with open(attachment_path, "rb") as f:
|
| 42 |
+
part = MIMEApplication(
|
| 43 |
+
f.read(),
|
| 44 |
+
Name=os.path.basename(attachment_path)
|
| 45 |
+
)
|
| 46 |
+
# After the file is closed
|
| 47 |
+
part['Content-Disposition'] = f'attachment; filename="{os.path.basename(attachment_path)}"'
|
| 48 |
+
msg.attach(part)
|
| 49 |
+
print(f"📎 Attached file: {attachment_path}")
|
| 50 |
+
except Exception as e:
|
| 51 |
+
print(f"⚠️ Failed to attach file: {e}")
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
# Connect to Gmail SMTP server
|
| 55 |
+
server = smtplib.SMTP('smtp.gmail.com', 587)
|
| 56 |
+
server.starttls()
|
| 57 |
+
server.login(sender_email, password)
|
| 58 |
+
text = msg.as_string()
|
| 59 |
+
server.sendmail(sender_email, to_email, text)
|
| 60 |
+
server.quit()
|
| 61 |
+
print(f"✅ Email sent to {to_email}")
|
| 62 |
+
return True, "Email sent successfully"
|
| 63 |
+
except Exception as e:
|
| 64 |
+
print(f"❌ Failed to send to {to_email}: {e}")
|
| 65 |
+
return False, f"Failed to send email: {str(e)}"
|
backend/services/excel_reader.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
def read_company_excel(file_path: str):
|
| 4 |
+
try:
|
| 5 |
+
if file_path.endswith('.csv'):
|
| 6 |
+
df = pd.read_csv(file_path)
|
| 7 |
+
else:
|
| 8 |
+
df = pd.read_excel(file_path)
|
| 9 |
+
|
| 10 |
+
# Normalize headers to lowercase? Or expected exact match?
|
| 11 |
+
# User prompt: "Company Name | HR Name | Email | Role | Tech Stack | Type"
|
| 12 |
+
# Let's clean up nan values first
|
| 13 |
+
df = df.where(pd.notnull(df), None)
|
| 14 |
+
companies = df.to_dict(orient="records")
|
| 15 |
+
return companies
|
| 16 |
+
except Exception as e:
|
| 17 |
+
print(f"Error reading Excel/CSV: {e}")
|
| 18 |
+
return []
|
backend/services/resume_parser.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pypdf
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
def parse_resume(file_path: str):
|
| 5 |
+
text = ""
|
| 6 |
+
try:
|
| 7 |
+
reader = pypdf.PdfReader(file_path)
|
| 8 |
+
for page in reader.pages:
|
| 9 |
+
text += page.extract_text() + "\n"
|
| 10 |
+
|
| 11 |
+
# Basic cleaning
|
| 12 |
+
text = re.sub(r'\s+', ' ', text).strip()
|
| 13 |
+
|
| 14 |
+
except Exception as e:
|
| 15 |
+
print(f"Error reading PDF: {e}")
|
| 16 |
+
return str(e)
|
| 17 |
+
return text
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
frontend/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"@tailwindcss/postcss": "^4.1.18",
|
| 14 |
+
"axios": "^1.13.2",
|
| 15 |
+
"clsx": "^2.1.1",
|
| 16 |
+
"framer-motion": "^12.23.26",
|
| 17 |
+
"lucide-react": "^0.561.0",
|
| 18 |
+
"react": "^19.2.0",
|
| 19 |
+
"react-dom": "^19.2.0",
|
| 20 |
+
"recharts": "^3.5.1",
|
| 21 |
+
"tailwind-merge": "^3.4.0"
|
| 22 |
+
},
|
| 23 |
+
"devDependencies": {
|
| 24 |
+
"@eslint/js": "^9.39.1",
|
| 25 |
+
"@types/react": "^19.2.5",
|
| 26 |
+
"@types/react-dom": "^19.2.3",
|
| 27 |
+
"@vitejs/plugin-react": "^5.1.1",
|
| 28 |
+
"autoprefixer": "^10.4.22",
|
| 29 |
+
"eslint": "^9.39.1",
|
| 30 |
+
"eslint-plugin-react-hooks": "^7.0.1",
|
| 31 |
+
"eslint-plugin-react-refresh": "^0.4.24",
|
| 32 |
+
"globals": "^16.5.0",
|
| 33 |
+
"postcss": "^8.5.6",
|
| 34 |
+
"tailwindcss": "^4.1.18",
|
| 35 |
+
"vite": "^7.2.4"
|
| 36 |
+
}
|
| 37 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
'@tailwindcss/postcss': {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/vite.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#root {
|
| 2 |
+
max-width: 1280px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 2rem;
|
| 5 |
+
text-align: center;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.logo {
|
| 9 |
+
height: 6em;
|
| 10 |
+
padding: 1.5em;
|
| 11 |
+
will-change: filter;
|
| 12 |
+
transition: filter 300ms;
|
| 13 |
+
}
|
| 14 |
+
.logo:hover {
|
| 15 |
+
filter: drop-shadow(0 0 2em #646cffaa);
|
| 16 |
+
}
|
| 17 |
+
.logo.react:hover {
|
| 18 |
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
@keyframes logo-spin {
|
| 22 |
+
from {
|
| 23 |
+
transform: rotate(0deg);
|
| 24 |
+
}
|
| 25 |
+
to {
|
| 26 |
+
transform: rotate(360deg);
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 31 |
+
a:nth-of-type(2) .logo {
|
| 32 |
+
animation: logo-spin infinite 20s linear;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.card {
|
| 37 |
+
padding: 2em;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.read-the-docs {
|
| 41 |
+
color: #888;
|
| 42 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import Layout from './components/Layout';
|
| 3 |
+
import DashboardView from './views/DashboardView';
|
| 4 |
+
import UploadView from './views/UploadView';
|
| 5 |
+
import GeneratorView from './views/GeneratorView';
|
| 6 |
+
|
| 7 |
+
function App() {
|
| 8 |
+
const [activeTab, setActiveTab] = useState('dashboard');
|
| 9 |
+
const [uploadData, setUploadData] = useState(null);
|
| 10 |
+
|
| 11 |
+
const handleUploadComplete = (data) => {
|
| 12 |
+
setUploadData(data);
|
| 13 |
+
setActiveTab('generator');
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const renderView = () => {
|
| 17 |
+
switch (activeTab) {
|
| 18 |
+
case 'dashboard':
|
| 19 |
+
return <DashboardView setView={setActiveTab} />;
|
| 20 |
+
case 'upload':
|
| 21 |
+
return <UploadView onUploadComplete={handleUploadComplete} />;
|
| 22 |
+
case 'generator':
|
| 23 |
+
return <GeneratorView uploadData={uploadData} />;
|
| 24 |
+
case 'campaigns':
|
| 25 |
+
return <div className="text-slate-500 text-center py-20">Campaign Management Module Coming Soon</div>;
|
| 26 |
+
case 'analytics':
|
| 27 |
+
return <div className="text-slate-500 text-center py-20">Advanced Analytics Module Coming Soon</div>;
|
| 28 |
+
case 'settings':
|
| 29 |
+
return <div className="text-slate-500 text-center py-20">Settings Module Coming Soon</div>;
|
| 30 |
+
default:
|
| 31 |
+
return <DashboardView setView={setActiveTab} />;
|
| 32 |
+
}
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<Layout activeTab={activeTab} setActiveTab={setActiveTab}>
|
| 37 |
+
{renderView()}
|
| 38 |
+
</Layout>
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export default App;
|
frontend/src/Dashboard.jsx
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import UploadForm from './UploadForm';
|
| 3 |
+
import UploadForm from './UploadForm';
|
| 4 |
+
import axios from 'axios';
|
| 5 |
+
import { API_BASE_URL } from './config';
|
| 6 |
+
|
| 7 |
+
const Dashboard = () => {
|
| 8 |
+
const [step, setStep] = useState(1);
|
| 9 |
+
const [uploadData, setUploadData] = useState(null);
|
| 10 |
+
const [generatedEmails, setGeneratedEmails] = useState([]);
|
| 11 |
+
const [smtpEmail, setSmtpEmail] = useState("");
|
| 12 |
+
const [smtpPassword, setSmtpPassword] = useState("");
|
| 13 |
+
const [sendingResults, setSendingResults] = useState([]);
|
| 14 |
+
|
| 15 |
+
// Background Orbs for "Alive" feel
|
| 16 |
+
const BackgroundOrbs = () => (
|
| 17 |
+
<div className="fixed inset-0 overflow-hidden pointer-events-none -z-10">
|
| 18 |
+
<div className="absolute top-[-10%] left-[-10%] w-96 h-96 bg-purple-600/30 rounded-full blur-3xl float"></div>
|
| 19 |
+
<div className="absolute top-[20%] right-[-5%] w-72 h-72 bg-cyan-600/30 rounded-full blur-3xl float-delayed"></div>
|
| 20 |
+
<div className="absolute bottom-[-10%] left-[20%] w-80 h-80 bg-blue-600/30 rounded-full blur-3xl float"></div>
|
| 21 |
+
</div>
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
const handleUploadSuccess = (data) => {
|
| 25 |
+
setUploadData(data);
|
| 26 |
+
setStep(2);
|
| 27 |
+
generateEmails(data);
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
const generateEmails = async (data) => {
|
| 31 |
+
try {
|
| 32 |
+
const response = await axios.post(`${API_BASE_URL}/generate-emails`, {
|
| 33 |
+
resume_filename: data.resume_filename,
|
| 34 |
+
excel_filename: data.excel_filename
|
| 35 |
+
});
|
| 36 |
+
setGeneratedEmails(response.data.emails);
|
| 37 |
+
setStep(3);
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error(error);
|
| 40 |
+
alert("Error generating emails. Ensure Backend is active.");
|
| 41 |
+
setStep(1);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const handleSendEmails = async () => {
|
| 46 |
+
if (!smtpEmail || !smtpPassword) {
|
| 47 |
+
alert("Please enter SMTP Credentials");
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
const payload = {
|
| 53 |
+
emails: generatedEmails.map(item => ({
|
| 54 |
+
to: item.hr_email,
|
| 55 |
+
subject: item.email.includes("Subject:") ? item.email.split('\n').find(l => l.includes("Subject:")).replace("Subject:", "").trim() : "Application Inquiry",
|
| 56 |
+
body: item.email
|
| 57 |
+
})),
|
| 58 |
+
smtp_email: smtpEmail,
|
| 59 |
+
smtp_password: smtpPassword
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const response = await axios.post(`${API_BASE_URL}/send-bulk-emails`, payload);
|
| 63 |
+
setSendingResults(response.data.results);
|
| 64 |
+
setStep(4);
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error(error);
|
| 67 |
+
alert("Error sending emails");
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="relative min-h-screen w-full flex flex-col items-center justify-center p-6">
|
| 73 |
+
<BackgroundOrbs />
|
| 74 |
+
|
| 75 |
+
{/* Header */}
|
| 76 |
+
<header className="mb-10 text-center z-10 fade-in-up" style={{ animationDelay: '0.1s' }}>
|
| 77 |
+
<div className="inline-block p-1 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 mb-4">
|
| 78 |
+
<div className="bg-slate-900 rounded-full px-4 py-1">
|
| 79 |
+
<span className="text-sm font-bold bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-blue-400">
|
| 80 |
+
AI-POWERED RECRUITMENT AGENT
|
| 81 |
+
</span>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<h1 className="text-6xl font-extrabold text-white glow-text tracking-tight mb-2">
|
| 85 |
+
Hunter<span className="text-cyan-400">.ai</span>
|
| 86 |
+
</h1>
|
| 87 |
+
<p className="text-blue-200 text-lg max-w-lg mx-auto leading-relaxed">
|
| 88 |
+
Automate your job outreach with personalized, AI-crafted emails.
|
| 89 |
+
</p>
|
| 90 |
+
</header>
|
| 91 |
+
|
| 92 |
+
<main className="w-full max-w-6xl z-10">
|
| 93 |
+
{step === 1 && (
|
| 94 |
+
<div className="fade-in-up" style={{ animationDelay: '0.2s' }}>
|
| 95 |
+
<UploadForm onUploadSuccess={handleUploadSuccess} />
|
| 96 |
+
</div>
|
| 97 |
+
)}
|
| 98 |
+
|
| 99 |
+
{step === 2 && (
|
| 100 |
+
<div className="max-w-xl mx-auto glass-panel p-12 text-center fade-in-up">
|
| 101 |
+
<div className="relative w-24 h-24 mx-auto mb-8">
|
| 102 |
+
<div className="absolute inset-0 border-4 border-cyan-500/30 rounded-full animate-ping"></div>
|
| 103 |
+
<div className="absolute inset-0 border-4 border-t-cyan-400 rounded-full animate-spin"></div>
|
| 104 |
+
<div className="absolute inset-4 bg-cyan-500/20 rounded-full backdrop-blur-sm"></div>
|
| 105 |
+
</div>
|
| 106 |
+
<h2 className="text-3xl font-bold text-white mb-4">AI Brain at Work</h2>
|
| 107 |
+
<p className="text-blue-200 mb-8">Parsing resume, analyzing tech stack, and crafting perfect pitches...</p>
|
| 108 |
+
|
| 109 |
+
<div className="bg-black/20 rounded-full h-1.5 w-full overflow-hidden">
|
| 110 |
+
<div className="h-full bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-600 animate-[shimmer_1.5s_infinite] w-2/3"></div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
|
| 115 |
+
{step === 3 && (
|
| 116 |
+
<div className="fade-in-up">
|
| 117 |
+
<div className="flex justify-between items-end mb-8">
|
| 118 |
+
<div>
|
| 119 |
+
<h2 className="text-3xl font-bold text-white">Draft Reviews</h2>
|
| 120 |
+
<p className="text-blue-300">We've prepared {generatedEmails.length} personalized emails for you.</p>
|
| 121 |
+
</div>
|
| 122 |
+
<button className="text-sm text-cyan-400 hover:text-cyan-300 font-semibold underline underline-offset-4">
|
| 123 |
+
Regenerate All
|
| 124 |
+
</button>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
| 128 |
+
{generatedEmails.map((item, index) => (
|
| 129 |
+
<div key={index} className="glass-panel p-6 group hover:bg-white/15 transition-all duration-300 hover:-translate-y-2">
|
| 130 |
+
<div className="flex justify-between items-start mb-4">
|
| 131 |
+
<div className="p-2 bg-gradient-to-br from-cyan-500/20 to-blue-500/20 rounded-lg">
|
| 132 |
+
<span className="text-2xl">🏢</span>
|
| 133 |
+
</div>
|
| 134 |
+
<span className="text-xs font-bold px-2 py-1 rounded bg-white/10 text-cyan-300 border border-cyan-500/20">
|
| 135 |
+
MATCH: 92%
|
| 136 |
+
</span>
|
| 137 |
+
</div>
|
| 138 |
+
<h3 className="font-bold text-xl text-white mb-1 group-hover:text-cyan-300 transition-colors">{item.company}</h3>
|
| 139 |
+
<p className="text-sm text-gray-400 mb-4 font-mono truncate">{item.hr_email}</p>
|
| 140 |
+
|
| 141 |
+
<div className="bg-black/40 p-3 rounded-lg border border-white/5 h-40 overflow-hidden relative group-hover:border-cyan-500/30 transition-colors">
|
| 142 |
+
<div className="absolute bottom-0 left-0 w-full h-12 bg-gradient-to-t from-black/80 to-transparent pointer-events-none"></div>
|
| 143 |
+
<p className="text-xs text-gray-300 whitespace-pre-wrap leading-relaxed">{item.email}</p>
|
| 144 |
+
</div>
|
| 145 |
+
<button className="w-full mt-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-sm font-semibold text-white border border-white/10 transition-colors">
|
| 146 |
+
Edit / Preview
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
))}
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div className="glass-panel p-8 max-w-2xl mx-auto border-t-4 border-t-cyan-500">
|
| 153 |
+
<h3 className="text-2xl font-bold text-white mb-6 text-center">Ready to Launch Campaign? 🚀</h3>
|
| 154 |
+
|
| 155 |
+
<div className="grid grid-cols-1 gap-4 mb-6">
|
| 156 |
+
<input
|
| 157 |
+
type="email"
|
| 158 |
+
placeholder="Your Gmail Address"
|
| 159 |
+
value={smtpEmail}
|
| 160 |
+
onChange={(e) => setSmtpEmail(e.target.value)}
|
| 161 |
+
className="glass-input w-full"
|
| 162 |
+
/>
|
| 163 |
+
<input
|
| 164 |
+
type="password"
|
| 165 |
+
placeholder="Gmail App Password"
|
| 166 |
+
value={smtpPassword}
|
| 167 |
+
onChange={(e) => setSmtpPassword(e.target.value)}
|
| 168 |
+
className="glass-input w-full"
|
| 169 |
+
/>
|
| 170 |
+
</div>
|
| 171 |
+
<button
|
| 172 |
+
onClick={handleSendEmails}
|
| 173 |
+
className="w-full glass-btn text-lg"
|
| 174 |
+
>
|
| 175 |
+
Send {generatedEmails.length} Emails Now
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
|
| 181 |
+
{step === 4 && (
|
| 182 |
+
<div className="max-w-4xl mx-auto glass-panel p-10 fade-in-up">
|
| 183 |
+
<div className="text-center mb-10">
|
| 184 |
+
<div className="inline-block p-4 rounded-full bg-green-500/20 mb-4">
|
| 185 |
+
<span className="text-4xl">🎉</span>
|
| 186 |
+
</div>
|
| 187 |
+
<h2 className="text-4xl font-bold text-white mb-2">Campaign Completed!</h2>
|
| 188 |
+
<p className="text-gray-300">Here's how your outreach went.</p>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div className="overflow-hidden rounded-xl border border-white/10 bg-black/20">
|
| 192 |
+
<table className="w-full text-left">
|
| 193 |
+
<thead className="bg-white/5">
|
| 194 |
+
<tr>
|
| 195 |
+
<th className="p-4 text-sm font-semibold text-gray-400 uppercase tracking-wider">Recipient</th>
|
| 196 |
+
<th className="p-4 text-sm font-semibold text-gray-400 uppercase tracking-wider">Status</th>
|
| 197 |
+
<th className="p-4 text-sm font-semibold text-gray-400 uppercase tracking-wider">Details</th>
|
| 198 |
+
</tr>
|
| 199 |
+
</thead>
|
| 200 |
+
<tbody className="divide-y divide-white/10">
|
| 201 |
+
{sendingResults.map((res, index) => (
|
| 202 |
+
<tr key={index} className="hover:bg-white/5 transition-colors">
|
| 203 |
+
<td className="p-4 text-white font-medium">{res.to}</td>
|
| 204 |
+
<td className="p-4">
|
| 205 |
+
{res.status === 'sent' ? (
|
| 206 |
+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">
|
| 207 |
+
SENT
|
| 208 |
+
</span>
|
| 209 |
+
) : (
|
| 210 |
+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30">
|
| 211 |
+
FAILED
|
| 212 |
+
</span>
|
| 213 |
+
)}
|
| 214 |
+
</td>
|
| 215 |
+
<td className="p-4 text-sm text-gray-500">{res.error || "Delivered"}</td>
|
| 216 |
+
</tr>
|
| 217 |
+
))}
|
| 218 |
+
</tbody>
|
| 219 |
+
</table>
|
| 220 |
+
</div>
|
| 221 |
+
<div className="mt-10 text-center">
|
| 222 |
+
<button
|
| 223 |
+
onClick={() => window.location.reload()}
|
| 224 |
+
className="px-8 py-3 rounded-xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all border border-white/10 hover:border-cyan-500/50"
|
| 225 |
+
>
|
| 226 |
+
Start New Campaign
|
| 227 |
+
</button>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
)}
|
| 231 |
+
</main>
|
| 232 |
+
</div>
|
| 233 |
+
);
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
export default Dashboard;
|
frontend/src/UploadForm.jsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import axios from 'axios';
|
| 3 |
+
import { API_BASE_URL } from './config';
|
| 4 |
+
|
| 5 |
+
const UploadForm = ({ onUploadSuccess }) => {
|
| 6 |
+
const [resume, setResume] = useState(null);
|
| 7 |
+
const [excel, setExcel] = useState(null);
|
| 8 |
+
const [loading, setLoading] = useState(false);
|
| 9 |
+
const [status, setStatus] = useState("");
|
| 10 |
+
|
| 11 |
+
const handleUpload = async (e) => {
|
| 12 |
+
e.preventDefault();
|
| 13 |
+
if (!resume || !excel) {
|
| 14 |
+
alert("Please select both Resume and Excel file.");
|
| 15 |
+
return;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
setStatus("🚀 Initiating Launch Sequence...");
|
| 19 |
+
setLoading(true);
|
| 20 |
+
|
| 21 |
+
const formData = new FormData();
|
| 22 |
+
formData.append("resume", resume);
|
| 23 |
+
formData.append("company_excel", excel);
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const response = await axios.post(`${API_BASE_URL}/upload`, formData, {
|
| 27 |
+
headers: {
|
| 28 |
+
'Content-Type': 'multipart/form-data'
|
| 29 |
+
}
|
| 30 |
+
});
|
| 31 |
+
console.log(response.data);
|
| 32 |
+
setStatus("✨ Files Secure. Engaging AI Core...");
|
| 33 |
+
setTimeout(() => {
|
| 34 |
+
onUploadSuccess(response.data);
|
| 35 |
+
}, 1500);
|
| 36 |
+
} catch (error) {
|
| 37 |
+
console.error(error);
|
| 38 |
+
setStatus("❌ System Failure during upload.");
|
| 39 |
+
} finally {
|
| 40 |
+
setLoading(false);
|
| 41 |
+
}
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div className="glass-panel p-10 max-w-lg mx-auto transform transition-all hover:scale-[1.01] hover:shadow-[0_0_40px_rgba(6,182,212,0.15)] relative overflow-hidden group">
|
| 46 |
+
{/* Glossy shine effect */}
|
| 47 |
+
<div className="absolute top-0 left-[-100%] w-1/2 h-full bg-gradient-to-r from-transparent via-white/5 to-transparent skew-x-12 group-hover:animate-[shine_1.5s_ease-in-out]"></div>
|
| 48 |
+
|
| 49 |
+
<h2 className="text-2xl font-bold mb-6 text-center text-white">Initialize Outreach</h2>
|
| 50 |
+
|
| 51 |
+
<form onSubmit={handleUpload} className="space-y-6">
|
| 52 |
+
<div>
|
| 53 |
+
<label className="block text-sm font-semibold text-cyan-300 mb-2 uppercase tracking-wide">Resume (PDF)</label>
|
| 54 |
+
<div className={`relative border-2 border-dashed border-white/20 rounded-xl p-6 transition-all ${resume ? 'bg-cyan-500/10 border-cyan-500/50' : 'hover:bg-white/5 hover:border-cyan-400/50'}`}>
|
| 55 |
+
<input
|
| 56 |
+
type="file" accept=".pdf"
|
| 57 |
+
onChange={(e) => setResume(e.target.files[0])}
|
| 58 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 59 |
+
/>
|
| 60 |
+
<div className="text-center pointer-events-none">
|
| 61 |
+
<span className="text-2xl mb-2 block">{resume ? '📄' : '📤'}</span>
|
| 62 |
+
<p className="text-sm font-medium text-gray-300">{resume ? resume.name : "Drop PDF or Click to Browse"}</p>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<div>
|
| 68 |
+
<label className="block text-sm font-semibold text-purple-300 mb-2 uppercase tracking-wide">Company List (Excel)</label>
|
| 69 |
+
<div className={`relative border-2 border-dashed border-white/20 rounded-xl p-6 transition-all ${excel ? 'bg-purple-500/10 border-purple-500/50' : 'hover:bg-white/5 hover:border-purple-400/50'}`}>
|
| 70 |
+
<input
|
| 71 |
+
type="file" accept=".xlsx, .xls"
|
| 72 |
+
onChange={(e) => setExcel(e.target.files[0])}
|
| 73 |
+
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
| 74 |
+
/>
|
| 75 |
+
<div className="text-center pointer-events-none">
|
| 76 |
+
<span className="text-2xl mb-2 block">{excel ? '📊' : '📥'}</span>
|
| 77 |
+
<p className="text-sm font-medium text-gray-300">{excel ? excel.name : "Drop Excel or Click to Browse"}</p>
|
| 78 |
+
</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<button
|
| 83 |
+
type="submit"
|
| 84 |
+
disabled={loading}
|
| 85 |
+
className="w-full glass-btn mt-4 group"
|
| 86 |
+
>
|
| 87 |
+
{loading ? (
|
| 88 |
+
<span className="flex items-center justify-center gap-2">
|
| 89 |
+
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
| 90 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
| 91 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
| 92 |
+
</svg>
|
| 93 |
+
Processing...
|
| 94 |
+
</span>
|
| 95 |
+
) : (
|
| 96 |
+
"🚀 Start Processing"
|
| 97 |
+
)}
|
| 98 |
+
</button>
|
| 99 |
+
</form>
|
| 100 |
+
{status && <div className="mt-4 p-3 rounded-lg bg-black/30 text-center text-sm font-mono text-cyan-200 border border-cyan-500/20">{status}</div>}
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export default UploadForm;
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/components/Layout.jsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import Sidebar from './Sidebar';
|
| 3 |
+
|
| 4 |
+
const Layout = ({ children, activeTab, setActiveTab }) => {
|
| 5 |
+
return (
|
| 6 |
+
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans selection:bg-indigo-500/30">
|
| 7 |
+
<Sidebar activeTab={activeTab} setActiveTab={setActiveTab} />
|
| 8 |
+
|
| 9 |
+
<main className="pl-64 min-h-screen">
|
| 10 |
+
{/* Top Bar Place holder if needed, or integrated into pages */}
|
| 11 |
+
<div className="p-8 max-w-7xl mx-auto animate-in fade-in zoom-in duration-300">
|
| 12 |
+
{children}
|
| 13 |
+
</div>
|
| 14 |
+
</main>
|
| 15 |
+
</div>
|
| 16 |
+
);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
export default Layout;
|
frontend/src/components/Sidebar.jsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
LayoutDashboard,
|
| 4 |
+
UploadCloud,
|
| 5 |
+
Sparkles,
|
| 6 |
+
Send,
|
| 7 |
+
BarChart2,
|
| 8 |
+
Settings,
|
| 9 |
+
LogOut
|
| 10 |
+
} from 'lucide-react';
|
| 11 |
+
import { motion } from 'framer-motion';
|
| 12 |
+
|
| 13 |
+
const Sidebar = ({ activeTab, setActiveTab }) => {
|
| 14 |
+
const menuItems = [
|
| 15 |
+
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
| 16 |
+
{ id: 'upload', label: 'Upload Data', icon: UploadCloud },
|
| 17 |
+
{ id: 'generator', label: 'AI Generator', icon: Sparkles },
|
| 18 |
+
{ id: 'campaigns', label: 'Campaigns', icon: Send },
|
| 19 |
+
{ id: 'analytics', label: 'Analytics', icon: BarChart2 },
|
| 20 |
+
{ id: 'settings', label: 'Settings', icon: Settings },
|
| 21 |
+
];
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div className="w-64 h-screen bg-white border-r border-slate-200 flex flex-col fixed left-0 top-0 z-50">
|
| 25 |
+
{/* Brand */}
|
| 26 |
+
<div className="p-6">
|
| 27 |
+
<div className="flex items-center gap-2">
|
| 28 |
+
<div className="w-8 h-8 rounded-lg bg-indigo-600 flex items-center justify-center">
|
| 29 |
+
<Sparkles className="w-5 h-5 text-white" />
|
| 30 |
+
</div>
|
| 31 |
+
<span className="text-xl font-bold text-slate-900 tracking-tight">HunterAI</span>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
{/* Navigation */}
|
| 36 |
+
<nav className="flex-1 px-4 space-y-1 mt-6">
|
| 37 |
+
{menuItems.map((item) => {
|
| 38 |
+
const Icon = item.icon;
|
| 39 |
+
const isActive = activeTab === item.id;
|
| 40 |
+
|
| 41 |
+
return (
|
| 42 |
+
<motion.div
|
| 43 |
+
key={item.id}
|
| 44 |
+
onClick={() => setActiveTab(item.id)}
|
| 45 |
+
className={`nav-item group ${isActive ? 'active' : ''}`}
|
| 46 |
+
whileHover={{ x: 4 }}
|
| 47 |
+
whileTap={{ scale: 0.98 }}
|
| 48 |
+
>
|
| 49 |
+
<Icon className={`sidebar-icon ${isActive ? 'text-indigo-400' : ''}`} />
|
| 50 |
+
<span>{item.label}</span>
|
| 51 |
+
{isActive && (
|
| 52 |
+
<motion.div
|
| 53 |
+
layoutId="active-pill"
|
| 54 |
+
className="absolute left-0 w-1 h-8 bg-indigo-500 rounded-r-full"
|
| 55 |
+
initial={{ opacity: 0 }}
|
| 56 |
+
animate={{ opacity: 1 }}
|
| 57 |
+
exit={{ opacity: 0 }}
|
| 58 |
+
/>
|
| 59 |
+
)}
|
| 60 |
+
</motion.div>
|
| 61 |
+
);
|
| 62 |
+
})}
|
| 63 |
+
</nav>
|
| 64 |
+
|
| 65 |
+
{/* User Profile */}
|
| 66 |
+
<div className="p-4 border-t border-slate-200">
|
| 67 |
+
<div className="flex items-center gap-3 px-2 py-2 rounded-xl hover:bg-slate-100 cursor-pointer transition-colors">
|
| 68 |
+
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-xs font-semibold text-slate-600">
|
| 69 |
+
AS
|
| 70 |
+
</div>
|
| 71 |
+
<div className="flex-1">
|
| 72 |
+
<p className="text-sm font-medium text-slate-900">Abhishek Singh</p>
|
| 73 |
+
<p className="text-xs text-slate-500">Pro Plan</p>
|
| 74 |
+
</div>
|
| 75 |
+
<LogOut className="w-4 h-4 text-slate-400 hover:text-slate-600" />
|
| 76 |
+
</div>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
);
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
export default Sidebar;
|
frontend/src/config.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:10000";
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
@import "tailwindcss";
|
| 4 |
+
|
| 5 |
+
@layer theme {
|
| 6 |
+
:root {
|
| 7 |
+
--font-sans: 'Inter', sans-serif;
|
| 8 |
+
}
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
@layer base {
|
| 12 |
+
body {
|
| 13 |
+
@apply bg-slate-50 text-slate-900 font-sans antialiased;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.glass-panel {
|
| 18 |
+
@apply bg-white/60 backdrop-blur-xl border border-slate-200 shadow-xl rounded-2xl;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
.gradient-text {
|
| 22 |
+
@apply bg-clip-text text-transparent bg-gradient-to-r from-indigo-600 via-violet-600 to-blue-600;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.sidebar-icon {
|
| 26 |
+
@apply w-5 h-5 text-slate-400 group-hover:text-indigo-600 transition-colors;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.nav-item {
|
| 30 |
+
@apply flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-all cursor-pointer;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.nav-item.active {
|
| 34 |
+
@apply bg-indigo-50 text-indigo-600 border border-indigo-200;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/* Animations */
|
| 38 |
+
.fade-in {
|
| 39 |
+
animation: fadeIn 0.4s ease-out forwards;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
@keyframes fadeIn {
|
| 43 |
+
from {
|
| 44 |
+
opacity: 0;
|
| 45 |
+
transform: translateY(10px);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
to {
|
| 49 |
+
opacity: 1;
|
| 50 |
+
transform: translateY(0);
|
| 51 |
+
}
|
| 52 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/views/DashboardView.jsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import {
|
| 3 |
+
BarChart,
|
| 4 |
+
Bar,
|
| 5 |
+
XAxis,
|
| 6 |
+
YAxis,
|
| 7 |
+
CartesianGrid,
|
| 8 |
+
Tooltip,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
LineChart,
|
| 11 |
+
Line
|
| 12 |
+
} from 'recharts';
|
| 13 |
+
import { Mail, MousePointer, Users, Activity, Plus } from 'lucide-react';
|
| 14 |
+
import { motion } from 'framer-motion';
|
| 15 |
+
|
| 16 |
+
const data = [
|
| 17 |
+
{ name: 'Mon', emails: 400, replies: 240 },
|
| 18 |
+
{ name: 'Tue', emails: 300, replies: 139 },
|
| 19 |
+
{ name: 'Wed', emails: 200, replies: 980 },
|
| 20 |
+
{ name: 'Thu', emails: 278, replies: 390 },
|
| 21 |
+
{ name: 'Fri', emails: 189, replies: 480 },
|
| 22 |
+
{ name: 'Sat', emails: 239, replies: 380 },
|
| 23 |
+
{ name: 'Sun', emails: 349, replies: 430 },
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
const StatCard = ({ title, value, change, icon: Icon, color }) => (
|
| 27 |
+
<div className="bg-white p-6 flex flex-col justify-between h-40 rounded-2xl shadow-sm border border-slate-100 hover:shadow-md transition-shadow">
|
| 28 |
+
<div className="flex justify-between items-start">
|
| 29 |
+
<div className={`p-3 rounded-xl ${color} text-white shadow-md shadow-${color.replace('bg-', '')}/20`}>
|
| 30 |
+
<Icon className="w-6 h-6" />
|
| 31 |
+
</div>
|
| 32 |
+
<span className="text-xs font-semibold text-emerald-600 bg-emerald-50 px-2 py-1 rounded-full">{change}</span>
|
| 33 |
+
</div>
|
| 34 |
+
<div>
|
| 35 |
+
<h3 className="text-3xl font-bold text-slate-900 tracking-tight">{value}</h3>
|
| 36 |
+
<p className="text-sm text-slate-500 font-medium mt-1">{title}</p>
|
| 37 |
+
</div>
|
| 38 |
+
</div>
|
| 39 |
+
);
|
| 40 |
+
|
| 41 |
+
const DashboardView = ({ setView }) => {
|
| 42 |
+
return (
|
| 43 |
+
<div className="space-y-8">
|
| 44 |
+
{/* Hero Section */}
|
| 45 |
+
<div className="relative overflow-hidden rounded-3xl bg-gradient-to-r from-indigo-50 to-violet-50 border border-indigo-100 p-10 shadow-lg">
|
| 46 |
+
<div className="absolute top-0 right-0 w-96 h-96 bg-white/40 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none" />
|
| 47 |
+
<div className="relative z-10 max-w-2xl">
|
| 48 |
+
<motion.div
|
| 49 |
+
initial={{ opacity: 0, y: 10 }}
|
| 50 |
+
animate={{ opacity: 1, y: 0 }}
|
| 51 |
+
transition={{ duration: 0.5 }}
|
| 52 |
+
>
|
| 53 |
+
<h1 className="text-4xl font-bold text-slate-900 mb-4">
|
| 54 |
+
Automate your <span className="gradient-text">Outreach</span>
|
| 55 |
+
</h1>
|
| 56 |
+
<p className="text-slate-600 text-lg mb-8 leading-relaxed">
|
| 57 |
+
HunterAI creates hyper-personalized emails using your resume and target company data to 10x your response rate.
|
| 58 |
+
</p>
|
| 59 |
+
<button
|
| 60 |
+
onClick={() => setView('upload')}
|
| 61 |
+
className="bg-indigo-600 text-white px-6 py-3 rounded-xl font-semibold hover:bg-indigo-700 transition-colors flex items-center gap-2 shadow-lg shadow-indigo-200"
|
| 62 |
+
>
|
| 63 |
+
<Plus className="w-5 h-5" />
|
| 64 |
+
Create New Campaign
|
| 65 |
+
</button>
|
| 66 |
+
</motion.div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
{/* Stats Grid */}
|
| 71 |
+
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
| 72 |
+
<StatCard title="Total Emails Sent" value="12,450" change="+12%" icon={Mail} color="bg-indigo-500" />
|
| 73 |
+
<StatCard title="Response Rate" value="24.8%" change="+4.2%" icon={MousePointer} color="bg-violet-500" />
|
| 74 |
+
<StatCard title="Active Campaigns" value="3" change="Active" icon={Activity} color="bg-blue-500" />
|
| 75 |
+
<StatCard title="Jobs Matched" value="85" change="+18" icon={Users} color="bg-emerald-500" />
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
{/* Analytics Chart */}
|
| 79 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-96">
|
| 80 |
+
<div className="lg:col-span-2 glass-panel p-6 bg-white">
|
| 81 |
+
<h3 className="text-lg font-semibold text-slate-900 mb-6">Campaign Performance</h3>
|
| 82 |
+
<ResponsiveContainer width="100%" height="85%">
|
| 83 |
+
<BarChart data={data}>
|
| 84 |
+
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
| 85 |
+
<XAxis dataKey="name" stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
| 86 |
+
<YAxis stroke="#64748b" fontSize={12} tickLine={false} axisLine={false} />
|
| 87 |
+
<Tooltip
|
| 88 |
+
contentStyle={{ backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '8px', color: '#0f172a' }}
|
| 89 |
+
itemStyle={{ color: '#0f172a' }}
|
| 90 |
+
/>
|
| 91 |
+
<Bar dataKey="emails" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
| 92 |
+
<Bar dataKey="replies" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
| 93 |
+
</BarChart>
|
| 94 |
+
</ResponsiveContainer>
|
| 95 |
+
</div>
|
| 96 |
+
|
| 97 |
+
<div className="glass-panel p-6 bg-white">
|
| 98 |
+
<h3 className="text-lg font-semibold text-slate-900 mb-4">Recent Activity</h3>
|
| 99 |
+
<div className="space-y-4">
|
| 100 |
+
{[1, 2, 3, 4].map((_, i) => (
|
| 101 |
+
<div key={i} className="flex items-center gap-3 p-3 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer border border-transparent hover:border-slate-100">
|
| 102 |
+
<div className="w-2 h-2 rounded-full bg-indigo-500"></div>
|
| 103 |
+
<div className="flex-1">
|
| 104 |
+
<p className="text-sm text-slate-700">Campaign "Frontend Roles" sent</p>
|
| 105 |
+
<p className="text-xs text-slate-500">2 hours ago</p>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
))}
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
export default DashboardView;
|
frontend/src/views/GeneratorView.jsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { RefreshCw, Check, X, Send, Edit, Copy } from 'lucide-react';
|
| 3 |
+
import axios from 'axios';
|
| 4 |
+
import { API_BASE_URL } from '../config';
|
| 5 |
+
|
| 6 |
+
const GeneratorView = ({ uploadData }) => {
|
| 7 |
+
const [generatedEmails, setGeneratedEmails] = useState([]);
|
| 8 |
+
const [selectedEmailIndex, setSelectedEmailIndex] = useState(0);
|
| 9 |
+
const [loading, setLoading] = useState(false);
|
| 10 |
+
const [sending, setSending] = useState(false);
|
| 11 |
+
|
| 12 |
+
useEffect(() => {
|
| 13 |
+
if (uploadData && generatedEmails.length === 0) {
|
| 14 |
+
generateEmails();
|
| 15 |
+
}
|
| 16 |
+
}, [uploadData]);
|
| 17 |
+
|
| 18 |
+
const generateEmails = async () => {
|
| 19 |
+
setLoading(true);
|
| 20 |
+
try {
|
| 21 |
+
const res = await axios.post(`${API_BASE_URL}/generate-emails`, {
|
| 22 |
+
resume_filename: uploadData.resume_filename,
|
| 23 |
+
excel_filename: uploadData.excel_filename
|
| 24 |
+
});
|
| 25 |
+
setGeneratedEmails(res.data.emails);
|
| 26 |
+
} catch (e) {
|
| 27 |
+
console.error(e);
|
| 28 |
+
alert("Error generating emails");
|
| 29 |
+
} finally {
|
| 30 |
+
setLoading(false);
|
| 31 |
+
}
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const handleSendCurrent = async () => {
|
| 35 |
+
// Logic to send single email or push to queue
|
| 36 |
+
if (!uploadData.smtpEmail || !uploadData.smtpPassword) {
|
| 37 |
+
alert("Missing SMTP Credentials. Go back to Upload.");
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const current = generatedEmails[selectedEmailIndex];
|
| 42 |
+
setSending(true);
|
| 43 |
+
try {
|
| 44 |
+
const payload = {
|
| 45 |
+
emails: [{
|
| 46 |
+
to: current.hr_email,
|
| 47 |
+
subject: current.email.split('\n')[0].replace("Subject: ", ""), // Simple extraction
|
| 48 |
+
body: current.email
|
| 49 |
+
}],
|
| 50 |
+
smtp_email: uploadData.smtpEmail,
|
| 51 |
+
smtp_password: uploadData.smtpPassword,
|
| 52 |
+
resume_filename: uploadData.resume_filename // Pass filename for attachment
|
| 53 |
+
};
|
| 54 |
+
await axios.post(`${API_BASE_URL}/send-bulk-emails`, payload);
|
| 55 |
+
alert(`Email sent to ${current.company}`);
|
| 56 |
+
} catch (e) {
|
| 57 |
+
console.error(e);
|
| 58 |
+
alert("Sending failed.");
|
| 59 |
+
} finally {
|
| 60 |
+
setSending(false);
|
| 61 |
+
}
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
if (!uploadData) return (
|
| 65 |
+
<div className="flex items-center justify-center h-full text-slate-500">
|
| 66 |
+
No data loaded. Please start from Upload page.
|
| 67 |
+
</div>
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="h-[calc(100vh-100px)] flex gap-6">
|
| 72 |
+
{/* Left: List */}
|
| 73 |
+
<div className="w-1/3 glass-panel overflow-hidden flex flex-col bg-white">
|
| 74 |
+
<div className="p-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
|
| 75 |
+
<h3 className="font-semibold text-slate-900">Candidates ({generatedEmails.length})</h3>
|
| 76 |
+
<button
|
| 77 |
+
onClick={generateEmails}
|
| 78 |
+
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
| 79 |
+
title="Regenerate All"
|
| 80 |
+
>
|
| 81 |
+
<RefreshCw className={`w-4 h-4 text-slate-400 ${loading ? 'animate-spin' : ''}`} />
|
| 82 |
+
</button>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="overflow-y-auto flex-1 p-2 space-y-2">
|
| 86 |
+
{loading ? (
|
| 87 |
+
<div className="text-center p-8 text-slate-500">AI is brainstorming...</div>
|
| 88 |
+
) : generatedEmails.map((item, idx) => (
|
| 89 |
+
<div
|
| 90 |
+
key={idx}
|
| 91 |
+
onClick={() => setSelectedEmailIndex(idx)}
|
| 92 |
+
className={`p-4 rounded-xl cursor-pointer border transition-all ${selectedEmailIndex === idx ? 'bg-indigo-50 border-indigo-200' : 'bg-transparent border-transparent hover:bg-slate-50'}`}
|
| 93 |
+
>
|
| 94 |
+
<div className="flex justify-between items-center mb-1">
|
| 95 |
+
<h4 className={`font-medium ${selectedEmailIndex === idx ? 'text-indigo-700' : 'text-slate-700'}`}>{item.company}</h4>
|
| 96 |
+
<span className="text-xs text-slate-500 px-2 py-0.5 rounded-full bg-slate-100">95%</span>
|
| 97 |
+
</div>
|
| 98 |
+
<p className="text-xs text-slate-500 truncate">{item.hr_email}</p>
|
| 99 |
+
</div>
|
| 100 |
+
))}
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
{/* Right: Preview */}
|
| 105 |
+
<div className="flex-1 glass-panel flex flex-col overflow-hidden relative bg-white">
|
| 106 |
+
{generatedEmails.length > 0 && (
|
| 107 |
+
<>
|
| 108 |
+
<div className="p-6 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
|
| 109 |
+
<div>
|
| 110 |
+
<p className="text-sm text-slate-500 mb-1">Subject</p>
|
| 111 |
+
<h2 className="text-lg font-medium text-slate-900 truncate max-w-lg">
|
| 112 |
+
{generatedEmails[selectedEmailIndex].email.split('\n')[0].replace("Subject: ", "")}
|
| 113 |
+
</h2>
|
| 114 |
+
</div>
|
| 115 |
+
<div className="flex gap-2">
|
| 116 |
+
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-sm font-medium transition-colors">
|
| 117 |
+
<Edit className="w-4 h-4" /> Edit
|
| 118 |
+
</button>
|
| 119 |
+
<button
|
| 120 |
+
onClick={handleSendCurrent}
|
| 121 |
+
disabled={sending}
|
| 122 |
+
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors shadow-lg shadow-indigo-500/20"
|
| 123 |
+
>
|
| 124 |
+
<Send className="w-4 h-4" /> {sending ? 'Sending...' : 'Send Now'}
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div className="flex-1 p-8 overflow-y-auto bg-slate-100/50">
|
| 130 |
+
<div className="max-w-3xl mx-auto bg-white text-slate-900 p-8 rounded-lg shadow-sm border border-slate-200 min-h-full font-serif loading-relaxed leading-7 whitespace-pre-wrap">
|
| 131 |
+
{generatedEmails[selectedEmailIndex].email}
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
</>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
export default GeneratorView;
|
frontend/src/views/UploadView.jsx
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { UploadCloud, FileText, Settings, ArrowRight, CheckCircle2 } from 'lucide-react';
|
| 3 |
+
import { motion } from 'framer-motion';
|
| 4 |
+
import axios from 'axios';
|
| 5 |
+
import { API_BASE_URL } from '../config';
|
| 6 |
+
|
| 7 |
+
const UploadView = ({ onUploadComplete }) => {
|
| 8 |
+
const [resume, setResume] = useState(null);
|
| 9 |
+
const [excel, setExcel] = useState(null);
|
| 10 |
+
const [smtpEmail, setSmtpEmail] = useState("");
|
| 11 |
+
const [smtpPassword, setSmtpPassword] = useState("");
|
| 12 |
+
const [loading, setLoading] = useState(false);
|
| 13 |
+
|
| 14 |
+
const handleUpload = async () => {
|
| 15 |
+
if (!resume || !excel) {
|
| 16 |
+
alert("Please provide both Resume and Excel files.");
|
| 17 |
+
return;
|
| 18 |
+
}
|
| 19 |
+
setLoading(true);
|
| 20 |
+
const formData = new FormData();
|
| 21 |
+
formData.append("resume", resume);
|
| 22 |
+
formData.append("company_excel", excel);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const res = await axios.post(`${API_BASE_URL}/upload`, formData);
|
| 26 |
+
// Pass minimal data needed for generator + generic SMTP creds storage (in state)
|
| 27 |
+
onUploadComplete({
|
| 28 |
+
...res.data,
|
| 29 |
+
smtpEmail,
|
| 30 |
+
smtpPassword
|
| 31 |
+
});
|
| 32 |
+
} catch (err) {
|
| 33 |
+
console.error(err);
|
| 34 |
+
alert("Upload failed.");
|
| 35 |
+
} finally {
|
| 36 |
+
setLoading(false);
|
| 37 |
+
}
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
return (
|
| 41 |
+
<div className="max-w-5xl mx-auto py-8">
|
| 42 |
+
<div className="mb-12">
|
| 43 |
+
<h2 className="text-3xl font-bold text-slate-900 mb-2">Configure Campaign</h2>
|
| 44 |
+
<p className="text-slate-500">Upload your data sources to initialize the AI agent.</p>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
| 48 |
+
{/* Card 1: Company List */}
|
| 49 |
+
<div className="glass-panel p-6 hover:bg-white transition-colors group bg-white">
|
| 50 |
+
<div className="flex items-center gap-3 mb-6">
|
| 51 |
+
<div className="p-2 bg-emerald-50 rounded-lg text-emerald-600 group-hover:bg-emerald-100 transition-colors">
|
| 52 |
+
<UploadCloud className="w-6 h-6" />
|
| 53 |
+
</div>
|
| 54 |
+
<h3 className="font-semibold text-slate-900">Company Data</h3>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer ${excel ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200 hover:border-slate-400'}`}>
|
| 58 |
+
<input
|
| 59 |
+
type="file"
|
| 60 |
+
accept=".xlsx, .xls, .csv"
|
| 61 |
+
onChange={(e) => setExcel(e.target.files[0])}
|
| 62 |
+
className="hidden"
|
| 63 |
+
id="excel-upload"
|
| 64 |
+
/>
|
| 65 |
+
<label htmlFor="excel-upload" className="cursor-pointer block h-full">
|
| 66 |
+
{excel ? (
|
| 67 |
+
<div className="text-emerald-400 flex flex-col items-center">
|
| 68 |
+
<CheckCircle2 className="w-8 h-8 mb-2" />
|
| 69 |
+
<span className="text-sm font-medium truncate max-w-full">{excel.name}</span>
|
| 70 |
+
</div>
|
| 71 |
+
) : (
|
| 72 |
+
<span className="text-sm text-slate-500">
|
| 73 |
+
Drag & drop Excel or CSV<br /><span className="text-xs opacity-70">or click to browse</span>
|
| 74 |
+
</span>
|
| 75 |
+
)}
|
| 76 |
+
</label>
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{/* Card 2: Resume */}
|
| 81 |
+
<div className="glass-panel p-6 hover:bg-white transition-colors group bg-white">
|
| 82 |
+
<div className="flex items-center gap-3 mb-6">
|
| 83 |
+
<div className="p-2 bg-blue-50 rounded-lg text-blue-600 group-hover:bg-blue-100 transition-colors">
|
| 84 |
+
<FileText className="w-6 h-6" />
|
| 85 |
+
</div>
|
| 86 |
+
<h3 className="font-semibold text-slate-900">Your Resume</h3>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
<div className={`border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer ${resume ? 'border-blue-500 bg-blue-50' : 'border-slate-200 hover:border-slate-400'}`}>
|
| 90 |
+
<input
|
| 91 |
+
type="file"
|
| 92 |
+
accept=".pdf"
|
| 93 |
+
onChange={(e) => setResume(e.target.files[0])}
|
| 94 |
+
className="hidden"
|
| 95 |
+
id="resume-upload"
|
| 96 |
+
/>
|
| 97 |
+
<label htmlFor="resume-upload" className="cursor-pointer block h-full">
|
| 98 |
+
{resume ? (
|
| 99 |
+
<div className="text-blue-400 flex flex-col items-center">
|
| 100 |
+
<CheckCircle2 className="w-8 h-8 mb-2" />
|
| 101 |
+
<span className="text-sm font-medium truncate max-w-full">{resume.name}</span>
|
| 102 |
+
</div>
|
| 103 |
+
) : (
|
| 104 |
+
<span className="text-sm text-slate-500">
|
| 105 |
+
Drag & drop PDF<br /><span className="text-xs opacity-70">or click to browse</span>
|
| 106 |
+
</span>
|
| 107 |
+
)}
|
| 108 |
+
</label>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Card 3: Email Config */}
|
| 113 |
+
<div className="glass-panel p-6 hover:bg-white transition-colors group bg-white">
|
| 114 |
+
<div className="flex items-center gap-3 mb-6">
|
| 115 |
+
<div className="p-2 bg-purple-50 rounded-lg text-purple-600 group-hover:bg-purple-100 transition-colors">
|
| 116 |
+
<Settings className="w-6 h-6" />
|
| 117 |
+
</div>
|
| 118 |
+
<h3 className="font-semibold text-slate-900">Sender Config</h3>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div className="space-y-4">
|
| 122 |
+
<input
|
| 123 |
+
type="email"
|
| 124 |
+
placeholder="Gmail Address"
|
| 125 |
+
value={smtpEmail}
|
| 126 |
+
onChange={(e) => setSmtpEmail(e.target.value)}
|
| 127 |
+
className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-900 focus:outline-none focus:border-indigo-500"
|
| 128 |
+
/>
|
| 129 |
+
<input
|
| 130 |
+
type="password"
|
| 131 |
+
placeholder="App Password"
|
| 132 |
+
value={smtpPassword}
|
| 133 |
+
onChange={(e) => setSmtpPassword(e.target.value)}
|
| 134 |
+
className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-900 focus:outline-none focus:border-indigo-500"
|
| 135 |
+
/>
|
| 136 |
+
<div className="flex items-center gap-2">
|
| 137 |
+
<div className="w-2 h-2 rounded-full bg-slate-600"></div>
|
| 138 |
+
<span className="text-xs text-slate-500">SMTP Disconnected</span>
|
| 139 |
+
</div>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
<div className="flex justify-end">
|
| 145 |
+
<button
|
| 146 |
+
onClick={handleUpload}
|
| 147 |
+
disabled={loading}
|
| 148 |
+
className="group relative inline-flex items-center gap-2 px-8 py-4 bg-indigo-600 text-white rounded-xl font-bold text-lg hover:bg-indigo-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
|
| 149 |
+
>
|
| 150 |
+
{loading ? 'Processing...' : 'Analyze & Generate'}
|
| 151 |
+
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
);
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
export default UploadView;
|
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
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|
main.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import uvicorn
|
| 4 |
+
|
| 5 |
+
# Fix path to allow imports from backend folder
|
| 6 |
+
sys.path.append(os.path.join(os.path.dirname(__file__), "backend"))
|
| 7 |
+
|
| 8 |
+
from hunter_backend import app
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
uvicorn.run(app, host="0.0.0.0", port=10000)
|
render.yaml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
# Backend Service
|
| 3 |
+
- type: web
|
| 4 |
+
name: hunter-ai-backend
|
| 5 |
+
env: python
|
| 6 |
+
region: singapore
|
| 7 |
+
plan: free
|
| 8 |
+
buildCommand: pip install -r requirements.txt
|
| 9 |
+
startCommand: python main.py
|
| 10 |
+
rootDirectory: .
|
| 11 |
+
envVars:
|
| 12 |
+
- key: PYTHON_VERSION
|
| 13 |
+
value: 3.10.0
|
| 14 |
+
- key: FRONTEND_URL
|
| 15 |
+
fromService:
|
| 16 |
+
type: web
|
| 17 |
+
name: hunter-ai-frontend
|
| 18 |
+
property: url
|
| 19 |
+
- key: OLLAMA_URL
|
| 20 |
+
value: http://localhost:11434 # Placeholder, user must update this in Render dashboard
|
| 21 |
+
- key: PORT
|
| 22 |
+
value: 10000
|
| 23 |
+
- key: GROQ_API_KEY
|
| 24 |
+
sync: false
|
| 25 |
+
|
| 26 |
+
# Frontend Service
|
| 27 |
+
- type: web
|
| 28 |
+
name: hunter-ai-frontend
|
| 29 |
+
env: static
|
| 30 |
+
region: singapore
|
| 31 |
+
plan: free
|
| 32 |
+
buildCommand: npm install && npm run build
|
| 33 |
+
staticPublishPath: ./dist
|
| 34 |
+
rootDirectory: frontend
|
| 35 |
+
envVars:
|
| 36 |
+
- key: VITE_API_URL
|
| 37 |
+
fromService:
|
| 38 |
+
type: web
|
| 39 |
+
name: hunter-ai-backend
|
| 40 |
+
property: url
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
-r backend/requirements.txt
|
test_companies.csv
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Company Name,HR Name,Email,Role,Tech Stack,Type
|
| 2 |
+
Google,Jane Doe,jane@google.com,Software Engineer,Python,Product
|
| 3 |
+
Facebook,John Start,john@facebook.com,Product Manager,React,Product
|
| 4 |
+
Amazon,Alice Stone,alice@amazon.com,Data Scientist,SQL,Service
|