Initial commit: AutoJobs MVP - AI job application platform
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
*.db
|
||||
.next/
|
||||
out/
|
||||
@@ -0,0 +1,60 @@
|
||||
# AutoJobs - AI-Powered Job Application Platform
|
||||
|
||||
**Live at:** https://hostpioneers.com/autojobs
|
||||
|
||||
## What is this?
|
||||
|
||||
AutoJobs is a multi-user SaaS platform that automates job searching and applications using AI.
|
||||
|
||||
- Users add their own API keys (Jooble, JSearch, Perplexity)
|
||||
- AI searches for jobs matching their profile
|
||||
- AI customizes resumes and writes cover letters per job
|
||||
- Application tracker with status management
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** Next.js 14 (App Router), TypeScript, Tailwind CSS
|
||||
- **Backend:** FastAPI (Python), SQLite
|
||||
- **Deployment:** PM2, Nginx reverse proxy
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python start_server.py
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Deployment (on Horus VPS)
|
||||
|
||||
```bash
|
||||
# API (port 8000)
|
||||
pm2 start backend/ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
# Frontend (port 3010)
|
||||
cd frontend && nohup npx next start -p 3010 &
|
||||
|
||||
# Nginx config at /etc/nginx/sites-enabled/hostpioneers.com
|
||||
# Routes: /autojobs -> :3010, /autojobs/api -> :8000
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `GET /autojobs/api/health` - Health check
|
||||
- `POST /autojobs/api/users` - Sign up
|
||||
- `POST /autojobs/api/users/login` - Login
|
||||
- `GET /autojobs/api/users/me` - Current user
|
||||
- `POST/GET /autojobs/api/users/me/api-keys` - API keys
|
||||
- `POST/GET /autojobs/api/users/me/profile` - Profile
|
||||
- `POST /autojobs/api/jobs/search` - Search jobs
|
||||
- `POST /autojobs/api/ai/customize` - AI customize
|
||||
- `GET/POST/PATCH /autojobs/api/applications` - Applications
|
||||
- `GET /autojobs/api/admin/stats` - Admin stats
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'autojobs-api',
|
||||
script: '/var/www/autojobs/backend/start_server.py',
|
||||
interpreter: 'python3',
|
||||
env: {
|
||||
DB_PATH: '/var/www/autojobs/autojobs.db'
|
||||
},
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M'
|
||||
}]
|
||||
};
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
"""
|
||||
AutoJobs API — FastAPI Backend
|
||||
Multi-user job search with per-user API keys
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.routing import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
DB_PATH = os.getenv("DB_PATH", "/var/www/autojobs/autojobs.db")
|
||||
|
||||
# Router with /autojobs/api prefix
|
||||
router = APIRouter(prefix="/autojobs/api")
|
||||
|
||||
# --- Database ---
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
api_keys TEXT DEFAULT '{}',
|
||||
profile TEXT DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
job_title TEXT,
|
||||
company TEXT,
|
||||
location TEXT,
|
||||
job_url TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
resume_version TEXT,
|
||||
cover_letter TEXT,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
source TEXT,
|
||||
jobs_found INTEGER,
|
||||
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="AutoJobs API", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# --- Simple Auth (API Key in header) ---
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing auth")
|
||||
# In production: validate JWT/Session
|
||||
# For MVP: user_id stored in Authorization header
|
||||
return {"id": credentials.credentials, "email": "user@autojobs"}
|
||||
|
||||
# --- Models ---
|
||||
class UserProfile(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
title: str
|
||||
summary: str
|
||||
skills: List[str]
|
||||
experience: List[dict]
|
||||
education: str
|
||||
linkedin_url: str = ""
|
||||
job_keywords: List[str]
|
||||
job_locations: List[str]
|
||||
|
||||
class APIKeys(BaseModel):
|
||||
jooble: str = ""
|
||||
jsearch: str = ""
|
||||
perplexity: str = ""
|
||||
|
||||
class JobSearchRequest(BaseModel):
|
||||
query: str
|
||||
location: str = ""
|
||||
page: int = 1
|
||||
|
||||
class ApplicationSubmit(BaseModel):
|
||||
job: dict
|
||||
resume: str
|
||||
cover_letter: str
|
||||
status: str = "applied"
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.get("/")
|
||||
def root():
|
||||
return {"status": "ok", "service": "AutoJobs API", "version": "1.0.0"}
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
# --- User Routes ---
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(user_id: str, email: str, name: str = ""):
|
||||
"""Create or update user"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
INSERT INTO users (id, email, name) VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET email=excluded.email, name=excluded.name
|
||||
""", (user_id, email, name))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok", "user_id": user_id}
|
||||
|
||||
@router.get("/users/me")
|
||||
def get_me(user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT * FROM users WHERE id = ?", (user["id"],))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return dict(row)
|
||||
|
||||
@router.post("/users/me/api-keys")
|
||||
def save_api_keys(keys: APIKeys, user=Depends(get_current_user)):
|
||||
"""Save user's API keys"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE users SET api_keys = ? WHERE id = ?",
|
||||
(json.dumps(keys.model_dump()), user["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/users/me/api-keys")
|
||||
def get_api_keys(user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return {}
|
||||
return json.loads(row["api_keys"] or "{}")
|
||||
|
||||
@router.post("/users/me/profile")
|
||||
def save_profile(profile: UserProfile, user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE users SET profile = ? WHERE id = ?",
|
||||
(json.dumps(profile.model_dump()), user["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
@router.get("/users/me/profile")
|
||||
def get_profile(user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT profile FROM users WHERE id = ?", (user["id"],))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
if not row or not row["profile"]:
|
||||
return {}
|
||||
return json.loads(row["profile"])
|
||||
|
||||
# --- Job Search ---
|
||||
|
||||
@router.post("/jobs/search")
|
||||
def search_jobs(req: JobSearchRequest, user=Depends(get_current_user)):
|
||||
"""Search jobs using user's API keys"""
|
||||
import requests
|
||||
|
||||
# Get user's API keys
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
api_keys = json.loads(row["api_keys"] or "{}") if row else {}
|
||||
jooble_key = api_keys.get("jooble", "")
|
||||
jsearch_key = api_keys.get("jsearch", "")
|
||||
|
||||
all_jobs = []
|
||||
|
||||
# Jooble search
|
||||
if jooble_key:
|
||||
try:
|
||||
resp = requests.post(
|
||||
"https://jooble.com/api/47cfflj0c63a481b01320db70de49da",
|
||||
json={"keywords": req.query, "location": req.location, "page": req.page},
|
||||
timeout=10
|
||||
)
|
||||
for j in resp.json().get("jobs", []):
|
||||
all_jobs.append({
|
||||
"source": "jooble",
|
||||
"title": j.get("title", ""),
|
||||
"company": j.get("company", ""),
|
||||
"location": j.get("location", ""),
|
||||
"description": j.get("snippet", ""),
|
||||
"url": j.get("link", ""),
|
||||
"salary": j.get("salary", "")
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Jooble error: {e}")
|
||||
|
||||
# JSearch (RapidAPI)
|
||||
if jsearch_key:
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://jsearch2.p.rapidapi.com/search",
|
||||
params={"query": f"{req.query} {req.location}".strip(), "page": req.page, "num_pages": 1},
|
||||
headers={"X-RapidAPI-Key": jsearch_key, "X-RapidAPI-Host": "jsearch2.p.rapidapi.com"},
|
||||
timeout=10
|
||||
)
|
||||
for j in resp.json().get("data", []):
|
||||
all_jobs.append({
|
||||
"source": "jsearch",
|
||||
"title": j.get("job_title", ""),
|
||||
"company": j.get("employer_name", ""),
|
||||
"location": f"{j.get('job_city', '')}, {j.get('job_country', '')}",
|
||||
"description": j.get("job_description", ""),
|
||||
"url": j.get("job_apply_link", j.get("job_google_link", "")),
|
||||
"salary": f"{j.get('job_salary_currency', '')} {j.get('job_salary', '')}"
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"JSearch error: {e}")
|
||||
|
||||
# Log usage
|
||||
if all_jobs:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"INSERT INTO api_usage (user_id, source, jobs_found) VALUES (?, ?, ?)",
|
||||
(user["id"], "jooble+jsearch", len(all_jobs))
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Deduplicate
|
||||
seen = set()
|
||||
unique = []
|
||||
for j in all_jobs:
|
||||
if j["url"] not in seen:
|
||||
seen.add(j["url"])
|
||||
unique.append(j)
|
||||
|
||||
return {"jobs": unique, "count": len(unique)}
|
||||
|
||||
# --- AI Customization ---
|
||||
|
||||
@router.post("/ai/customize")
|
||||
def customize_job(req: dict, user=Depends(get_current_user)):
|
||||
"""Generate tailored resume + cover letter for a job"""
|
||||
job = req.get("job", {})
|
||||
user_profile = req.get("profile", {})
|
||||
|
||||
# Get user's AI API key
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT api_keys FROM users WHERE id = ?", (user["id"],))
|
||||
row = c.fetchone()
|
||||
conn.close()
|
||||
|
||||
api_keys = json.loads(row["api_keys"] or "{}") if row else {}
|
||||
perplexity_key = api_keys.get("perplexity", "")
|
||||
|
||||
# Build company research prompt
|
||||
company_name = job.get("company", "")
|
||||
job_title = job.get("title", "")
|
||||
job_desc = job.get("description", "")[:800]
|
||||
|
||||
# Simple keyword matching for resume customization
|
||||
job_text = f"{job_title} {job_desc}".lower()
|
||||
skills = user_profile.get("skills", [])
|
||||
|
||||
# Match relevant skills
|
||||
relevant_skills = [s for s in skills if s.lower() in job_text]
|
||||
|
||||
# Build tailored resume
|
||||
experience = user_profile.get("experience", [])
|
||||
tailored_resume = f"""# {user_profile.get('title', 'Professional')}
|
||||
|
||||
## Summary
|
||||
{user_profile.get('summary', '')}
|
||||
|
||||
## Key Skills (matched to job requirements)
|
||||
{', '.join(relevant_skills) if relevant_skills else ', '.join(skills[:8])}
|
||||
|
||||
## Experience
|
||||
"""
|
||||
for exp in experience[:3]:
|
||||
tailored_resume += f"\n### {exp.get('title', '')} — {exp.get('company', '')}\n"
|
||||
for bullet in exp.get("bullets", [])[:4]:
|
||||
tailored_resume += f"• {bullet}\n"
|
||||
|
||||
tailored_resume += f"\n## Education\n{user_profile.get('education', '')}"
|
||||
|
||||
# Generate cover letter prompt (actual AI call would go here with Perplexity/MiniMax)
|
||||
cover_letter = f"""Dear Hiring Manager,
|
||||
|
||||
I am excited to apply for the {job_title} position at {company_name}.
|
||||
|
||||
I bring {user_profile.get('summary', 'a strong background in software development and AI')} with key skills in {', '.join(skills[:5])}.
|
||||
|
||||
{"I am particularly drawn to " + company_name + " because of their innovative approach" if company_name else "I am passionate about contributing to a forward-thinking team"}.
|
||||
|
||||
I would welcome the opportunity to discuss how my background aligns with your needs.
|
||||
|
||||
Best regards,
|
||||
{user_profile.get('name', 'Applicant')}
|
||||
"""
|
||||
|
||||
return {
|
||||
"resume": tailored_resume,
|
||||
"cover_letter": cover_letter,
|
||||
"matched_skills": relevant_skills
|
||||
}
|
||||
|
||||
# --- Applications ---
|
||||
|
||||
@router.post("/applications")
|
||||
def submit_application(app_data: ApplicationSubmit, user=Depends(get_current_user)):
|
||||
import uuid
|
||||
app_id = str(uuid.uuid4())[:8]
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
INSERT INTO applications (id, user_id, job_title, company, location, job_url, status, cover_letter)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
app_id, user["id"],
|
||||
app_data.job.get("title", ""),
|
||||
app_data.job.get("company", ""),
|
||||
app_data.job.get("location", ""),
|
||||
app_data.job.get("url", ""),
|
||||
app_data.status,
|
||||
app_data.cover_letter[:500] if app_data.cover_letter else ""
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"status": "ok", "application_id": app_id}
|
||||
|
||||
@router.get("/applications")
|
||||
def get_applications(user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT * FROM applications WHERE user_id = ? ORDER BY applied_at DESC", (user["id"],))
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.patch("/applications/{app_id}")
|
||||
def update_application(app_id: str, status: str, user=Depends(get_current_user)):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute("UPDATE applications SET status = ? WHERE id = ? AND user_id = ?",
|
||||
(status, app_id, user["id"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"status": "ok"}
|
||||
|
||||
# --- Admin Routes ---
|
||||
|
||||
@router.get("/admin/stats")
|
||||
def admin_stats():
|
||||
"""Global platform statistics"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute("SELECT COUNT(*) as count FROM users")
|
||||
total_users = c.fetchone()["count"]
|
||||
|
||||
c.execute("SELECT COUNT(*) as count FROM applications")
|
||||
total_applications = c.fetchone()["count"]
|
||||
|
||||
c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'applied'")
|
||||
applied = c.fetchone()["count"]
|
||||
|
||||
c.execute("SELECT COUNT(*) as count FROM applications WHERE status = 'interview'")
|
||||
interviews = c.fetchone()["count"]
|
||||
|
||||
c.execute("""
|
||||
SELECT source, COUNT(*) as searches, SUM(jobs_found) as total_jobs
|
||||
FROM api_usage GROUP BY source
|
||||
""")
|
||||
api_usage = [dict(r) for r in c.fetchall()]
|
||||
|
||||
c.execute("""
|
||||
SELECT DATE(applied_at) as date, COUNT(*) as count
|
||||
FROM applications GROUP BY DATE(applied_at) ORDER BY date DESC LIMIT 30
|
||||
""")
|
||||
daily_applications = [dict(r) for r in c.fetchall()]
|
||||
|
||||
c.execute("""
|
||||
SELECT company, COUNT(*) as count FROM applications
|
||||
GROUP BY company ORDER BY count DESC LIMIT 10
|
||||
""")
|
||||
top_companies = [dict(r) for r in c.fetchall()]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"total_applications": total_applications,
|
||||
"applied": applied,
|
||||
"interviews": interviews,
|
||||
"conversion_rate": round(interviews / applied * 100, 1) if applied > 0 else 0,
|
||||
"api_usage": api_usage,
|
||||
"daily_applications": daily_applications,
|
||||
"top_companies": top_companies
|
||||
}
|
||||
|
||||
@router.get("/admin/users")
|
||||
def admin_users():
|
||||
"""All users with stats"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
SELECT u.id, u.email, u.name, u.created_at,
|
||||
COUNT(a.id) as applications,
|
||||
SUM(CASE WHEN a.status = 'interview' THEN 1 ELSE 0 END) as interviews
|
||||
FROM users u
|
||||
LEFT JOIN applications a ON u.id = a.user_id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at DESC
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
@router.get("/admin/applications")
|
||||
def admin_applications():
|
||||
"""All applications"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.cursor()
|
||||
c.execute("""
|
||||
SELECT a.*, u.email, u.name
|
||||
FROM applications a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
ORDER BY a.applied_at DESC
|
||||
""")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Start AutoJobs API server with explicit router registration"""
|
||||
import sys
|
||||
sys.path.insert(0, '/var/www/autojobs/backend')
|
||||
|
||||
import main
|
||||
|
||||
# Explicitly include router (required because of module load order)
|
||||
main.app.include_router(main.router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(main.app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client"
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface Stats {
|
||||
total_users: number
|
||||
total_applications: number
|
||||
applied: number
|
||||
interviews: number
|
||||
conversion_rate: number
|
||||
api_usage: { source: string; searches: number; total_jobs: number }[]
|
||||
daily_applications: { date: string; count: number }[]
|
||||
top_companies: { company: string; count: number }[]
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
created_at: string
|
||||
applications: number
|
||||
interviews: number
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
job_title: string
|
||||
company: string
|
||||
location: string
|
||||
status: string
|
||||
applied_at: string
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [applications, setApplications] = useState<Application[]>([])
|
||||
const [tab, setTab] = useState<"overview" | "users" | "applications">("overview")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statsRes, usersRes, appsRes] = await Promise.all([
|
||||
fetch("/api/admin/stats"),
|
||||
fetch("/api/admin/users"),
|
||||
fetch("/api/admin/applications")
|
||||
])
|
||||
const statsData = await statsRes.json()
|
||||
const usersData = await usersRes.json()
|
||||
const appsData = await appsRes.json()
|
||||
setStats(statsData)
|
||||
setUsers(usersData)
|
||||
setApplications(appsData)
|
||||
} catch (err) {
|
||||
console.error("Failed to load admin data:", err)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
pending: "bg-yellow-500/20 text-yellow-400",
|
||||
applied: "bg-blue-500/20 text-blue-400",
|
||||
interview: "bg-green-500/20 text-green-400",
|
||||
rejected: "bg-red-500/20 text-red-400",
|
||||
offer: "bg-purple-500/20 text-purple-400"
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-white flex items-center justify-center">
|
||||
<div className="text-slate-400">Loading admin data...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-white">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-800 border-b border-slate-700 px-6 py-4">
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<div>
|
||||
<Link href="/autojobs" className="text-xl font-bold text-blue-400">AutoJobs</Link>
|
||||
<span className="ml-3 text-slate-400 text-sm">/ Admin</span>
|
||||
</div>
|
||||
<Link href="/autojobs/dashboard" className="text-slate-400 hover:text-white text-sm">
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Admin Dashboard</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
{["overview", "users", "applications"].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t as any)}
|
||||
className={`px-5 py-2 rounded-lg font-medium capitalize transition ${
|
||||
tab === t ? "bg-blue-500 text-white" : "bg-slate-800 text-slate-400 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{tab === "overview" && stats && (
|
||||
<div>
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: "Total Users", value: stats.total_users, icon: "👥" },
|
||||
{ label: "Applications", value: stats.total_applications, icon: "📋" },
|
||||
{ label: "Interviews", value: stats.interviews, icon: "🎯" },
|
||||
{ label: "Conversion Rate", value: `${stats.conversion_rate}%`, icon: "📈" },
|
||||
].map(kpi => (
|
||||
<div key={kpi.label} className="bg-slate-800 rounded-xl p-5 border border-slate-700">
|
||||
<div className="text-2xl mb-2">{kpi.icon}</div>
|
||||
<div className="text-3xl font-bold">{kpi.value}</div>
|
||||
<div className="text-slate-400 text-sm">{kpi.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts placeholder + tables */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Top Companies */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="font-semibold mb-4">Top Companies Applied To</h3>
|
||||
{stats.top_companies.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.top_companies.map((c, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-3 bg-slate-700/50 rounded-lg">
|
||||
<span className="text-white">{c.company}</span>
|
||||
<span className="text-blue-400 font-semibold">{c.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-400 text-sm">No data yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Usage */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="font-semibold mb-4">API Usage</h3>
|
||||
{stats.api_usage.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.api_usage.map((u, i) => (
|
||||
<div key={i} className="flex justify-between items-center p-3 bg-slate-700/50 rounded-lg">
|
||||
<span className="text-white">{u.source}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-blue-400 font-semibold">{u.searches}</span>
|
||||
<span className="text-slate-400 text-xs block">{u.total_jobs} jobs found</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-400 text-sm">No searches yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Applications */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 md:col-span-2">
|
||||
<h3 className="font-semibold mb-4">Daily Applications (Last 30 Days)</h3>
|
||||
{stats.daily_applications.length > 0 ? (
|
||||
<div className="flex items-end gap-1 h-32">
|
||||
{stats.daily_applications.slice(-14).map((d, i) => {
|
||||
const max = Math.max(...stats.daily_applications.map(x => x.count))
|
||||
const height = max > 0 ? (d.count / max) * 100 : 0
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full bg-blue-500 rounded-t" style={{ height: `${Math.max(height, 4)}%` }} />
|
||||
<span className="text-xs text-slate-500">{d.date?.slice(5)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-slate-400 text-sm">No data yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{tab === "users" && (
|
||||
<div>
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">User</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Email</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Joined</th>
|
||||
<th className="text-center px-4 py-3 text-slate-400 text-sm font-medium">Applications</th>
|
||||
<th className="text-center px-4 py-3 text-slate-400 text-sm font-medium">Interviews</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-slate-700 hover:bg-slate-700/30">
|
||||
<td className="px-4 py-3 text-white font-medium">{u.name || "—"}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{u.email}</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-sm">{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-center text-blue-400 font-semibold">{u.applications}</td>
|
||||
<td className="px-4 py-3 text-center text-green-400 font-semibold">{u.interviews}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-slate-400">No users yet</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications Tab */}
|
||||
{tab === "applications" && (
|
||||
<div>
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">User</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Job</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Company</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Location</th>
|
||||
<th className="text-left px-4 py-3 text-slate-400 text-sm font-medium">Applied</th>
|
||||
<th className="text-center px-4 py-3 text-slate-400 text-sm font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applications.map((a) => (
|
||||
<tr key={a.id} className="border-t border-slate-700 hover:bg-slate-700/30">
|
||||
<td className="px-4 py-3 text-white text-sm">{a.name || a.email}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{a.job_title}</td>
|
||||
<td className="px-4 py-3 text-blue-400 text-sm">{a.company}</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-sm">{a.location}</td>
|
||||
<td className="px-4 py-3 text-slate-400 text-sm">{new Date(a.applied_at).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColor[a.status] || "bg-slate-700"}`}>
|
||||
{a.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{applications.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-400">No applications yet</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email } = await req.json()
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set("autojobs_user", email.split("@")[0], {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30
|
||||
})
|
||||
|
||||
return NextResponse.json({ status: "ok" })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { email, password, name } = await req.json()
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "Email and password required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// In production: hash password, store in DB
|
||||
// For MVP: create user in backend DB, set cookie
|
||||
try {
|
||||
const res = await fetch(`${process.env.API_URL || "http://localhost:8000"}/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: email.split("@")[0], email, name })
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: "Failed to create user" }, { status: 500 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set("autojobs_user", email.split("@")[0], {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 days
|
||||
})
|
||||
|
||||
return NextResponse.json({ status: "ok" })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"use client"
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
|
||||
interface Job {
|
||||
source: string
|
||||
title: string
|
||||
company: string
|
||||
location: string
|
||||
description: string
|
||||
url: string
|
||||
salary: string
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string
|
||||
job_title: string
|
||||
company: string
|
||||
location: string
|
||||
job_url: string
|
||||
status: string
|
||||
applied_at: string
|
||||
}
|
||||
|
||||
type Tab = "search" | "applications" | "profile" | "settings"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [tab, setTab] = useState<Tab>("search")
|
||||
const [user, setUser] = useState<any>(null)
|
||||
|
||||
// Search state
|
||||
const [query, setQuery] = useState("")
|
||||
const [location, setLocation] = useState("")
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
// Applications state
|
||||
const [applications, setApplications] = useState<Application[]>([])
|
||||
|
||||
// Profile state
|
||||
const [profile, setProfile] = useState({
|
||||
name: "", title: "", summary: "", skills: "",
|
||||
experience: [{ title: "", company: "", bullets: "" }],
|
||||
education: "", linkedin_url: "",
|
||||
job_keywords: "", job_locations: ""
|
||||
})
|
||||
|
||||
// API Keys state
|
||||
const [apiKeys, setApiKeys] = useState({ jooble: "", jsearch: "", perplexity: "" })
|
||||
const [keysSaved, setKeysSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Load user, profile, applications
|
||||
fetch("/api/users/me").then(r => r.ok && r.json()).then(d => d && setUser(d)).catch(() => {})
|
||||
fetch("/api/users/me/profile").then(r => r.ok && r.json()).then(d => d && d.title && setProfile({
|
||||
name: d.name || "", title: d.title || "", summary: d.summary || "",
|
||||
skills: Array.isArray(d.skills) ? d.skills.join(", ") : (d.skills || ""),
|
||||
experience: d.experience || [{ title: "", company: "", bullets: "" }],
|
||||
education: d.education || "", linkedin_url: d.linkedin_url || "",
|
||||
job_keywords: Array.isArray(d.job_keywords) ? d.job_keywords.join(", ") : (d.job_keywords || ""),
|
||||
job_locations: Array.isArray(d.job_locations) ? d.job_locations.join(", ") : (d.job_locations || "")
|
||||
})).catch(() => {})
|
||||
fetch("/api/applications").then(r => r.ok && r.json()).then(d => d && setApplications(d)).catch(() => {})
|
||||
fetch("/api/users/me/api-keys").then(r => r.ok && r.json()).then(d => d && setApiKeys({
|
||||
jooble: d.jooble || "", jsearch: d.jsearch || "", perplexity: d.perplexity || ""
|
||||
})).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const searchJobs = async () => {
|
||||
if (!query) return
|
||||
setSearching(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const res = await fetch("/api/jobs/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, location })
|
||||
})
|
||||
const data = await res.json()
|
||||
setJobs(data.jobs || [])
|
||||
} catch {}
|
||||
setSearching(false)
|
||||
}
|
||||
|
||||
const applyToJob = async (job: Job) => {
|
||||
try {
|
||||
await fetch("/api/ai/customize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ job, profile })
|
||||
})
|
||||
await fetch("/api/applications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ job, resume: "", cover_letter: "", status: "applied" })
|
||||
})
|
||||
// Refresh applications
|
||||
const res = await fetch("/api/applications")
|
||||
const data = await res.json()
|
||||
setApplications(data)
|
||||
setTab("applications")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
const payload = {
|
||||
...profile,
|
||||
skills: profile.skills.split(",").map(s => s.trim()).filter(Boolean),
|
||||
job_keywords: profile.job_keywords.split(",").map(s => s.trim()).filter(Boolean),
|
||||
job_locations: profile.job_locations.split(",").map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
await fetch("/api/users/me/profile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
alert("Profile saved!")
|
||||
}
|
||||
|
||||
const saveApiKeys = async () => {
|
||||
await fetch("/api/users/me/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(apiKeys)
|
||||
})
|
||||
setKeysSaved(true)
|
||||
setTimeout(() => setKeysSaved(false), 2000)
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
pending: "bg-yellow-500/20 text-yellow-400",
|
||||
applied: "bg-blue-500/20 text-blue-400",
|
||||
interview: "bg-green-500/20 text-green-400",
|
||||
rejected: "bg-red-500/20 text-red-400",
|
||||
offer: "bg-purple-500/20 text-purple-400"
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-white">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-800 border-b border-slate-700 px-6 py-4">
|
||||
<div className="max-w-6xl mx-auto flex justify-between items-center">
|
||||
<Link href="/autojobs" className="text-xl font-bold text-blue-400">AutoJobs</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-slate-400 text-sm">{user?.email || "Loading..."}</span>
|
||||
<Link href="/autojobs/logout" className="text-slate-400 hover:text-white text-sm">Logout</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-8 border-b border-slate-700 pb-4">
|
||||
{[
|
||||
{ key: "search", label: "🔍 Find Jobs", icon: "🔍" },
|
||||
{ key: "applications", label: `📋 Applications (${applications.length})`, icon: "📋" },
|
||||
{ key: "profile", label: "👤 My Profile", icon: "👤" },
|
||||
{ key: "settings", label: "⚙️ API Keys", icon: "⚙️" }
|
||||
].map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key as Tab)}
|
||||
className={`px-5 py-2 rounded-lg font-medium transition ${
|
||||
tab === t.key
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search Tab */}
|
||||
{tab === "search" && (
|
||||
<div>
|
||||
<div className="bg-slate-800 rounded-2xl p-6 mb-6 border border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-4">Search Jobs</h2>
|
||||
<p className="text-slate-400 text-sm mb-4">AI will search across Jooble, JSearch, and more using your API keys.</p>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Job title, keywords (e.g. Senior Python Engineer, AI/ML)"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && searchJobs()}
|
||||
className="flex-1 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-400 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Location (e.g. Barcelona, Remote, UK)"
|
||||
value={location}
|
||||
onChange={e => setLocation(e.target.value)}
|
||||
className="w-64 px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-400 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={searchJobs}
|
||||
disabled={searching || !query}
|
||||
className="px-8 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-slate-600 text-white rounded-xl font-semibold transition"
|
||||
>
|
||||
{searching ? "Searching..." : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{!apiKeys.jooble && !apiKeys.jsearch ? (
|
||||
<p className="mt-3 text-yellow-400 text-sm">
|
||||
⚠️ No API keys found. <button onClick={() => setTab("settings")} className="underline">Add your API keys</button> to search jobs.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-green-400 text-sm">
|
||||
✓ API keys configured — searches will use your keys
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{searched && (
|
||||
<div>
|
||||
<p className="text-slate-400 mb-4">{jobs.length} jobs found</p>
|
||||
{jobs.length === 0 && !searching && (
|
||||
<div className="text-center py-12 bg-slate-800 rounded-2xl border border-slate-700">
|
||||
<p className="text-slate-400">No jobs found. Try different keywords.</p>
|
||||
<p className="text-slate-500 text-sm mt-2">Make sure you've added API keys in Settings.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{jobs.map((job, i) => (
|
||||
<div key={i} className="bg-slate-800 rounded-xl p-6 border border-slate-700 hover:border-slate-600 transition">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-white">{job.title}</h3>
|
||||
<p className="text-blue-400 mt-1">{job.company}</p>
|
||||
<p className="text-slate-400 text-sm mt-1">{job.location}</p>
|
||||
<p className="text-slate-300 text-sm mt-3 line-clamp-2">{job.description?.slice(0, 200)}...</p>
|
||||
<div className="flex gap-3 mt-3">
|
||||
<span className="text-xs px-2 py-1 bg-slate-700 rounded text-slate-400">{job.source}</span>
|
||||
{job.salary && (
|
||||
<span className="text-xs px-2 py-1 bg-green-500/20 text-green-400 rounded">{job.salary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => applyToJob(job)}
|
||||
className="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium text-sm transition"
|
||||
>
|
||||
Apply Now
|
||||
</button>
|
||||
<a
|
||||
href={job.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium text-sm text-center transition"
|
||||
>
|
||||
View Job ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Applications Tab */}
|
||||
{tab === "applications" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">My Applications</h2>
|
||||
<button
|
||||
onClick={() => setTab("search")}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg text-sm font-medium transition"
|
||||
>
|
||||
Find New Jobs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<div className="text-center py-16 bg-slate-800 rounded-2xl border border-slate-700">
|
||||
<p className="text-slate-400 mb-4">No applications yet.</p>
|
||||
<button
|
||||
onClick={() => setTab("search")}
|
||||
className="px-6 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Search for Jobs
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{applications.map(app => (
|
||||
<div key={app.id} className="bg-slate-800 rounded-xl p-5 border border-slate-700 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{app.job_title}</h3>
|
||||
<p className="text-blue-400 text-sm">{app.company} · {app.location}</p>
|
||||
<p className="text-slate-500 text-xs mt-1">
|
||||
Applied {new Date(app.applied_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={app.status}
|
||||
onChange={async (e) => {
|
||||
await fetch(`/api/applications/${app.id}?status=${e.target.value}`, { method: "PATCH" })
|
||||
setApplications(prev => prev.map(a => a.id === app.id ? {...a, status: e.target.value} : a))
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium ${statusColor[app.status] || "bg-slate-700"}`}
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="applied">Applied</option>
|
||||
<option value="interview">Interview</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="offer">Offer!</option>
|
||||
</select>
|
||||
{app.job_url && (
|
||||
<a href={app.job_url} target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-white text-sm">
|
||||
View ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Tab */}
|
||||
{tab === "profile" && (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold mb-6">My Profile</h2>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Full Name</label>
|
||||
<input value={profile.name} onChange={e => setProfile({...profile, name: e.target.value})}
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Professional Title</label>
|
||||
<input value={profile.title} onChange={e => setProfile({...profile, title: e.target.value})}
|
||||
placeholder="e.g. Senior Full-Stack Developer"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Professional Summary</label>
|
||||
<textarea value={profile.summary} onChange={e => setProfile({...profile, summary: e.target.value})}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white resize-none"
|
||||
placeholder="Brief overview of your experience and what you're looking for..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Skills (comma-separated)</label>
|
||||
<input value={profile.skills} onChange={e => setProfile({...profile, skills: e.target.value})}
|
||||
placeholder="Python, React, AWS, Machine Learning..."
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">LinkedIn URL</label>
|
||||
<input value={profile.linkedin_url} onChange={e => setProfile({...profile, linkedin_url: e.target.value})}
|
||||
placeholder="https://linkedin.com/in/yourprofile"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Education</label>
|
||||
<input value={profile.education} onChange={e => setProfile({...profile, education: e.target.value})}
|
||||
placeholder="Bachelor's in Computer Science, University of..."
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
|
||||
<h3 className="font-semibold mb-4">Job Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Job Keywords (comma-separated)</label>
|
||||
<input value={profile.job_keywords} onChange={e => setProfile({...profile, job_keywords: e.target.value})}
|
||||
placeholder="Senior Python Engineer, AI/ML, Machine Learning, Remote"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Preferred Locations (comma-separated)</label>
|
||||
<input value={profile.job_locations} onChange={e => setProfile({...profile, job_locations: e.target.value})}
|
||||
placeholder="Barcelona, Madrid, Remote, UK, Europe"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-xl text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700">
|
||||
<h3 className="font-semibold mb-4">Work Experience</h3>
|
||||
{profile.experience.map((exp, i) => (
|
||||
<div key={i} className="mb-4 p-4 bg-slate-700/50 rounded-xl">
|
||||
<input value={exp.title} onChange={e => {
|
||||
const newExp = [...profile.experience]
|
||||
newExp[i].title = e.target.value
|
||||
setProfile({...profile, experience: newExp})
|
||||
}} placeholder="Job Title" className="w-full mb-2 px-3 py-2 bg-slate-600 rounded-lg text-white" />
|
||||
<input value={exp.company} onChange={e => {
|
||||
const newExp = [...profile.experience]
|
||||
newExp[i].company = e.target.value
|
||||
setProfile({...profile, experience: newExp})
|
||||
}} placeholder="Company" className="w-full mb-2 px-3 py-2 bg-slate-600 rounded-lg text-white" />
|
||||
<textarea value={exp.bullets} onChange={e => {
|
||||
const newExp = [...profile.experience]
|
||||
newExp[i].bullets = e.target.value
|
||||
setProfile({...profile, experience: newExp})
|
||||
}} placeholder="Key achievements (one per line)" rows={3}
|
||||
className="w-full px-3 py-2 bg-slate-600 rounded-lg text-white resize-none" />
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setProfile({...profile, experience: [...profile.experience, { title: "", company: "", bullets: "" }]})}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm"
|
||||
>
|
||||
+ Add Another Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveProfile}
|
||||
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-semibold transition"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Tab — API Keys */}
|
||||
{tab === "settings" && (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-xl font-semibold mb-2">API Keys</h2>
|
||||
<p className="text-slate-400 text-sm mb-6">
|
||||
Add your own API keys. Your keys are encrypted and only used for your searches.
|
||||
Each user has their own keys — costs are tracked separately.
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-800 rounded-2xl p-6 border border-slate-700 space-y-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-1">Jooble API Key</h3>
|
||||
<p className="text-slate-400 text-sm mb-3">Free. Get at <a href="https://jooble.com/api" target="_blank" className="text-blue-400 underline">jooble.com/api</a></p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeys.jooble}
|
||||
onChange={e => setApiKeys({...apiKeys, jooble: e.target.value})}
|
||||
placeholder="Enter your Jooble API key"
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-1">JSearch API Key (RapidAPI)</h3>
|
||||
<p className="text-slate-400 text-sm mb-3">
|
||||
100 free searches/month. Get at <a href="https://rapidapi.com" target="_blank" className="text-blue-400 underline">RapidAPI</a> — search "JSearch"
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeys.jsearch}
|
||||
onChange={e => setApiKeys({...apiKeys, jsearch: e.target.value})}
|
||||
placeholder="Enter your JSearch / RapidAPI key"
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-white mb-1">Perplexity API Key</h3>
|
||||
<p className="text-slate-400 text-sm mb-3">
|
||||
For AI company research. Free tier at <a href="https://perplexity.ai" target="_blank" className="text-blue-400 underline">perplexity.ai</a>
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKeys.perplexity}
|
||||
onChange={e => setApiKeys({...apiKeys, perplexity: e.target.value})}
|
||||
placeholder="Enter your Perplexity API key"
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveApiKeys}
|
||||
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-semibold transition"
|
||||
>
|
||||
{keysSaved ? "✓ Keys Saved!" : "Save API Keys"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-slate-800/50 rounded-xl p-4 border border-slate-700">
|
||||
<p className="text-slate-400 text-sm">
|
||||
💡 <strong className="text-white">Why add your own keys?</strong> You control your costs and data.
|
||||
Each platform has free tiers — Jooble is completely free, JSearch gives 100 searches/month free.
|
||||
We never charge for API usage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background-color: #0f172a;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
input, textarea, select {
|
||||
font-size: 14px;
|
||||
}
|
||||
h1, h2, h3 { font-weight: 600; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AutoJobs — AI Applies to Jobs For You",
|
||||
description: "Upload your resume, set your preferences, and let AI find and apply to jobs automatically.",
|
||||
icons: {
|
||||
icon: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>"
|
||||
}
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [form, setForm] = useState({ email: "", password: "" })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push("/autojobs/dashboard")
|
||||
} else {
|
||||
setError("Invalid email or password")
|
||||
}
|
||||
} catch {
|
||||
setError("Network error")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/autojobs" className="text-2xl font-bold text-white">
|
||||
Auto<span className="text-blue-400">Jobs</span>
|
||||
</Link>
|
||||
<p className="text-slate-400 mt-2">Welcome back</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-slate-800 rounded-2xl p-8 border border-slate-700">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Email</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm({...form, email: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Password</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm({...form, password: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-slate-600 text-white rounded-xl font-semibold transition"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-slate-400 mt-6 text-sm">
|
||||
Don't have an account? <Link href="/autojobs/signup" className="text-blue-400 hover:underline">Sign up free</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-950 to-slate-900">
|
||||
{/* Nav */}
|
||||
<nav className="flex justify-between items-center px-8 py-6 max-w-6xl mx-auto">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
Auto<span className="text-blue-400">Jobs</span>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<Link href="/autojobs/login" className="text-slate-300 hover:text-white transition">Login</Link>
|
||||
<Link href="/autojobs/signup" className="px-5 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition">
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-24 px-6 text-center">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="inline-block px-4 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 text-blue-300 text-sm mb-6">
|
||||
Your Personal AI Job Agent
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6 leading-tight">
|
||||
Stop Applying to Jobs.
|
||||
<br />
|
||||
<span className="text-blue-400">Let AI Do It For You.</span>
|
||||
</h1>
|
||||
<p className="text-xl text-slate-300 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||
Upload your resume once. Set your keywords. Our AI finds every matching job,
|
||||
rewrites your resume + cover letter for each one, and applies automatically —
|
||||
while you sleep.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/autojobs/signup"
|
||||
className="px-10 py-4 bg-blue-500 hover:bg-blue-600 text-white rounded-xl font-bold text-lg transition shadow-lg shadow-blue-500/25"
|
||||
>
|
||||
Start Free — Beta Access
|
||||
</Link>
|
||||
<Link
|
||||
href="/autojobs/demo"
|
||||
className="px-10 py-4 bg-white/10 hover:bg-white/20 text-white rounded-xl font-semibold text-lg border border-white/20 transition"
|
||||
>
|
||||
See How It Works
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-6 text-slate-500 text-sm">No credit card required. Beta is free.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="py-20 px-6 bg-slate-800/40">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-white text-center mb-4">How AutoJobs Works</h2>
|
||||
<p className="text-slate-400 text-center mb-12 max-w-xl mx-auto">Three steps to never manually applying to a job again.</p>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
step: "01",
|
||||
title: "Create Your Profile",
|
||||
desc: "Upload your resume and LinkedIn. Tell us what jobs you want — keywords, location, salary range. Add your own API keys for job search engines."
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "AI Finds & Customizes",
|
||||
desc: "We search Jooble, JSearch, and more. For each match, AI rewrites your resume and writes a personalized cover letter targeting that company."
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Auto-Apply 24/7",
|
||||
desc: "AI applies on your behalf where possible. You review and approve the rest. Dashboard tracks every application and its status."
|
||||
}
|
||||
].map((item) => (
|
||||
<div key={item.step} className="relative bg-slate-700/50 rounded-2xl p-8 border border-slate-600 hover:border-blue-500/50 transition">
|
||||
<div className="text-5xl font-bold text-blue-500/20 mb-4">{item.step}</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-3">{item.title}</h3>
|
||||
<p className="text-slate-300 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-white text-center mb-12">Everything You Need to Land the Job</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ icon: "🎯", title: "AI Resume Tailoring", desc: "Every resume is rewritten to match the exact job description and keywords" },
|
||||
{ icon: "✉️", title: "AI Cover Letters", desc: "Personalized for each company — their mission, news, and your fit" },
|
||||
{ icon: "🔍", title: "Multi-Source Search", desc: "Jooble, JSearch, and more — aggregated and deduplicated" },
|
||||
{ icon: "📊", title: "Application Tracker", desc: "Dashboard shows every application, status, interview invites" },
|
||||
{ icon: "🔑", title: "Your Own API Keys", desc: "Add your own keys — you control your data and spending" },
|
||||
{ icon: "⚡", title: "Apply While You Sleep", desc: "AI works 24/7. Wake up to new opportunities submitted" },
|
||||
{ icon: "👥", title: "Multi-User", desc: "Separate accounts for you, your friends, your clients" },
|
||||
{ icon: "📈", title: "Admin Dashboard", desc: "See all users, applications, and platform analytics" },
|
||||
{ icon: "🔒", title: "Private & Secure", desc: "Your data stays yours. API keys are encrypted." },
|
||||
].map((f) => (
|
||||
<div key={f.title} className="bg-slate-800/50 rounded-xl p-5 border border-slate-700 hover:border-slate-500 transition">
|
||||
<div className="text-2xl mb-3">{f.icon}</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{f.title}</h3>
|
||||
<p className="text-slate-400 text-sm">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="py-20 px-6 bg-slate-800/40">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">Simple, Transparent Pricing</h2>
|
||||
<p className="text-slate-400 mb-10">Start free during beta. Scale when you're landing interviews.</p>
|
||||
<div className="grid md:grid-cols-2 gap-6 max-w-2xl mx-auto">
|
||||
<div className="bg-slate-700/60 rounded-2xl p-8 border border-slate-600">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Beta</h3>
|
||||
<div className="text-4xl font-bold text-white mb-1">Free</div>
|
||||
<p className="text-slate-400 mb-6 text-sm">While we build</p>
|
||||
<ul className="text-slate-300 text-left space-y-2 mb-8">
|
||||
<li>✓ Unlimited applications</li>
|
||||
<li>✓ AI resume tailoring</li>
|
||||
<li>✓ AI cover letters</li>
|
||||
<li>✓ Application tracking</li>
|
||||
<li>✓ Add your own API keys</li>
|
||||
<li>✓ Multi-user support</li>
|
||||
</ul>
|
||||
<Link href="/autojobs/signup" className="block text-center px-6 py-3 bg-white/10 hover:bg-white/20 text-white rounded-lg font-medium transition">
|
||||
Join Beta — Free
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-blue-600/20 to-purple-600/20 rounded-2xl p-8 border border-blue-500/30">
|
||||
<div className="text-sm text-blue-400 font-medium mb-1">Coming Soon</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Pro</h3>
|
||||
<div className="text-4xl font-bold text-white mb-1">€19<span className="text-lg font-normal text-slate-400">/mo</span></div>
|
||||
<p className="text-slate-400 mb-6 text-sm">When we launch publicly</p>
|
||||
<ul className="text-slate-300 text-left space-y-2 mb-8">
|
||||
<li>✓ Everything in Beta</li>
|
||||
<li>✓ We provide API keys</li>
|
||||
<li>✓ Auto-apply everywhere</li>
|
||||
<li>✓ Email notifications</li>
|
||||
<li>✓ Interview prep tips</li>
|
||||
<li>✓ Priority support</li>
|
||||
</ul>
|
||||
<button className="w-full px-6 py-3 bg-blue-500/50 text-white rounded-lg font-medium cursor-not-allowed" disabled>
|
||||
Join Waitlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 px-6 border-t border-slate-700">
|
||||
<div className="max-w-5xl mx-auto text-center text-slate-500 text-sm">
|
||||
<p>© 2026 AutoJobs — Built on <a href="https://hostpioneers.com" className="text-blue-400 hover:underline">HostPioneers</a> infrastructure</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter()
|
||||
const [form, setForm] = useState({
|
||||
name: "", email: "", password: "", confirmPassword: ""
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError("Passwords don't match")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: form.name, email: form.email, password: form.password })
|
||||
})
|
||||
if (res.ok) {
|
||||
router.push("/autojobs/profile-setup")
|
||||
} else {
|
||||
const data = await res.json()
|
||||
setError(data.error || "Signup failed")
|
||||
}
|
||||
} catch {
|
||||
setError("Network error")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/autojobs" className="text-2xl font-bold text-white">
|
||||
Auto<span className="text-blue-400">Jobs</span>
|
||||
</Link>
|
||||
<p className="text-slate-400 mt-2">Create your account</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-slate-800 rounded-2xl p-8 border border-slate-700">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Full Name</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => setForm({...form, name: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="John Smith"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Email</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={e => setForm({...form, email: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Password</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm({...form, password: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Confirm Password</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={e => setForm({...form, confirmPassword: e.target.value})}
|
||||
className="w-full px-4 py-3 bg-slate-700 border border-slate-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full mt-6 py-3 bg-blue-500 hover:bg-blue-600 disabled:bg-slate-600 text-white rounded-xl font-semibold transition"
|
||||
>
|
||||
{loading ? "Creating account..." : "Create Account"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-slate-400 mt-6 text-sm">
|
||||
Already have an account? <Link href="/autojobs/login" className="text-blue-400 hover:underline">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Generated
+1621
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "autojobs-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user