iris_backend / src /components /CandidateDrawer.jsx
Muhammed Sameer
Safely configure API URLs for production fallback
d998071
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ExternalLink, ThumbsUp, ThumbsDown, BrainCircuit } from 'lucide-react';
import FullProfileOverlay from './FullProfileOverlay';
import { saveAs } from 'file-saver';
import { supabase } from '../supabaseClient';
const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
const [showFullProfile, setShowFullProfile] = useState(false);
const [analysis, setAnalysis] = useState(null);
const [loadingAnalysis, setLoadingAnalysis] = useState(false);
React.useEffect(() => {
if (isOpen && candidate?.userId && !analysis) {
fetchAnalysis();
}
}, [isOpen, candidate, analysis]);
// Reset analysis when candidate changes
React.useEffect(() => {
setAnalysis(null);
}, [candidate?.id]);
const fetchAnalysis = async () => {
setLoadingAnalysis(true);
try {
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const response = await fetch(`${API_URL}/analyze-candidate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
candidate_id: candidate.userId,
job_id: candidate.jobId
})
});
const result = await response.json();
if (result.status === 'success') {
setAnalysis(result.data);
}
} catch (error) {
console.error("Failed to fetch analysis:", error);
} finally {
setLoadingAnalysis(false);
}
};
const handleDownloadCV = async () => {
let url = candidate.resumeUrl;
if (Array.isArray(url)) url = url[0];
url = (url || '').trim();
if (!url) {
alert("No resume available for this candidate.");
return;
}
try {
if (url.startsWith('http')) {
const response = await fetch(url);
const blob = await response.blob();
saveAs(blob, `${candidate.name.replace(/\s+/g, '_')}_Resume.pdf`);
return;
}
// 1. Try common bucket and path combinations
const tryDownload = async (path) => {
const { data, error } = await supabase.storage.from('resume').createSignedUrl(path, 60);
if (!error && data?.signedUrl) return data.signedUrl;
// Try 'resumes' plural bucket too
const fallback = await supabase.storage.from('resumes').createSignedUrl(path, 60);
if (!fallback.error && fallback.data?.signedUrl) return fallback.data.signedUrl;
return null;
};
// Path strategy 1: Use as stored
let signedUrl = await tryDownload(url);
// Path strategy 2: If no folder in path, try prepending userID (as per user clarification)
if (!signedUrl && !url.includes('/') && candidate.userId) {
const prefixedPath = `${candidate.userId}/${url}`;
console.log(`Retrying with userID prefix: ${prefixedPath}`);
signedUrl = await tryDownload(prefixedPath);
}
if (signedUrl) {
window.open(signedUrl, '_blank');
} else {
throw new Error("File not found in storage");
}
} catch (error) {
console.error("Download failed:", error);
// 2. Final Fallback: Attempt Public URL (best guess)
try {
const { data: { publicUrl } } = supabase.storage.from('resume').getPublicUrl(url);
window.open(publicUrl, '_blank');
} catch (finalError) {
alert(`Could not access the file. Please ensure the resume is in the 'resume' bucket inside a folder named '${candidate.userId}'.`);
}
}
};
if (!candidate) return null;
const summary = analysis?.summary || candidate.summary || "No summary available.";
const strengths = analysis?.strengths || candidate.insights?.strengths || [];
const weaknesses = analysis?.weaknesses || candidate.insights?.weaknesses || [];
const missingSkills = analysis?.missing_skills || [];
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.6)',
backdropFilter: 'blur(4px)',
zIndex: 40
}}
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100%',
width: '100%',
maxWidth: '500px',
backgroundColor: '#0f172a',
backgroundImage: `
radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.25) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(239, 68, 68, 0.25) 0px, transparent 50%),
linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 100%)
`,
borderLeft: '1px solid rgba(255,255,255,0.1)',
zIndex: 50,
overflowY: 'auto',
boxShadow: '-10px 0 25px rgba(0,0,0,0.5)'
}}
>
<div style={{ padding: '2rem' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '2rem' }}>
<div>
<h2 style={{ fontSize: '1.75rem', fontWeight: 'bold', color: 'white' }}>{candidate.name}</h2>
<p style={{ color: '#94a3b8' }}>{candidate.jobTitle || candidate.role} • {candidate.experience} </p>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
<X size={24} />
</button>
</div>
<div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<span style={{ fontWeight: 'bold', color: '#EF4444' }}>{analysis?.score !== undefined ? 'Gemini AI Score' : 'Semantic Match Score'}</span>
<span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%</span>
</div>
<div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%` }}
transition={{ delay: 0.2, duration: 1 }}
style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }}
/>
</div>
</div>
{loadingAnalysis && (
<div style={{ padding: '1rem', textAlign: 'center', color: '#EF4444', fontSize: '0.9rem' }}>
Analyzing candidate with AI...
</div>
)}
{/* AI Insights Section */}
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '1rem' }}>
<BrainCircuit size={18} color="#EF4444" /> AI Insights
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{strengths.map((str, i) => (
<div key={`str-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
<ThumbsUp size={16} color="#34d399" style={{ marginTop: '3px', flexShrink: 0 }} />
<span>{str}</span>
</div>
))}
{weaknesses.map((wk, i) => (
<div key={`wk-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
<ThumbsDown size={16} color="#fb7185" style={{ marginTop: '3px', flexShrink: 0 }} />
<span>{wk}</span>
</div>
))}
</div>
</div>
{/* Missing Skills Section */}
{missingSkills.length > 0 && (
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Missing Skills (Gap Analysis)</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{missingSkills.map((skill, i) => (
<span key={i} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#EF4444', padding: '4px 10px', borderRadius: '4px', fontSize: '0.8rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
{skill}
</span>
))}
</div>
</div>
)}
{/* Resume Summary */}
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>AI Professional Summary</h3>
<p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#94a3b8', backgroundColor: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}>
{summary}
</p>
</div>
{/* Portfolio & Projects */}
<div style={{ marginBottom: '2rem' }}>
<h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Portfolio & Projects</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
{candidate.projects?.map((project, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.85rem', color: 'white', backgroundColor: 'rgba(255,255,255,0.1)', padding: '0.4rem 0.8rem', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)', cursor: 'default' }}>
{typeof project === 'object' ? (project.title || project.name) : project} <ExternalLink size={12} />
</div>
))}
</div>
</div>
{/* Footer Actions */}
<div style={{ marginTop: '3rem', paddingTop: '1.5rem', borderTop: '1px solid rgba(255,255,255,0.1)', display: 'flex', gap: '1rem' }}>
<button
onClick={() => setShowFullProfile(true)}
style={{ flex: 1, padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: '#EF4444', color: 'white', border: 'none', fontWeight: '600', cursor: 'pointer', boxShadow: '0 4px 12px rgba(239, 68, 68, 0.3)' }}
>
Full Profile
</button>
<button
onClick={handleDownloadCV}
style={{ flex: 1, padding: '0.75rem', borderRadius: '0.5rem', backgroundColor: 'transparent', color: 'white', border: '1px solid rgba(255,255,255,0.2)', fontWeight: '600', cursor: 'pointer' }}
>
Download CV
</button>
</div>
</div>
</motion.div>
{showFullProfile && (
<AnimatePresence>
<FullProfileOverlay
candidate={candidate}
onClose={() => setShowFullProfile(false)}
/>
</AnimatePresence>
)}
</>
)}
</AnimatePresence>
);
};
export default CandidateDrawer;