Initial commit: AutoJobs MVP - AI job application platform

This commit is contained in:
2026-04-13 18:39:26 +02:00
commit 8d1845c874
16 changed files with 3483 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
__pycache__/
*.pyc
.env
*.db
.next/
out/
+60
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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]
+13
View File
@@ -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)
+276
View File
@@ -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>
)
}
+16
View File
@@ -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" })
}
+36
View File
@@ -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 })
}
}
+507
View File
@@ -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>
)
}
+25
View File
@@ -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;
}
}
+18
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+163
View File
@@ -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>
)
}
+123
View File
@@ -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>
)
}
+1621
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}