Files
autojobs/frontend/app/dashboard/page.tsx
T

508 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}