508 lines
24 KiB
TypeScript
508 lines
24 KiB
TypeScript
"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>
|
||
)
|
||
}
|