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:
parent
0136d3d8a4
commit
0674a282b0
6 changed files with 2330 additions and 0 deletions
196
api/candidate/interviews.ts
Normal file
196
api/candidate/interviews.ts
Normal 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
136
api/candidate/offers.ts
Normal 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
191
api/candidate/profile.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
};
|
||||
620
client/pages/candidate/CandidatePortal.tsx
Normal file
620
client/pages/candidate/CandidatePortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
981
client/pages/candidate/CandidateProfile.tsx
Normal file
981
client/pages/candidate/CandidateProfile.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
206
supabase/migrations/20260126_add_candidate_portal.sql
Normal file
206
supabase/migrations/20260126_add_candidate_portal.sql
Normal 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();
|
||||
Loading…
Reference in a new issue