Add Candidate Portal foundation - API and core pages

- Add database migration for candidate_profiles, candidate_interviews,
  and candidate_offers tables with RLS policies
- Add API endpoints: /api/candidate/profile, /api/candidate/interviews,
  /api/candidate/offers
- Add CandidatePortal.tsx main dashboard with stats, quick actions,
  upcoming interviews, and pending offers
- Add CandidateProfile.tsx profile builder with tabs for basic info,
  work experience, education, and portfolio links
This commit is contained in:
Claude 2026-01-26 21:58:45 +00:00
parent 0136d3d8a4
commit 0674a282b0
No known key found for this signature in database
6 changed files with 2330 additions and 0 deletions

196
api/candidate/interviews.ts Normal file
View file

@ -0,0 +1,196 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
const url = new URL(req.url);
try {
// GET - Fetch interviews
if (req.method === "GET") {
const status = url.searchParams.get("status");
const upcoming = url.searchParams.get("upcoming") === "true";
let query = supabase
.from("candidate_interviews")
.select(
`
*,
employer:profiles!candidate_interviews_employer_id_fkey(
full_name,
avatar_url,
email
)
`,
)
.eq("candidate_id", userId)
.order("scheduled_at", { ascending: true });
if (status) {
query = query.eq("status", status);
}
if (upcoming) {
query = query
.gte("scheduled_at", new Date().toISOString())
.in("status", ["scheduled", "rescheduled"]);
}
const { data: interviews, error } = await query;
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Group by status
const grouped = {
upcoming: interviews?.filter(
(i) =>
["scheduled", "rescheduled"].includes(i.status) &&
new Date(i.scheduled_at) >= new Date(),
) || [],
past: interviews?.filter(
(i) =>
i.status === "completed" ||
new Date(i.scheduled_at) < new Date(),
) || [],
cancelled: interviews?.filter((i) => i.status === "cancelled") || [],
};
return new Response(
JSON.stringify({
interviews: interviews || [],
grouped,
total: interviews?.length || 0,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Create interview (for self-scheduling or employer invites)
if (req.method === "POST") {
const body = await req.json();
const {
application_id,
employer_id,
opportunity_id,
scheduled_at,
duration_minutes,
meeting_link,
meeting_type,
notes,
} = body;
if (!scheduled_at || !employer_id) {
return new Response(
JSON.stringify({ error: "scheduled_at and employer_id are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const { data, error } = await supabase
.from("candidate_interviews")
.insert({
application_id,
candidate_id: userId,
employer_id,
opportunity_id,
scheduled_at,
duration_minutes: duration_minutes || 30,
meeting_link,
meeting_type: meeting_type || "video",
notes,
status: "scheduled",
})
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ interview: data }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
// PATCH - Update interview (feedback, reschedule)
if (req.method === "PATCH") {
const body = await req.json();
const { id, candidate_feedback, status, scheduled_at } = body;
if (!id) {
return new Response(JSON.stringify({ error: "Interview id is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const updateData: Record<string, any> = {};
if (candidate_feedback !== undefined)
updateData.candidate_feedback = candidate_feedback;
if (status !== undefined) updateData.status = status;
if (scheduled_at !== undefined) {
updateData.scheduled_at = scheduled_at;
updateData.status = "rescheduled";
}
const { data, error } = await supabase
.from("candidate_interviews")
.update(updateData)
.eq("id", id)
.eq("candidate_id", userId)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ interview: data }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate interviews API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

136
api/candidate/offers.ts Normal file
View file

@ -0,0 +1,136 @@
import { supabase } from "../_supabase.js";
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
try {
// GET - Fetch offers
if (req.method === "GET") {
const { data: offers, error } = await supabase
.from("candidate_offers")
.select(
`
*,
employer:profiles!candidate_offers_employer_id_fkey(
full_name,
avatar_url,
email
)
`,
)
.eq("candidate_id", userId)
.order("created_at", { ascending: false });
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Group by status
const grouped = {
pending: offers?.filter((o) => o.status === "pending") || [],
accepted: offers?.filter((o) => o.status === "accepted") || [],
declined: offers?.filter((o) => o.status === "declined") || [],
expired: offers?.filter((o) => o.status === "expired") || [],
withdrawn: offers?.filter((o) => o.status === "withdrawn") || [],
};
return new Response(
JSON.stringify({
offers: offers || [],
grouped,
total: offers?.length || 0,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// PATCH - Respond to offer (accept/decline)
if (req.method === "PATCH") {
const body = await req.json();
const { id, status, notes } = body;
if (!id) {
return new Response(JSON.stringify({ error: "Offer id is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!["accepted", "declined"].includes(status)) {
return new Response(
JSON.stringify({ error: "Status must be accepted or declined" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const { data, error } = await supabase
.from("candidate_offers")
.update({
status,
notes,
candidate_response_at: new Date().toISOString(),
})
.eq("id", id)
.eq("candidate_id", userId)
.eq("status", "pending") // Can only respond to pending offers
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
if (!data) {
return new Response(
JSON.stringify({ error: "Offer not found or already responded" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ offer: data }), {
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate offers API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

191
api/candidate/profile.ts Normal file
View file

@ -0,0 +1,191 @@
import { supabase } from "../_supabase.js";
interface ProfileData {
headline?: string;
bio?: string;
resume_url?: string;
portfolio_urls?: string[];
work_history?: WorkHistory[];
education?: Education[];
skills?: string[];
availability?: string;
desired_rate?: number;
rate_type?: string;
location?: string;
remote_preference?: string;
is_public?: boolean;
}
interface WorkHistory {
company: string;
position: string;
start_date: string;
end_date?: string;
current: boolean;
description?: string;
}
interface Education {
institution: string;
degree: string;
field: string;
start_year: number;
end_year?: number;
current: boolean;
}
export default async (req: Request) => {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { data: userData } = await supabase.auth.getUser(token);
if (!userData.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = userData.user.id;
try {
// GET - Fetch candidate profile
if (req.method === "GET") {
const { data: profile, error } = await supabase
.from("candidate_profiles")
.select("*")
.eq("user_id", userId)
.single();
if (error && error.code !== "PGRST116") {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Get user info for basic profile
const { data: userProfile } = await supabase
.from("profiles")
.select("full_name, avatar_url, email")
.eq("id", userId)
.single();
// Get application stats
const { data: applications } = await supabase
.from("aethex_applications")
.select("id, status")
.eq("applicant_id", userId);
const stats = {
total_applications: applications?.length || 0,
pending: applications?.filter((a) => a.status === "pending").length || 0,
reviewed: applications?.filter((a) => a.status === "reviewed").length || 0,
accepted: applications?.filter((a) => a.status === "accepted").length || 0,
rejected: applications?.filter((a) => a.status === "rejected").length || 0,
};
return new Response(
JSON.stringify({
profile: profile || null,
user: userProfile,
stats,
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// POST - Create or update profile
if (req.method === "POST") {
const body: ProfileData = await req.json();
// Check if profile exists
const { data: existing } = await supabase
.from("candidate_profiles")
.select("id")
.eq("user_id", userId)
.single();
if (existing) {
// Update existing profile
const { data, error } = await supabase
.from("candidate_profiles")
.update({
...body,
portfolio_urls: body.portfolio_urls
? JSON.stringify(body.portfolio_urls)
: undefined,
work_history: body.work_history
? JSON.stringify(body.work_history)
: undefined,
education: body.education
? JSON.stringify(body.education)
: undefined,
})
.eq("user_id", userId)
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ profile: data }), {
headers: { "Content-Type": "application/json" },
});
} else {
// Create new profile
const { data, error } = await supabase
.from("candidate_profiles")
.insert({
user_id: userId,
...body,
portfolio_urls: body.portfolio_urls
? JSON.stringify(body.portfolio_urls)
: "[]",
work_history: body.work_history
? JSON.stringify(body.work_history)
: "[]",
education: body.education
? JSON.stringify(body.education)
: "[]",
})
.select()
.single();
if (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ profile: data }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
console.error("Candidate profile API error:", err);
return new Response(JSON.stringify({ error: err.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View file

@ -0,0 +1,620 @@
import { useState, useEffect } from "react";
import { Link } from "wouter";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Briefcase,
FileText,
Calendar,
Star,
ArrowRight,
User,
Clock,
CheckCircle2,
XCircle,
Eye,
Loader2,
Send,
Gift,
TrendingUp,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface ProfileData {
profile: {
headline: string;
bio: string;
skills: string[];
profile_completeness: number;
availability: string;
} | null;
user: {
full_name: string;
avatar_url: string;
email: string;
} | null;
stats: {
total_applications: number;
pending: number;
reviewed: number;
accepted: number;
rejected: number;
};
}
interface Interview {
id: string;
scheduled_at: string;
duration_minutes: number;
meeting_type: string;
status: string;
employer: {
full_name: string;
avatar_url: string;
};
}
interface Offer {
id: string;
position_title: string;
company_name: string;
salary_amount: number;
salary_type: string;
offer_expiry: string;
status: string;
}
export default function CandidatePortal() {
const { session, user } = useAuth();
const [loading, setLoading] = useState(true);
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [upcomingInterviews, setUpcomingInterviews] = useState<Interview[]>([]);
const [pendingOffers, setPendingOffers] = useState<Offer[]>([]);
useEffect(() => {
if (session?.access_token) {
fetchData();
}
}, [session?.access_token]);
const fetchData = async () => {
try {
const [profileRes, interviewsRes, offersRes] = await Promise.all([
fetch("/api/candidate/profile", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
fetch("/api/candidate/interviews?upcoming=true", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
fetch("/api/candidate/offers", {
headers: { Authorization: `Bearer ${session?.access_token}` },
}),
]);
if (profileRes.ok) {
const data = await profileRes.json();
setProfileData(data);
}
if (interviewsRes.ok) {
const data = await interviewsRes.json();
setUpcomingInterviews(data.grouped?.upcoming || []);
}
if (offersRes.ok) {
const data = await offersRes.json();
setPendingOffers(data.grouped?.pending || []);
}
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const getAvailabilityLabel = (availability: string) => {
const labels: Record<string, string> = {
immediate: "Available Immediately",
"2_weeks": "Available in 2 Weeks",
"1_month": "Available in 1 Month",
"3_months": "Available in 3 Months",
not_looking: "Not Currently Looking",
};
return labels[availability] || availability;
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getInitials = (name: string) => {
return name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase();
};
if (loading) {
return (
<Layout>
<SEO
title="Candidate Portal"
description="Manage your job applications and career"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
const stats = profileData?.stats || {
total_applications: 0,
pending: 0,
reviewed: 0,
accepted: 0,
rejected: 0,
};
return (
<Layout>
<SEO
title="Candidate Portal"
description="Manage your job applications and career"
/>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-6xl px-4 py-16">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-violet-500/30">
<AvatarImage src={profileData?.user?.avatar_url || ""} />
<AvatarFallback className="bg-violet-500/20 text-violet-300 text-lg">
{profileData?.user?.full_name
? getInitials(profileData.user.full_name)
: "U"}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Welcome back
{profileData?.user?.full_name
? `, ${profileData.user.full_name.split(" ")[0]}`
: ""}
!
</h1>
<p className="text-violet-200/70">
{profileData?.profile?.headline || "Your career dashboard"}
</p>
</div>
</div>
<div className="flex gap-3">
<Link href="/opportunities">
<Button className="bg-violet-600 hover:bg-violet-700">
<Briefcase className="h-4 w-4 mr-2" />
Browse Opportunities
</Button>
</Link>
<Link href="/candidate/profile">
<Button
variant="outline"
className="border-violet-500/30 text-violet-300 hover:bg-violet-500/10"
>
<User className="h-4 w-4 mr-2" />
Edit Profile
</Button>
</Link>
</div>
</div>
{/* Profile Completeness Alert */}
{profileData?.profile?.profile_completeness !== undefined &&
profileData.profile.profile_completeness < 80 && (
<Card className="bg-violet-500/10 border-violet-500/30 mb-8">
<CardContent className="pt-6">
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<p className="text-violet-100 font-medium mb-2">
Complete your profile to stand out
</p>
<Progress
value={profileData.profile.profile_completeness}
className="h-2"
/>
<p className="text-sm text-violet-200/70 mt-1">
{profileData.profile.profile_completeness}% complete
</p>
</div>
<Link href="/candidate/profile">
<Button
size="sm"
className="bg-violet-600 hover:bg-violet-700"
>
Complete Profile
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-violet-500/20 text-violet-400">
<Send className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.total_applications}
</p>
<p className="text-xs text-slate-400">Applications</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-yellow-500/20 text-yellow-400">
<Clock className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.pending}
</p>
<p className="text-xs text-slate-400">Pending</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-blue-500/20 text-blue-400">
<Eye className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.reviewed}
</p>
<p className="text-xs text-slate-400">In Review</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-green-500/20 text-green-400">
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.accepted}
</p>
<p className="text-xs text-slate-400">Accepted</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700/50">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-red-500/20 text-red-400">
<XCircle className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-violet-100">
{stats.rejected}
</p>
<p className="text-xs text-slate-400">Rejected</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Content Grid */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Quick Actions & Upcoming */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-4">
<Link href="/candidate/applications">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-violet-500/20 text-violet-400 w-fit mb-3">
<FileText className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
My Applications
</h3>
<p className="text-sm text-slate-400">
Track all your job applications
</p>
</CardContent>
</Card>
</Link>
<Link href="/candidate/interviews">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-blue-500/20 text-blue-400 w-fit mb-3">
<Calendar className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Interviews
</h3>
<p className="text-sm text-slate-400">
View and manage scheduled interviews
</p>
</CardContent>
</Card>
</Link>
<Link href="/candidate/offers">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-green-500/20 text-green-400 w-fit mb-3">
<Gift className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Offers
</h3>
<p className="text-sm text-slate-400">
Review and respond to job offers
</p>
</CardContent>
</Card>
</Link>
<Link href="/opportunities">
<Card className="bg-slate-800/50 border-slate-700/50 hover:border-violet-500/50 transition-all cursor-pointer h-full">
<CardContent className="pt-6">
<div className="p-2 rounded bg-orange-500/20 text-orange-400 w-fit mb-3">
<TrendingUp className="h-5 w-5" />
</div>
<h3 className="font-semibold text-violet-100 mb-1">
Browse Jobs
</h3>
<p className="text-sm text-slate-400">
Find new opportunities
</p>
</CardContent>
</Card>
</Link>
</div>
{/* Upcoming Interviews */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100 flex items-center gap-2">
<Calendar className="h-5 w-5 text-violet-400" />
Upcoming Interviews
</CardTitle>
<CardDescription className="text-slate-400">
Your scheduled interviews
</CardDescription>
</CardHeader>
<CardContent>
{upcomingInterviews.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No upcoming interviews scheduled
</p>
) : (
<div className="space-y-3">
{upcomingInterviews.slice(0, 3).map((interview) => (
<div
key={interview.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-700/30 border border-slate-600/30"
>
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarImage
src={interview.employer?.avatar_url || ""}
/>
<AvatarFallback className="bg-violet-500/20 text-violet-300">
{interview.employer?.full_name
? getInitials(interview.employer.full_name)
: "E"}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-violet-100">
Interview with{" "}
{interview.employer?.full_name || "Employer"}
</p>
<p className="text-sm text-slate-400">
{formatDate(interview.scheduled_at)} -{" "}
{interview.duration_minutes} min
</p>
</div>
</div>
<Badge
className={
interview.meeting_type === "video"
? "bg-blue-500/20 text-blue-300 border-blue-500/30"
: "bg-slate-700 text-slate-300"
}
>
{interview.meeting_type}
</Badge>
</div>
))}
</div>
)}
{upcomingInterviews.length > 0 && (
<Link href="/candidate/interviews">
<Button
variant="ghost"
className="w-full mt-4 text-violet-300 hover:text-violet-200 hover:bg-violet-500/10"
>
View All Interviews
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Pending Offers */}
{pendingOffers.length > 0 && (
<Card className="bg-gradient-to-br from-green-500/10 to-emerald-500/10 border-green-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-green-100 text-lg flex items-center gap-2">
<Gift className="h-5 w-5 text-green-400" />
Pending Offers
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{pendingOffers.slice(0, 2).map((offer) => (
<div
key={offer.id}
className="p-3 rounded-lg bg-slate-800/50 border border-green-500/20"
>
<p className="font-medium text-green-100">
{offer.position_title}
</p>
<p className="text-sm text-slate-400">
{offer.company_name}
</p>
{offer.offer_expiry && (
<p className="text-xs text-yellow-400 mt-1">
Expires {new Date(offer.offer_expiry).toLocaleDateString()}
</p>
)}
</div>
))}
<Link href="/candidate/offers">
<Button
size="sm"
className="w-full bg-green-600 hover:bg-green-700"
>
Review Offers
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Profile Summary */}
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="pb-3">
<CardTitle className="text-violet-100 text-lg flex items-center gap-2">
<User className="h-5 w-5 text-violet-400" />
Your Profile
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-slate-400 mb-1">Completeness</p>
<Progress
value={profileData?.profile?.profile_completeness || 0}
className="h-2"
/>
<p className="text-xs text-slate-500 mt-1">
{profileData?.profile?.profile_completeness || 0}%
</p>
</div>
{profileData?.profile?.availability && (
<div>
<p className="text-sm text-slate-400">Availability</p>
<Badge className="mt-1 bg-violet-500/20 text-violet-300 border-violet-500/30">
{getAvailabilityLabel(profileData.profile.availability)}
</Badge>
</div>
)}
{profileData?.profile?.skills &&
profileData.profile.skills.length > 0 && (
<div>
<p className="text-sm text-slate-400 mb-2">Skills</p>
<div className="flex flex-wrap gap-1">
{profileData.profile.skills.slice(0, 5).map((skill) => (
<Badge
key={skill}
variant="outline"
className="text-xs border-slate-600 text-slate-300"
>
{skill}
</Badge>
))}
{profileData.profile.skills.length > 5 && (
<Badge
variant="outline"
className="text-xs border-slate-600 text-slate-400"
>
+{profileData.profile.skills.length - 5}
</Badge>
)}
</div>
</div>
)}
<Link href="/candidate/profile">
<Button
variant="outline"
size="sm"
className="w-full border-violet-500/30 text-violet-300 hover:bg-violet-500/10"
>
Edit Profile
</Button>
</Link>
</CardContent>
</Card>
{/* Tips Card */}
<Card className="bg-slate-800/30 border-slate-700/30">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<div className="p-2 rounded bg-yellow-500/20 text-yellow-400">
<Star className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-violet-100 mb-1">
Pro Tip
</h3>
<p className="text-sm text-slate-400">
Candidates with complete profiles get 3x more
interview invitations. Make sure to add your skills
and work history!
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,981 @@
import { useState, useEffect } from "react";
import { Link } from "wouter";
import Layout from "@/components/Layout";
import SEO from "@/components/SEO";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Switch } from "@/components/ui/switch";
import {
User,
Briefcase,
GraduationCap,
Link as LinkIcon,
FileText,
ArrowLeft,
Plus,
Trash2,
Loader2,
Save,
CheckCircle2,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { aethexToast } from "@/components/ui/aethex-toast";
interface WorkHistory {
company: string;
position: string;
start_date: string;
end_date: string;
current: boolean;
description: string;
}
interface Education {
institution: string;
degree: string;
field: string;
start_year: number;
end_year: number;
current: boolean;
}
interface ProfileData {
headline: string;
bio: string;
resume_url: string;
portfolio_urls: string[];
work_history: WorkHistory[];
education: Education[];
skills: string[];
availability: string;
desired_rate: number;
rate_type: string;
location: string;
remote_preference: string;
is_public: boolean;
profile_completeness: number;
}
const DEFAULT_PROFILE: ProfileData = {
headline: "",
bio: "",
resume_url: "",
portfolio_urls: [],
work_history: [],
education: [],
skills: [],
availability: "",
desired_rate: 0,
rate_type: "hourly",
location: "",
remote_preference: "",
is_public: false,
profile_completeness: 0,
};
export default function CandidateProfile() {
const { session } = useAuth();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [profile, setProfile] = useState<ProfileData>(DEFAULT_PROFILE);
const [newSkill, setNewSkill] = useState("");
const [newPortfolio, setNewPortfolio] = useState("");
useEffect(() => {
if (session?.access_token) {
fetchProfile();
}
}, [session?.access_token]);
const fetchProfile = async () => {
try {
const response = await fetch("/api/candidate/profile", {
headers: { Authorization: `Bearer ${session?.access_token}` },
});
if (response.ok) {
const data = await response.json();
if (data.profile) {
setProfile({
...DEFAULT_PROFILE,
...data.profile,
portfolio_urls: Array.isArray(data.profile.portfolio_urls)
? data.profile.portfolio_urls
: [],
work_history: Array.isArray(data.profile.work_history)
? data.profile.work_history
: [],
education: Array.isArray(data.profile.education)
? data.profile.education
: [],
skills: Array.isArray(data.profile.skills)
? data.profile.skills
: [],
});
}
}
} catch (error) {
console.error("Error fetching profile:", error);
} finally {
setLoading(false);
}
};
const saveProfile = async () => {
if (!session?.access_token) return;
setSaving(true);
try {
const response = await fetch("/api/candidate/profile", {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(profile),
});
if (!response.ok) throw new Error("Failed to save profile");
const data = await response.json();
setProfile((prev) => ({
...prev,
profile_completeness: data.profile.profile_completeness,
}));
aethexToast.success("Profile saved successfully!");
} catch (error) {
console.error("Error saving profile:", error);
aethexToast.error("Failed to save profile");
} finally {
setSaving(false);
}
};
const addSkill = () => {
if (newSkill.trim() && !profile.skills.includes(newSkill.trim())) {
setProfile((prev) => ({
...prev,
skills: [...prev.skills, newSkill.trim()],
}));
setNewSkill("");
}
};
const removeSkill = (skill: string) => {
setProfile((prev) => ({
...prev,
skills: prev.skills.filter((s) => s !== skill),
}));
};
const addPortfolio = () => {
if (newPortfolio.trim() && !profile.portfolio_urls.includes(newPortfolio.trim())) {
setProfile((prev) => ({
...prev,
portfolio_urls: [...prev.portfolio_urls, newPortfolio.trim()],
}));
setNewPortfolio("");
}
};
const removePortfolio = (url: string) => {
setProfile((prev) => ({
...prev,
portfolio_urls: prev.portfolio_urls.filter((u) => u !== url),
}));
};
const addWorkHistory = () => {
setProfile((prev) => ({
...prev,
work_history: [
...prev.work_history,
{
company: "",
position: "",
start_date: "",
end_date: "",
current: false,
description: "",
},
],
}));
};
const updateWorkHistory = (index: number, field: string, value: any) => {
setProfile((prev) => ({
...prev,
work_history: prev.work_history.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
}));
};
const removeWorkHistory = (index: number) => {
setProfile((prev) => ({
...prev,
work_history: prev.work_history.filter((_, i) => i !== index),
}));
};
const addEducation = () => {
setProfile((prev) => ({
...prev,
education: [
...prev.education,
{
institution: "",
degree: "",
field: "",
start_year: new Date().getFullYear(),
end_year: new Date().getFullYear(),
current: false,
},
],
}));
};
const updateEducation = (index: number, field: string, value: any) => {
setProfile((prev) => ({
...prev,
education: prev.education.map((item, i) =>
i === index ? { ...item, [field]: value } : item,
),
}));
};
const removeEducation = (index: number) => {
setProfile((prev) => ({
...prev,
education: prev.education.filter((_, i) => i !== index),
}));
};
if (loading) {
return (
<Layout>
<SEO title="Edit Profile" description="Build your candidate profile" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-violet-400" />
</div>
</Layout>
);
}
return (
<Layout>
<SEO title="Edit Profile" description="Build your candidate profile" />
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* Background effects */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-20 left-10 w-96 h-96 bg-violet-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
</div>
<div className="relative z-10">
<div className="container mx-auto max-w-4xl px-4 py-16">
{/* Header */}
<div className="mb-8">
<Link href="/candidate">
<Button
variant="ghost"
size="sm"
className="text-violet-300 hover:text-violet-200 hover:bg-violet-500/10 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Dashboard
</Button>
</Link>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-violet-500/20 border border-violet-500/30">
<User className="h-6 w-6 text-violet-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-violet-100">
Edit Profile
</h1>
<p className="text-violet-200/70">
Build your candidate profile to stand out
</p>
</div>
</div>
<Button
onClick={saveProfile}
disabled={saving}
className="bg-violet-600 hover:bg-violet-700"
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
{/* Profile Completeness */}
<Card className="mt-6 bg-slate-800/50 border-violet-500/30">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-violet-100 font-medium">
Profile Completeness
</span>
<span className="text-violet-300 font-bold">
{profile.profile_completeness}%
</span>
</div>
<Progress value={profile.profile_completeness} className="h-2" />
{profile.profile_completeness === 100 && (
<div className="flex items-center gap-2 mt-2 text-green-400">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm">Profile complete!</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="basic" className="space-y-6">
<TabsList className="w-full bg-slate-800/50 border border-slate-700/50 p-1">
<TabsTrigger
value="basic"
className="flex-1 data-[state=active]:bg-violet-600"
>
<User className="h-4 w-4 mr-2" />
Basic Info
</TabsTrigger>
<TabsTrigger
value="experience"
className="flex-1 data-[state=active]:bg-violet-600"
>
<Briefcase className="h-4 w-4 mr-2" />
Experience
</TabsTrigger>
<TabsTrigger
value="education"
className="flex-1 data-[state=active]:bg-violet-600"
>
<GraduationCap className="h-4 w-4 mr-2" />
Education
</TabsTrigger>
<TabsTrigger
value="links"
className="flex-1 data-[state=active]:bg-violet-600"
>
<LinkIcon className="h-4 w-4 mr-2" />
Links
</TabsTrigger>
</TabsList>
{/* Basic Info Tab */}
<TabsContent value="basic">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100">
Basic Information
</CardTitle>
<CardDescription className="text-slate-400">
Your headline and summary
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label className="text-violet-200">Headline</Label>
<Input
value={profile.headline}
onChange={(e) =>
setProfile((prev) => ({
...prev,
headline: e.target.value,
}))
}
placeholder="e.g., Senior Full Stack Developer | React & Node.js"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Bio</Label>
<Textarea
value={profile.bio}
onChange={(e) =>
setProfile((prev) => ({ ...prev, bio: e.target.value }))
}
placeholder="Tell employers about yourself..."
rows={4}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Location</Label>
<Input
value={profile.location}
onChange={(e) =>
setProfile((prev) => ({
...prev,
location: e.target.value,
}))
}
placeholder="e.g., San Francisco, CA"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">
Remote Preference
</Label>
<Select
value={profile.remote_preference}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
remote_preference: value,
}))
}
>
<SelectTrigger className="bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue placeholder="Select preference" />
</SelectTrigger>
<SelectContent>
<SelectItem value="remote_only">
Remote Only
</SelectItem>
<SelectItem value="hybrid">Hybrid</SelectItem>
<SelectItem value="on_site">On-Site</SelectItem>
<SelectItem value="flexible">Flexible</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Availability</Label>
<Select
value={profile.availability}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
availability: value,
}))
}
>
<SelectTrigger className="bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue placeholder="Select availability" />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate">
Available Immediately
</SelectItem>
<SelectItem value="2_weeks">In 2 Weeks</SelectItem>
<SelectItem value="1_month">In 1 Month</SelectItem>
<SelectItem value="3_months">In 3 Months</SelectItem>
<SelectItem value="not_looking">
Not Currently Looking
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Desired Rate</Label>
<div className="flex gap-2">
<Input
type="number"
value={profile.desired_rate || ""}
onChange={(e) =>
setProfile((prev) => ({
...prev,
desired_rate: parseFloat(e.target.value) || 0,
}))
}
placeholder="0"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
<Select
value={profile.rate_type}
onValueChange={(value) =>
setProfile((prev) => ({
...prev,
rate_type: value,
}))
}
>
<SelectTrigger className="w-32 bg-slate-700/50 border-slate-600 text-slate-100">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">/hour</SelectItem>
<SelectItem value="monthly">/month</SelectItem>
<SelectItem value="yearly">/year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Skills */}
<div className="space-y-2">
<Label className="text-violet-200">Skills</Label>
<div className="flex gap-2">
<Input
value={newSkill}
onChange={(e) => setNewSkill(e.target.value)}
placeholder="Add a skill..."
className="bg-slate-700/50 border-slate-600 text-slate-100"
onKeyDown={(e) => e.key === "Enter" && addSkill()}
/>
<Button
onClick={addSkill}
variant="outline"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{profile.skills.map((skill) => (
<Badge
key={skill}
className="bg-violet-500/20 text-violet-300 border-violet-500/30"
>
{skill}
<button
onClick={() => removeSkill(skill)}
className="ml-2 hover:text-red-400"
>
&times;
</button>
</Badge>
))}
</div>
</div>
{/* Public Profile Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-700/30 border border-slate-600/30">
<div>
<p className="font-medium text-violet-100">
Public Profile
</p>
<p className="text-sm text-slate-400">
Allow employers to discover your profile
</p>
</div>
<Switch
checked={profile.is_public}
onCheckedChange={(checked) =>
setProfile((prev) => ({ ...prev, is_public: checked }))
}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Experience Tab */}
<TabsContent value="experience">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-violet-100">
Work Experience
</CardTitle>
<CardDescription className="text-slate-400">
Your professional background
</CardDescription>
</div>
<Button
onClick={addWorkHistory}
variant="outline"
size="sm"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4 mr-2" />
Add Experience
</Button>
</CardHeader>
<CardContent className="space-y-6">
{profile.work_history.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No work experience added yet
</p>
) : (
profile.work_history.map((work, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 space-y-4"
>
<div className="flex justify-between">
<h4 className="font-medium text-violet-100">
Position {index + 1}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => removeWorkHistory(index)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Company</Label>
<Input
value={work.company}
onChange={(e) =>
updateWorkHistory(
index,
"company",
e.target.value,
)
}
placeholder="Company name"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Position</Label>
<Input
value={work.position}
onChange={(e) =>
updateWorkHistory(
index,
"position",
e.target.value,
)
}
placeholder="Job title"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">
Start Date
</Label>
<Input
type="month"
value={work.start_date}
onChange={(e) =>
updateWorkHistory(
index,
"start_date",
e.target.value,
)
}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">End Date</Label>
<Input
type="month"
value={work.end_date}
onChange={(e) =>
updateWorkHistory(
index,
"end_date",
e.target.value,
)
}
disabled={work.current}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={work.current}
onCheckedChange={(checked) =>
updateWorkHistory(index, "current", checked)
}
/>
<Label className="text-violet-200">
I currently work here
</Label>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Description</Label>
<Textarea
value={work.description}
onChange={(e) =>
updateWorkHistory(
index,
"description",
e.target.value,
)
}
placeholder="Describe your responsibilities..."
rows={3}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
{/* Education Tab */}
<TabsContent value="education">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-violet-100">Education</CardTitle>
<CardDescription className="text-slate-400">
Your academic background
</CardDescription>
</div>
<Button
onClick={addEducation}
variant="outline"
size="sm"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4 mr-2" />
Add Education
</Button>
</CardHeader>
<CardContent className="space-y-6">
{profile.education.length === 0 ? (
<p className="text-slate-400 text-center py-8">
No education added yet
</p>
) : (
profile.education.map((edu, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-700/30 border border-slate-600/30 space-y-4"
>
<div className="flex justify-between">
<h4 className="font-medium text-violet-100">
Education {index + 1}
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => removeEducation(index)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Institution</Label>
<Input
value={edu.institution}
onChange={(e) =>
updateEducation(
index,
"institution",
e.target.value,
)
}
placeholder="University or school name"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">Degree</Label>
<Input
value={edu.degree}
onChange={(e) =>
updateEducation(index, "degree", e.target.value)
}
placeholder="e.g., Bachelor's, Master's"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">
Field of Study
</Label>
<Input
value={edu.field}
onChange={(e) =>
updateEducation(index, "field", e.target.value)
}
placeholder="e.g., Computer Science"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-violet-200">
Start Year
</Label>
<Input
type="number"
value={edu.start_year}
onChange={(e) =>
updateEducation(
index,
"start_year",
parseInt(e.target.value),
)
}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">End Year</Label>
<Input
type="number"
value={edu.end_year}
onChange={(e) =>
updateEducation(
index,
"end_year",
parseInt(e.target.value),
)
}
disabled={edu.current}
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={edu.current}
onCheckedChange={(checked) =>
updateEducation(index, "current", checked)
}
/>
<Label className="text-violet-200">
Currently studying
</Label>
</div>
</div>
))
)}
</CardContent>
</Card>
</TabsContent>
{/* Links Tab */}
<TabsContent value="links">
<Card className="bg-slate-800/50 border-slate-700/50">
<CardHeader>
<CardTitle className="text-violet-100">
Portfolio & Links
</CardTitle>
<CardDescription className="text-slate-400">
Your resume and portfolio links
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label className="text-violet-200">Resume URL</Label>
<Input
value={profile.resume_url}
onChange={(e) =>
setProfile((prev) => ({
...prev,
resume_url: e.target.value,
}))
}
placeholder="Link to your resume (Google Drive, Dropbox, etc.)"
className="bg-slate-700/50 border-slate-600 text-slate-100"
/>
</div>
<div className="space-y-2">
<Label className="text-violet-200">Portfolio Links</Label>
<div className="flex gap-2">
<Input
value={newPortfolio}
onChange={(e) => setNewPortfolio(e.target.value)}
placeholder="GitHub, Behance, personal website..."
className="bg-slate-700/50 border-slate-600 text-slate-100"
onKeyDown={(e) =>
e.key === "Enter" && addPortfolio()
}
/>
<Button
onClick={addPortfolio}
variant="outline"
className="border-violet-500/30 text-violet-300"
>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-2 mt-2">
{profile.portfolio_urls.map((url, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg bg-slate-700/30 border border-slate-600/30"
>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 truncate flex-1"
>
{url}
</a>
<Button
variant="ghost"
size="sm"
onClick={() => removePortfolio(url)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 ml-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Save Button (Bottom) */}
<div className="mt-6 flex justify-end">
<Button
onClick={saveProfile}
disabled={saving}
className="bg-violet-600 hover:bg-violet-700"
>
{saving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
</div>
</div>
</div>
</div>
</Layout>
);
}

View file

@ -0,0 +1,206 @@
-- Candidate Profiles Table
-- Extended profile data for job applicants
CREATE TABLE IF NOT EXISTS candidate_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
resume_url TEXT,
portfolio_urls JSONB DEFAULT '[]',
work_history JSONB DEFAULT '[]',
education JSONB DEFAULT '[]',
skills TEXT[] DEFAULT '{}',
availability TEXT CHECK (availability IN ('immediate', '2_weeks', '1_month', '3_months', 'not_looking')),
desired_rate DECIMAL(10,2),
rate_type TEXT CHECK (rate_type IN ('hourly', 'monthly', 'yearly')),
location TEXT,
remote_preference TEXT CHECK (remote_preference IN ('remote_only', 'hybrid', 'on_site', 'flexible')),
bio TEXT,
headline TEXT,
profile_completeness INTEGER DEFAULT 0,
is_public BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_candidate_profiles_user_id ON candidate_profiles(user_id);
CREATE INDEX idx_candidate_profiles_skills ON candidate_profiles USING GIN(skills);
CREATE INDEX idx_candidate_profiles_availability ON candidate_profiles(availability);
-- Enable RLS
ALTER TABLE candidate_profiles ENABLE ROW LEVEL SECURITY;
-- Candidates can view and update their own profile
CREATE POLICY "Candidates can view own profile"
ON candidate_profiles
FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Candidates can update own profile"
ON candidate_profiles
FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Candidates can insert own profile"
ON candidate_profiles
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Public profiles can be viewed by anyone
CREATE POLICY "Public profiles are viewable"
ON candidate_profiles
FOR SELECT
USING (is_public = TRUE);
-- Candidate Interviews Table
-- Tracks scheduled interviews between candidates and employers
CREATE TABLE IF NOT EXISTS candidate_interviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID,
candidate_id UUID REFERENCES auth.users(id),
employer_id UUID REFERENCES auth.users(id),
opportunity_id UUID,
scheduled_at TIMESTAMPTZ NOT NULL,
duration_minutes INTEGER DEFAULT 30,
meeting_link TEXT,
meeting_type TEXT DEFAULT 'video' CHECK (meeting_type IN ('video', 'phone', 'in_person')),
status TEXT DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'completed', 'cancelled', 'rescheduled', 'no_show')),
notes TEXT,
interviewer_notes TEXT,
candidate_feedback TEXT,
interviewer_feedback TEXT,
rating INTEGER CHECK (rating >= 1 AND rating <= 5),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_candidate_interviews_candidate_id ON candidate_interviews(candidate_id);
CREATE INDEX idx_candidate_interviews_employer_id ON candidate_interviews(employer_id);
CREATE INDEX idx_candidate_interviews_status ON candidate_interviews(status);
CREATE INDEX idx_candidate_interviews_scheduled_at ON candidate_interviews(scheduled_at);
-- Enable RLS
ALTER TABLE candidate_interviews ENABLE ROW LEVEL SECURITY;
-- Candidates can view their own interviews
CREATE POLICY "Candidates can view own interviews"
ON candidate_interviews
FOR SELECT
USING (auth.uid() = candidate_id);
-- Employers can view interviews they're part of
CREATE POLICY "Employers can view their interviews"
ON candidate_interviews
FOR SELECT
USING (auth.uid() = employer_id);
-- Candidates can update their feedback
CREATE POLICY "Candidates can update own interview feedback"
ON candidate_interviews
FOR UPDATE
USING (auth.uid() = candidate_id);
-- Employers can manage interviews
CREATE POLICY "Employers can manage interviews"
ON candidate_interviews
FOR ALL
USING (auth.uid() = employer_id);
-- Candidate Offers Table
-- Tracks job offers made to candidates
CREATE TABLE IF NOT EXISTS candidate_offers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
application_id UUID,
candidate_id UUID REFERENCES auth.users(id),
employer_id UUID REFERENCES auth.users(id),
opportunity_id UUID,
position_title TEXT NOT NULL,
company_name TEXT NOT NULL,
salary_amount DECIMAL(12,2),
salary_type TEXT CHECK (salary_type IN ('hourly', 'monthly', 'yearly', 'project')),
start_date DATE,
offer_expiry DATE,
benefits JSONB DEFAULT '[]',
offer_letter_url TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'declined', 'expired', 'withdrawn')),
candidate_response_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_candidate_offers_candidate_id ON candidate_offers(candidate_id);
CREATE INDEX idx_candidate_offers_status ON candidate_offers(status);
-- Enable RLS
ALTER TABLE candidate_offers ENABLE ROW LEVEL SECURITY;
-- Candidates can view their offers
CREATE POLICY "Candidates can view own offers"
ON candidate_offers
FOR SELECT
USING (auth.uid() = candidate_id);
-- Candidates can respond to offers
CREATE POLICY "Candidates can respond to offers"
ON candidate_offers
FOR UPDATE
USING (auth.uid() = candidate_id);
-- Employers can manage offers they created
CREATE POLICY "Employers can manage their offers"
ON candidate_offers
FOR ALL
USING (auth.uid() = employer_id);
-- Function to calculate profile completeness
CREATE OR REPLACE FUNCTION calculate_candidate_profile_completeness()
RETURNS TRIGGER AS $$
DECLARE
completeness INTEGER := 0;
BEGIN
-- Each section adds points
IF NEW.headline IS NOT NULL AND NEW.headline != '' THEN completeness := completeness + 10; END IF;
IF NEW.bio IS NOT NULL AND NEW.bio != '' THEN completeness := completeness + 10; END IF;
IF NEW.resume_url IS NOT NULL AND NEW.resume_url != '' THEN completeness := completeness + 20; END IF;
IF NEW.skills IS NOT NULL AND array_length(NEW.skills, 1) > 0 THEN completeness := completeness + 15; END IF;
IF NEW.work_history IS NOT NULL AND jsonb_array_length(NEW.work_history) > 0 THEN completeness := completeness + 15; END IF;
IF NEW.education IS NOT NULL AND jsonb_array_length(NEW.education) > 0 THEN completeness := completeness + 10; END IF;
IF NEW.portfolio_urls IS NOT NULL AND jsonb_array_length(NEW.portfolio_urls) > 0 THEN completeness := completeness + 10; END IF;
IF NEW.availability IS NOT NULL THEN completeness := completeness + 5; END IF;
IF NEW.location IS NOT NULL AND NEW.location != '' THEN completeness := completeness + 5; END IF;
NEW.profile_completeness := completeness;
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-calculate profile completeness
CREATE TRIGGER calculate_profile_completeness_trigger
BEFORE INSERT OR UPDATE ON candidate_profiles
FOR EACH ROW
EXECUTE FUNCTION calculate_candidate_profile_completeness();
-- Update timestamps trigger
CREATE OR REPLACE FUNCTION update_candidate_tables_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER candidate_interviews_updated_at
BEFORE UPDATE ON candidate_interviews
FOR EACH ROW
EXECUTE FUNCTION update_candidate_tables_updated_at();
CREATE TRIGGER candidate_offers_updated_at
BEFORE UPDATE ON candidate_offers
FOR EACH ROW
EXECUTE FUNCTION update_candidate_tables_updated_at();