From 0674a282b06c52e6f1f2f4d958b63924328e5fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 21:58:45 +0000 Subject: [PATCH] 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 --- api/candidate/interviews.ts | 196 ++++ api/candidate/offers.ts | 136 +++ api/candidate/profile.ts | 191 ++++ client/pages/candidate/CandidatePortal.tsx | 620 +++++++++++ client/pages/candidate/CandidateProfile.tsx | 981 ++++++++++++++++++ .../20260126_add_candidate_portal.sql | 206 ++++ 6 files changed, 2330 insertions(+) create mode 100644 api/candidate/interviews.ts create mode 100644 api/candidate/offers.ts create mode 100644 api/candidate/profile.ts create mode 100644 client/pages/candidate/CandidatePortal.tsx create mode 100644 client/pages/candidate/CandidateProfile.tsx create mode 100644 supabase/migrations/20260126_add_candidate_portal.sql diff --git a/api/candidate/interviews.ts b/api/candidate/interviews.ts new file mode 100644 index 00000000..21fd16fd --- /dev/null +++ b/api/candidate/interviews.ts @@ -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 = {}; + 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" }, + }); + } +}; diff --git a/api/candidate/offers.ts b/api/candidate/offers.ts new file mode 100644 index 00000000..3f597af6 --- /dev/null +++ b/api/candidate/offers.ts @@ -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" }, + }); + } +}; diff --git a/api/candidate/profile.ts b/api/candidate/profile.ts new file mode 100644 index 00000000..26779f2e --- /dev/null +++ b/api/candidate/profile.ts @@ -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" }, + }); + } +}; diff --git a/client/pages/candidate/CandidatePortal.tsx b/client/pages/candidate/CandidatePortal.tsx new file mode 100644 index 00000000..95afebd1 --- /dev/null +++ b/client/pages/candidate/CandidatePortal.tsx @@ -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(null); + const [upcomingInterviews, setUpcomingInterviews] = useState([]); + const [pendingOffers, setPendingOffers] = useState([]); + + 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 = { + 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 ( + + +
+ +
+
+ ); + } + + const stats = profileData?.stats || { + total_applications: 0, + pending: 0, + reviewed: 0, + accepted: 0, + rejected: 0, + }; + + return ( + + + +
+ {/* Background effects */} +
+
+
+
+ +
+
+ {/* Header */} +
+
+ + + + {profileData?.user?.full_name + ? getInitials(profileData.user.full_name) + : "U"} + + +
+

+ Welcome back + {profileData?.user?.full_name + ? `, ${profileData.user.full_name.split(" ")[0]}` + : ""} + ! +

+

+ {profileData?.profile?.headline || "Your career dashboard"} +

+
+
+
+ + + + + + +
+
+ + {/* Profile Completeness Alert */} + {profileData?.profile?.profile_completeness !== undefined && + profileData.profile.profile_completeness < 80 && ( + + +
+
+

+ Complete your profile to stand out +

+ +

+ {profileData.profile.profile_completeness}% complete +

+
+ + + +
+
+
+ )} + + {/* Stats Grid */} +
+ + +
+
+ +
+
+

+ {stats.total_applications} +

+

Applications

+
+
+
+
+ + +
+
+ +
+
+

+ {stats.pending} +

+

Pending

+
+
+
+
+ + +
+
+ +
+
+

+ {stats.reviewed} +

+

In Review

+
+
+
+
+ + +
+
+ +
+
+

+ {stats.accepted} +

+

Accepted

+
+
+
+
+ + +
+
+ +
+
+

+ {stats.rejected} +

+

Rejected

+
+
+
+
+
+ + {/* Main Content Grid */} +
+ {/* Quick Actions & Upcoming */} +
+ {/* Quick Actions */} +
+ + + +
+ +
+

+ My Applications +

+

+ Track all your job applications +

+
+
+ + + + +
+ +
+

+ Interviews +

+

+ View and manage scheduled interviews +

+
+
+ + + + +
+ +
+

+ Offers +

+

+ Review and respond to job offers +

+
+
+ + + + +
+ +
+

+ Browse Jobs +

+

+ Find new opportunities +

+
+
+ +
+ + {/* Upcoming Interviews */} + + + + + Upcoming Interviews + + + Your scheduled interviews + + + + {upcomingInterviews.length === 0 ? ( +

+ No upcoming interviews scheduled +

+ ) : ( +
+ {upcomingInterviews.slice(0, 3).map((interview) => ( +
+
+ + + + {interview.employer?.full_name + ? getInitials(interview.employer.full_name) + : "E"} + + +
+

+ Interview with{" "} + {interview.employer?.full_name || "Employer"} +

+

+ {formatDate(interview.scheduled_at)} -{" "} + {interview.duration_minutes} min +

+
+
+ + {interview.meeting_type} + +
+ ))} +
+ )} + {upcomingInterviews.length > 0 && ( + + + + )} +
+
+
+ + {/* Sidebar */} +
+ {/* Pending Offers */} + {pendingOffers.length > 0 && ( + + + + + Pending Offers + + + + {pendingOffers.slice(0, 2).map((offer) => ( +
+

+ {offer.position_title} +

+

+ {offer.company_name} +

+ {offer.offer_expiry && ( +

+ Expires {new Date(offer.offer_expiry).toLocaleDateString()} +

+ )} +
+ ))} + + + +
+
+ )} + + {/* Profile Summary */} + + + + + Your Profile + + + +
+

Completeness

+ +

+ {profileData?.profile?.profile_completeness || 0}% +

+
+ {profileData?.profile?.availability && ( +
+

Availability

+ + {getAvailabilityLabel(profileData.profile.availability)} + +
+ )} + {profileData?.profile?.skills && + profileData.profile.skills.length > 0 && ( +
+

Skills

+
+ {profileData.profile.skills.slice(0, 5).map((skill) => ( + + {skill} + + ))} + {profileData.profile.skills.length > 5 && ( + + +{profileData.profile.skills.length - 5} + + )} +
+
+ )} + + + +
+
+ + {/* Tips Card */} + + +
+
+ +
+
+

+ Pro Tip +

+

+ Candidates with complete profiles get 3x more + interview invitations. Make sure to add your skills + and work history! +

+
+
+
+
+
+
+
+
+
+ + ); +} diff --git a/client/pages/candidate/CandidateProfile.tsx b/client/pages/candidate/CandidateProfile.tsx new file mode 100644 index 00000000..b4369f8b --- /dev/null +++ b/client/pages/candidate/CandidateProfile.tsx @@ -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(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 ( + + +
+ +
+
+ ); + } + + return ( + + + +
+ {/* Background effects */} +
+
+
+
+ +
+
+ {/* Header */} +
+ + + + +
+
+
+ +
+
+

+ Edit Profile +

+

+ Build your candidate profile to stand out +

+
+
+ +
+ + {/* Profile Completeness */} + + +
+ + Profile Completeness + + + {profile.profile_completeness}% + +
+ + {profile.profile_completeness === 100 && ( +
+ + Profile complete! +
+ )} +
+
+
+ + {/* Tabs */} + + + + + Basic Info + + + + Experience + + + + Education + + + + Links + + + + {/* Basic Info Tab */} + + + + + Basic Information + + + Your headline and summary + + + +
+ + + 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" + /> +
+ +
+ +