Initial commit
This commit is contained in:
451
src/components/krow/AIInterviewModal.jsx
Normal file
451
src/components/krow/AIInterviewModal.jsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
|
||||
import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TOTAL_SECONDS = 180;
|
||||
const QUESTION_COUNT = 5;
|
||||
|
||||
export default function AIInterviewModal({ open, onClose, application, job, conductor = 'KROW' }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [phase, setPhase] = useState('intro'); // intro | active | evaluating | done
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [transcript, setTranscript] = useState('');
|
||||
const [timeLeft, setTimeLeft] = useState(TOTAL_SECONDS);
|
||||
const [evaluation, setEvaluation] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [language, setLanguage] = useState('en'); // 'en' | 'es'
|
||||
|
||||
const recognitionRef = useRef(null);
|
||||
const lastUserMessageTime = useRef(null);
|
||||
const timerRef = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
const createInterview = useCreateInterview();
|
||||
const updateApplication = useUpdateApplication();
|
||||
|
||||
const jobTitle = job?.title || 'the role';
|
||||
const candidateName = application?.applicant_name || 'Candidate';
|
||||
const isOwliver = conductor === 'Owliver';
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const speak = useCallback((text) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!ttsEnabled || !window.speechSynthesis) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
const targetLang = language === 'es' ? 'es' : 'en';
|
||||
utterance.lang = language === 'es' ? 'es-ES' : 'en-US';
|
||||
|
||||
// Pick the best available voice for the target language
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(targetLang));
|
||||
if (langVoices.length > 0) {
|
||||
// Prefer native/local voices, then Google voices, then any
|
||||
const preferred =
|
||||
langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
|
||||
langVoices.find(v => v.localService) ||
|
||||
langVoices.find(v => v.name.toLowerCase().includes('google')) ||
|
||||
langVoices.find(v => v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
|
||||
langVoices[0];
|
||||
utterance.voice = preferred;
|
||||
}
|
||||
|
||||
utterance.rate = language === 'es' ? 0.98 : 1.05;
|
||||
utterance.pitch = 1.0;
|
||||
utterance.onstart = () => setIsSpeaking(true);
|
||||
utterance.onend = () => { setIsSpeaking(false); resolve(); };
|
||||
utterance.onerror = () => { setIsSpeaking(false); resolve(); };
|
||||
window.speechSynthesis.speak(utterance);
|
||||
});
|
||||
}, [ttsEnabled, language]);
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const askNextQuestion = useCallback(async (currentMessages) => {
|
||||
const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1;
|
||||
if (qNumber > QUESTION_COUNT) {
|
||||
await finishInterview(currentMessages);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const question = await generateInterviewQuestion(currentMessages, jobTitle, qNumber, language);
|
||||
const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() };
|
||||
const newMessages = [...currentMessages, aiMsg];
|
||||
setMessages(newMessages);
|
||||
lastUserMessageTime.current = Date.now();
|
||||
await speak(question);
|
||||
} catch (e) {
|
||||
setError('Interview connection issue. Please retry.');
|
||||
}
|
||||
}, [jobTitle, speak, language]);
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
setError('Voice input not supported in this browser. Use text mode below.');
|
||||
return;
|
||||
}
|
||||
// Preserve existing transcript so the user can continue speaking
|
||||
const baseTranscript = transcript;
|
||||
if (!baseTranscript) setTranscript('');
|
||||
setError('');
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.lang = language === 'es' ? 'es-ES' : 'en-US';
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let text = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
text += event.results[i][0].transcript;
|
||||
}
|
||||
setTranscript((baseTranscript ? baseTranscript + ' ' : '') + text.trim());
|
||||
};
|
||||
recognition.onerror = (event) => {
|
||||
if (event.error !== 'no-speech') setError(`Mic error: ${event.error}`);
|
||||
setIsListening(false);
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [language, transcript]);
|
||||
|
||||
const submitAnswer = useCallback(async () => {
|
||||
if (!transcript.trim()) return;
|
||||
stopListening();
|
||||
const responseTime = lastUserMessageTime.current ? (Date.now() - lastUserMessageTime.current) / 1000 : 0;
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: transcript.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
response_time_seconds: Math.round(responseTime * 10) / 10,
|
||||
};
|
||||
const newMessages = [...messages, userMsg];
|
||||
setMessages(newMessages);
|
||||
setTranscript('');
|
||||
// Ask next question
|
||||
setTimeout(() => askNextQuestion(newMessages), 300);
|
||||
}, [transcript, messages, stopListening, askNextQuestion]);
|
||||
|
||||
const finishInterview = useCallback(async (finalMessages) => {
|
||||
setPhase('evaluating');
|
||||
try {
|
||||
const result = await evaluateInterview(finalMessages, job, candidateName, language);
|
||||
const interviewRecord = await createInterview.mutateAsync({
|
||||
application_id: application.id,
|
||||
job_posting_id: application.job_posting_id,
|
||||
job_title: jobTitle,
|
||||
candidate_name: candidateName,
|
||||
messages: finalMessages,
|
||||
overall_interview_score: result.overall_interview_score,
|
||||
verdict: result.verdict,
|
||||
hire_recommendation: result.hire_recommendation,
|
||||
integrity_score: result.integrity_score,
|
||||
ai_flags: result.ai_flags,
|
||||
category_scores: result.category_scores,
|
||||
strengths: result.strengths,
|
||||
concerns: result.concerns,
|
||||
best_fit_roles: result.best_fit_roles,
|
||||
summary: result.summary,
|
||||
reasoning: result.reasoning,
|
||||
});
|
||||
await updateApplication.mutateAsync({ id: application.id, data: {
|
||||
status: 'interview',
|
||||
interview_id: interviewRecord.id,
|
||||
ai_score: result.overall_interview_score,
|
||||
} });
|
||||
setEvaluation(result);
|
||||
setPhase('done');
|
||||
} catch (e) {
|
||||
setError('Evaluation failed. Please try again.');
|
||||
setPhase('active');
|
||||
}
|
||||
}, [application, job, candidateName, createInterview, updateApplication, language]);
|
||||
|
||||
const startInterview = useCallback(() => {
|
||||
setPhase('active');
|
||||
setTimeLeft(TOTAL_SECONDS);
|
||||
setMessages([]);
|
||||
askNextQuestion([]);
|
||||
}, [askNextQuestion]);
|
||||
|
||||
// Timer
|
||||
useEffect(() => {
|
||||
if (phase !== 'active') return;
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timerRef.current);
|
||||
stopListening();
|
||||
if (messages.length > 0) finishInterview(messages);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}, [phase, messages, finishInterview, stopListening]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopListening();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [stopListening]);
|
||||
|
||||
const handleClose = () => {
|
||||
stopListening();
|
||||
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
setPhase('intro');
|
||||
setMessages([]);
|
||||
setEvaluation(null);
|
||||
setError('');
|
||||
setTimeLeft(TOTAL_SECONDS);
|
||||
setLanguage('en');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mins = Math.floor(timeLeft / 60);
|
||||
const secs = timeLeft % 60;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
{isOwliver ? (
|
||||
<OwliverAvatar className="w-8 h-8" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold">K</div>
|
||||
)}
|
||||
AI Interview with {conductor}
|
||||
</span>
|
||||
{phase === 'active' && (
|
||||
<span className={cn('text-[13px] font-mono px-2 py-1 rounded-lg', timeLeft < 30 ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151]')}>
|
||||
{mins}:{secs.toString().padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === 'intro' && (
|
||||
<div className="py-6 text-center">
|
||||
{isOwliver ? (
|
||||
<OwliverAvatar className="w-16 h-16 mx-auto mb-4" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-2xl font-bold mx-auto mb-4">K</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-[#111827] mb-2">Hi {candidateName.split(' ')[0]}, I'm {conductor}</h3>
|
||||
<p className="text-[14px] text-[#6B7280] max-w-md mx-auto mb-6">
|
||||
{language === 'es'
|
||||
? `Te haré ${QUESTION_COUNT} preguntas rápidas sobre tu experiencia en roles de ${jobTitle}. Toma unos 3 minutos. Solo habla naturalmente — no hay respuestas correctas o incorrectas.`
|
||||
: `I'll ask you ${QUESTION_COUNT} quick questions about your experience with ${jobTitle} roles. This takes about 3 minutes. Just speak naturally — there are no right or wrong answers.`}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3 mb-4 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1"><Volume2 className="w-3.5 h-3.5" /> Voice enabled</span>
|
||||
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="text-[#0A39DF] underline">
|
||||
{ttsEnabled ? 'Mute' : 'Unmute'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Language selector */}
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<span className="text-[12px] text-[#6B7280] font-medium">Interview language:</span>
|
||||
<div className="inline-flex rounded-lg border border-[#E5E7EB] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-[12px] font-medium transition-colors',
|
||||
language === 'en' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('es')}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-[12px] font-medium transition-colors',
|
||||
language === 'es' ? 'bg-[#0A39DF] text-white' : 'bg-white text-[#6B7280] hover:bg-[#F3F4F6]'
|
||||
)}
|
||||
>
|
||||
Español
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={startInterview} className="bg-[#0A39DF] hover:bg-[#082EB4] px-8">
|
||||
<Mic className="w-4 h-4 mr-2" /> Start Interview
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'active' && (
|
||||
<div className="space-y-4">
|
||||
<div ref={scrollRef} className="h-[300px] overflow-y-auto space-y-3 p-2">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||
<div className={cn(
|
||||
'max-w-[80%] rounded-2xl px-4 py-2.5 text-[14px]',
|
||||
msg.role === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-sm'
|
||||
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
|
||||
)}>
|
||||
<p>{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response flagged
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isSpeaking && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
|
||||
|
||||
{/* Answer input */}
|
||||
<div className="border-t border-[#E5E7EB] pt-3">
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(e) => setTranscript(e.target.value)}
|
||||
placeholder={isListening ? 'Listening...' : 'Type your answer or click mic to speak'}
|
||||
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitAnswer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<button
|
||||
onClick={isListening ? stopListening : startListening}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg text-[13px] font-medium transition-colors',
|
||||
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#F3F4F6] text-[#374151] hover:bg-[#E5E7EB]'
|
||||
)}
|
||||
>
|
||||
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
|
||||
{isListening ? 'Stop' : 'Speak'}
|
||||
</button>
|
||||
<Button
|
||||
onClick={submitAnswer}
|
||||
disabled={!transcript.trim()}
|
||||
className="bg-[#0A39DF] hover:bg-[#082EB4]"
|
||||
>
|
||||
Send Answer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'evaluating' && (
|
||||
<div className="py-12 text-center">
|
||||
<Loader2 className="w-10 h-10 text-[#0A39DF] animate-spin mx-auto mb-4" />
|
||||
<p className="text-[14px] text-[#6B7280]">{conductor} is evaluating your interview...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && evaluation && (
|
||||
<div className="py-4 space-y-4">
|
||||
<div className="text-center">
|
||||
<div className={cn(
|
||||
'w-16 h-16 rounded-full mx-auto flex items-center justify-center text-white text-xl font-bold mb-3',
|
||||
evaluation.verdict === 'hire' ? 'bg-[#059669]' : evaluation.verdict === 'maybe' ? 'bg-[#F59E0B]' : 'bg-[#EF4444]'
|
||||
)}>
|
||||
{evaluation.overall_interview_score}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#111827] capitalize">Verdict: {evaluation.verdict}</h3>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">{evaluation.hire_recommendation}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{evaluation.category_scores && Object.entries(evaluation.category_scores).map(([key, val]) => (
|
||||
<div key={key} className="bg-[#F9FAFB] rounded-lg p-3">
|
||||
<div className="text-[11px] text-[#6B7280] capitalize">{key.replace(/_/g, ' ')}</div>
|
||||
<div className="text-lg font-bold text-[#111827]">{val}/100</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{evaluation.integrity_score < 100 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {evaluation.integrity_score}/100</p>
|
||||
{evaluation.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{evaluation.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-1">Summary</p>
|
||||
<p className="text-[13px] text-[#6B7280]">{evaluation.summary}</p>
|
||||
</div>
|
||||
|
||||
{evaluation.strengths?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#059669] mb-1 flex items-center gap-1"><CheckCircle2 className="w-3.5 h-3.5" /> Strengths</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.strengths.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{evaluation.concerns?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#EF4444] mb-1 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" /> Concerns</p>
|
||||
<ul className="text-[13px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{evaluation.concerns.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleClose} className="w-full bg-[#0A39DF] hover:bg-[#082EB4]">Done</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
107
src/components/krow/ActivityCharts.jsx
Normal file
107
src/components/krow/ActivityCharts.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
const PIE_COLORS = ['#0A39DF', '#059669', '#333F48', '#D97706', '#DC2626', '#0891B2', '#DB2777'];
|
||||
|
||||
function last7Days() {
|
||||
const days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
export default function ActivityCharts({ activities }) {
|
||||
const timeSeries = useMemo(() => {
|
||||
const days = last7Days();
|
||||
return days.map((day) => {
|
||||
const dayActivities = activities.filter((a) => (a.created_date || '').slice(0, 10) === day);
|
||||
return {
|
||||
date: day.slice(5),
|
||||
signups: dayActivities.filter((a) => a.event_type === 'signup').length,
|
||||
logins: dayActivities.filter((a) => a.event_type === 'login').length,
|
||||
actions: dayActivities.length,
|
||||
};
|
||||
});
|
||||
}, [activities]);
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const counts = {};
|
||||
activities.forEach((a) => {
|
||||
counts[a.event_type] = (counts[a.event_type] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts).map(([name, value]) => ({ name, value }));
|
||||
}, [activities]);
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const counts = {};
|
||||
activities.forEach((a) => {
|
||||
counts[a.account_type] = (counts[a.account_type] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts).map(([name, value]) => ({ name, value }));
|
||||
}, [activities]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Activity (Last 7 Days)</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={timeSeries}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="signups" stroke="#0A39DF" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="logins" stroke="#059669" strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="actions" stroke="#D97706" strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Actions by Type</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={byType}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} stroke="#9CA3AF" interval={0} angle={-20} textAnchor="end" height={50} />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#0A39DF" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827] mb-4">Users by Role</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie data={byRole} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
|
||||
{byRole.map((_, i) => (
|
||||
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/krow/ActivityLogTable.jsx
Normal file
131
src/components/krow/ActivityLogTable.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
const EVENT_LABELS = {
|
||||
signup: 'Sign Up',
|
||||
login: 'Login',
|
||||
logout: 'Logout',
|
||||
create_position: 'Create Position',
|
||||
screen_candidate: 'Screen Candidate',
|
||||
hire_candidate: 'Hire Candidate',
|
||||
apply_job: 'Apply to Job',
|
||||
start_interview: 'Start Interview',
|
||||
};
|
||||
|
||||
const EVENT_COLORS = {
|
||||
signup: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
login: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
logout: 'bg-[#F3F4F6] text-[#374151]',
|
||||
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
|
||||
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
|
||||
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
|
||||
apply_job: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
|
||||
};
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ActivityLogTable({ activities }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = activities.filter((a) => {
|
||||
if (filter !== 'all' && a.event_type !== filter) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
(a.user_email || '').toLowerCase().includes(q) ||
|
||||
(a.user_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const eventTypes = ['all', ...Object.keys(EVENT_LABELS)];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#E5E7EB] overflow-hidden">
|
||||
<div className="p-4 border-b border-[#E5E7EB] flex flex-col sm:flex-row gap-3 sm:items-center justify-between">
|
||||
<h3 className="text-[14px] font-semibold text-[#111827]">Activity Log</h3>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#9CA3AF]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search user..."
|
||||
className="pl-8 pr-3 h-8 text-[12px] rounded-md border border-[#E5E7EB] focus:border-[#2563EB] focus:outline-none w-40"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="h-8 text-[12px] rounded-md border border-[#E5E7EB] bg-white px-2 focus:border-[#2563EB] focus:outline-none"
|
||||
>
|
||||
{eventTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Events' : EVENT_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="bg-[#F9FAFB] text-[#6B7280]">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Event</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">User</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Role</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Details</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#F3F4F6]">
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-[#9CA3AF]">
|
||||
No activity found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.slice(0, 50).map((a) => (
|
||||
<tr key={a.id} className="hover:bg-[#F9FAFB]">
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
EVENT_COLORS[a.event_type] || 'bg-[#F3F4F6] text-[#374151]'
|
||||
}`}
|
||||
>
|
||||
{EVENT_LABELS[a.event_type] || a.event_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="font-medium text-[#111827]">{a.user_name || '—'}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF]">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 capitalize text-[#6B7280]">{a.account_type || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] max-w-[200px] truncate">
|
||||
{a.details || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[#6B7280] whitespace-nowrap">
|
||||
{formatTime(a.created_date)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/krow/ActivityMetrics.jsx
Normal file
41
src/components/krow/ActivityMetrics.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Users, LogIn, Activity, UserCheck } from 'lucide-react';
|
||||
|
||||
export default function ActivityMetrics({ activities }) {
|
||||
const now = Date.now();
|
||||
const dayAgo = now - 24 * 60 * 60 * 1000;
|
||||
|
||||
const signups = activities.filter((a) => a.event_type === 'signup');
|
||||
const logins = activities.filter((a) => a.event_type === 'login');
|
||||
const logins24h = logins.filter((a) => new Date(a.created_date).getTime() > dayAgo);
|
||||
const recentUsers = [...signups, ...logins].filter(
|
||||
(a) => new Date(a.created_date).getTime() > dayAgo
|
||||
);
|
||||
const activeUsers24h = new Set(recentUsers.map((a) => a.user_email)).size;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Total Sign-ups', value: signups.length, icon: UserCheck, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Logins (24h)', value: logins24h.length, icon: LogIn, color: '#059669', bg: '#ECFDF5' },
|
||||
{ label: 'Active Users (24h)', value: activeUsers24h, icon: Users, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Total Actions', value: activities.length, icon: Activity, color: '#D97706', bg: '#FFFBEB' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{metrics.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<div key={m.label} className="bg-white rounded-xl border border-[#E5E7EB] p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-9 h-9 rounded-lg flex items-center justify-center" style={{ background: m.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: m.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-[#111827]">{m.value}</div>
|
||||
<div className="text-[12px] text-[#6B7280] mt-0.5">{m.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/krow/ActivitySnapshot.jsx
Normal file
28
src/components/krow/ActivitySnapshot.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
const SNAPSHOTS = [
|
||||
{ key: 'krow', label: 'KROW Score', get: (p) => Math.round(p.krow_score || 0) },
|
||||
{ key: 'badges', label: 'Other Badges', get: (p) => (p.earned_badges || []).length },
|
||||
{ key: 'skills', label: 'Skills', get: (p) => (p.skills || []).length },
|
||||
{ key: 'shares', label: 'Shares', get: () => 0 },
|
||||
];
|
||||
|
||||
export default function ActivitySnapshot({ profile }) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#E8EEFE] p-6 border border-[#C7D4F7]">
|
||||
<h3 className="font-serif text-center text-[18px] font-semibold mb-5" style={{ color: '#333F48' }}>
|
||||
Activity snapshot
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
{SNAPSHOTS.map((s) => (
|
||||
<div key={s.key} className="bg-white rounded-2xl shadow-sm p-4 flex flex-col items-center justify-center aspect-square">
|
||||
<span className="text-[34px] font-bold leading-none" style={{ color: '#0A39DF' }}>
|
||||
{s.get(profile)}
|
||||
</span>
|
||||
<span className="text-[12px] text-[#333F48] mt-2 text-center">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/krow/AnalyticsMetrics.jsx
Normal file
38
src/components/krow/AnalyticsMetrics.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Users, TrendingUp, Brain, Mic, UserCheck, Target } from 'lucide-react';
|
||||
|
||||
export default function AnalyticsMetrics({ applications, interviews, staff }) {
|
||||
const totalApplicants = applications.length;
|
||||
const screened = applications.filter(a => a.status !== 'applied');
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
const interviewed = applications.filter(a => a.status === 'interview' || a.interview_id);
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const hireRate = totalApplicants ? Math.round((hired.length / totalApplicants) * 100) : 0;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Total Applicants', value: totalApplicants, icon: Users, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Avg AI Score', value: avgScore, icon: Brain, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Interviews Done', value: interviewed.length, icon: Mic, color: '#0A39DF', bg: '#FEFCE8' },
|
||||
{ label: 'Hired', value: hired.length, icon: UserCheck, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Hire Rate', value: `${hireRate}%`, icon: Target, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'On Staff', value: staff.length, icon: TrendingUp, color: '#333F48', bg: '#D1E0D7' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{metrics.map((m) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<div key={m.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
|
||||
<div className="w-9 h-9 rounded-xl flex items-center justify-center mb-3" style={{ background: m.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: m.color }} />
|
||||
</div>
|
||||
<div className="text-2xl font-heading font-bold text-[#111827]">{m.value}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide mt-0.5">{m.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
src/components/krow/ApplicantForm.jsx
Normal file
296
src/components/krow/ApplicantForm.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Loader2, UploadCloud, FileCheck2, X, Camera, RefreshCw } from 'lucide-react';
|
||||
import { useCertifications } from '@/lib/krowHooks';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
const DEFAULT_CERTS = ['Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared'];
|
||||
const AVAIL_OPTIONS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
|
||||
const ENGLISH_LEVELS = ['basic', 'conversational', 'fluent', 'native'];
|
||||
|
||||
export default function ApplicantForm({ onSubmit, submitting }) {
|
||||
const [form, setForm] = useState({
|
||||
applicant_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
years_experience: 0,
|
||||
english_level: 'basic',
|
||||
certifications: [],
|
||||
availability: [],
|
||||
skills: [],
|
||||
professional_summary: '',
|
||||
cover_letter: '',
|
||||
certification_files: [],
|
||||
selfie_url: '',
|
||||
companies_worked: [],
|
||||
});
|
||||
const [customSkill, setCustomSkill] = useState('');
|
||||
const [customCompany, setCustomCompany] = useState('');
|
||||
const [uploadingSelfie, setUploadingSelfie] = useState(false);
|
||||
const [uploadingCert, setUploadingCert] = useState(false);
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const { data: certifications = [] } = useCertifications();
|
||||
const certOptions = certifications.length > 0 ? certifications.map(c => c.name) : DEFAULT_CERTS;
|
||||
|
||||
const update = (field, value) => setForm({ ...form, [field]: value });
|
||||
|
||||
const toggleCert = (cert) => {
|
||||
update('certifications', form.certifications.includes(cert)
|
||||
? form.certifications.filter(c => c !== cert)
|
||||
: [...form.certifications, cert]);
|
||||
};
|
||||
|
||||
const toggleAvail = (slot) => {
|
||||
update('availability', form.availability.includes(slot)
|
||||
? form.availability.filter(a => a !== slot)
|
||||
: [...form.availability, slot]);
|
||||
};
|
||||
|
||||
const handleCertUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
setUploadingCert(true);
|
||||
try {
|
||||
const uploaded = [];
|
||||
for (const file of files) {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
uploaded.push(file_url);
|
||||
}
|
||||
update('certification_files', [...form.certification_files, ...uploaded]);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingCert(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeCertFile = (idx) => {
|
||||
update('certification_files', form.certification_files.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleSelfieUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadingSelfie(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
update('selfie_url', file_url);
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
setUploadingSelfie(false);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (form.certification_files.length === 0) {
|
||||
setUploadError('Please upload at least one certification document to continue.');
|
||||
return;
|
||||
}
|
||||
if (!form.selfie_url) {
|
||||
setUploadError("Please take a selfie photo before submitting — it is required for your interview.");
|
||||
return;
|
||||
}
|
||||
setUploadError('');
|
||||
onSubmit(form);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* The Resume Dies — identity-first. Create yours at /identity */}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Full Name *</Label>
|
||||
<Input value={form.applicant_name} onChange={(e) => update('applicant_name', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Email *</Label>
|
||||
<Input type="email" value={form.email} onChange={(e) => update('email', e.target.value)} required className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Phone</Label>
|
||||
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Years of Experience</Label>
|
||||
<Input type="number" min="0" value={form.years_experience} onChange={(e) => update('years_experience', parseInt(e.target.value) || 0)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">English Level</Label>
|
||||
<select value={form.english_level} onChange={(e) => update('english_level', e.target.value)} className="w-full mt-1.5 h-9 rounded-md border border-[#E5E7EB] bg-white px-3 text-[13px] focus:border-[#0A39DF] focus:outline-none">
|
||||
{ENGLISH_LEVELS.map(l => <option key={l} value={l}>{l.charAt(0).toUpperCase() + l.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Skills</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customSkill} onChange={(e) => setCustomSkill(e.target.value)} placeholder="Add a skill..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customSkill.trim()) { update('skills', [...form.skills, customSkill.trim()]); setCustomSkill(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.skills.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#F3F4F6] text-[12px] text-[#374151]">
|
||||
{s}
|
||||
<button type="button" onClick={() => update('skills', form.skills.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Companies You've Worked At</Label>
|
||||
<div className="flex gap-2 mt-1.5">
|
||||
<Input value={customCompany} onChange={(e) => setCustomCompany(e.target.value)} placeholder="e.g. Google, Meta, Marriott..." className="flex-1" />
|
||||
<Button type="button" variant="outline" onClick={() => { if (customCompany.trim()) { update('companies_worked', [...form.companies_worked, customCompany.trim()]); setCustomCompany(''); } }}>Add</Button>
|
||||
</div>
|
||||
{form.companies_worked.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{form.companies_worked.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-[#EFF6FF] text-[12px] text-[#1E40AF] font-medium border border-[#DBEAFE]">
|
||||
{c}
|
||||
<button type="button" onClick={() => update('companies_worked', form.companies_worked.filter((_, idx) => idx !== i))} className="text-[#9CA3AF] hover:text-[#EF4444]">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Certifications</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{certOptions.map(cert => (
|
||||
<button
|
||||
key={cert}
|
||||
type="button"
|
||||
onClick={() => toggleCert(cert)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.certifications.includes(cert) ? 'bg-[#0A39DF] border-[#0A39DF] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{cert}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Certification file upload */}
|
||||
<div className="mt-3">
|
||||
<Label className="text-[12px] text-[#6B7280]">Upload certification documents</Label>
|
||||
<label className="mt-1.5 flex flex-col items-center justify-center gap-1.5 border-2 border-dashed border-[#E5E7EB] rounded-lg py-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
|
||||
{uploadingCert ? (
|
||||
<Loader2 className="w-5 h-5 text-[#0A39DF] animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="w-5 h-5 text-[#9CA3AF]" />
|
||||
)}
|
||||
<span className="text-[12px] text-[#6B7280]">
|
||||
{uploadingCert ? 'Uploading...' : 'Click to upload (PDF, JPG, PNG)'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
multiple
|
||||
onChange={handleCertUpload}
|
||||
disabled={uploadingCert}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{uploadError && (
|
||||
<p className="text-[12px] text-[#EF4444] mt-1">{uploadError}</p>
|
||||
)}
|
||||
{form.certification_files.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{form.certification_files.map((url, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-lg bg-[#F0FDF4] border border-[#BBF7D0]">
|
||||
<span className="flex items-center gap-1.5 text-[12px] text-[#166534] truncate">
|
||||
<FileCheck2 className="w-3.5 h-3.5 shrink-0" />
|
||||
{decodeURIComponent(url.split('/').pop()?.split('?')[0] || `Certification ${i + 1}`)}
|
||||
</span>
|
||||
<button type="button" onClick={() => removeCertFile(i)} className="text-[#9CA3AF] hover:text-[#EF4444] shrink-0">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selfie Photo */}
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Selfie Photo *</Label>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5 mb-2">Take a quick selfie so your interviewer can see you before the interview.</p>
|
||||
{form.selfie_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={form.selfie_url} alt="Selfie" className="w-20 h-20 rounded-xl object-cover border border-[#E5E7EB]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update('selfie_url', '')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-[#F3F4F6] hover:bg-[#E5E7EB] text-[#374151] font-medium text-[13px] transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" /> Retake
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-3 border-2 border-dashed border-[#E5E7EB] rounded-xl p-4 cursor-pointer hover:border-[#0A39DF] hover:bg-[#EFF6FF]/40 transition-colors">
|
||||
{uploadingSelfie ? (
|
||||
<Loader2 className="w-8 h-8 text-[#0A39DF] animate-spin shrink-0" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<Camera className="w-6 h-6 text-[#0A39DF]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#374151]">
|
||||
{uploadingSelfie ? 'Uploading...' : 'Take or upload a selfie'}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#6B7280]">Click to use your camera or select a photo</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="user"
|
||||
onChange={handleSelfieUpload}
|
||||
disabled={uploadingSelfie}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Availability</Label>
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{AVAIL_OPTIONS.map(slot => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
onClick={() => toggleAvail(slot)}
|
||||
className={`px-3 py-1.5 rounded-full text-[12px] font-medium border transition-all ${
|
||||
form.availability.includes(slot) ? 'bg-[#059669] border-[#059669] text-white' : 'border-[#E5E7EB] text-[#6B7280] hover:border-[#D1D5DB]'
|
||||
}`}
|
||||
>
|
||||
{slot}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Professional Summary</Label>
|
||||
<textarea value={form.professional_summary} onChange={(e) => update('professional_summary', e.target.value)} className="w-full mt-1.5 min-h-[70px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none" />
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting} className="w-full bg-[#0A39DF] hover:bg-[#082EB4] h-11 text-[14px]">
|
||||
{submitting ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
|
||||
Submit Application
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
151
src/components/krow/ApplyIdentityCard.jsx
Normal file
151
src/components/krow/ApplyIdentityCard.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React from 'react';
|
||||
import { Mic, Clock, Gauge, Sparkles, Fingerprint, BadgeCheck, Briefcase, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
function krowCode(profile) {
|
||||
if (profile?.id) {
|
||||
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
|
||||
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
|
||||
}
|
||||
return 'KROWID';
|
||||
}
|
||||
|
||||
export default function ApplyIdentityCard({ profile, job, onStart, submitting, onBuildIdentity }) {
|
||||
const ready = profile && profile.full_name;
|
||||
const completeness = Math.min(100, Math.round(profile?.profile_completion || 0));
|
||||
const skills = profile?.skills || [];
|
||||
const certs = profile?.certifications || [];
|
||||
const experienceYears = profile?.experience_years || 0;
|
||||
const krowScore = Math.round(profile?.krow_score || profile?.ai_interview_score || 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl overflow-hidden">
|
||||
{/* Header band */}
|
||||
<div className="px-6 py-5 bg-gradient-to-b from-[#F5F7FB] to-white border-b border-[#E5E7EB]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-semibold text-[#111827]">Apply: {job.title}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{job.role_category} • {job.location}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3.5 h-3.5" /> KROW ID · {krowCode(profile)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Identity preview */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
|
||||
{profile?.selfie_url ? (
|
||||
<Image src={profile.selfie_url} alt={profile.full_name} className="w-full h-full object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile?.full_name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[17px] font-bold text-[#111827] truncate">{profile?.full_name || 'Your KROW Identity'}</h3>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#1E40AF]">Verified</span>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mt-1">
|
||||
Your KROW Identity is loaded — no form needed. Owliver will run a short AI interview to confirm fit for this role.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Stat label="Experience" value={experienceYears > 0 ? `${experienceYears} yr` : '—'} icon={<Briefcase className="w-3.5 h-3.5" />} />
|
||||
<Stat label="KROW Score" value={krowScore > 0 ? krowScore : '—'} icon={<Gauge className="w-3.5 h-3.5" />} />
|
||||
<Stat label="Profile" value={`${completeness}%`} icon={<Sparkles className="w-3.5 h-3.5" />} />
|
||||
</div>
|
||||
|
||||
{/* Skills + certs */}
|
||||
<div className="space-y-3">
|
||||
{skills.length > 0 && (
|
||||
<Row label="Skills">
|
||||
{skills.slice(0, 8).map((s, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] text-[11px] font-medium">{s}</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
{certs.length > 0 && (
|
||||
<Row label="Certifications">
|
||||
{certs.slice(0, 6).map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F3F4F6] text-[#374151] text-[11px] font-medium">
|
||||
<BadgeCheck className="w-3 h-3" /> {c}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Incomplete identity notice */}
|
||||
{completeness < 40 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FFF7ED] rounded-lg border border-[#FED7AA]">
|
||||
<AlertCircle className="w-4 h-4 text-[#B45309] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Your KROW Identity is light.</p>
|
||||
<p className="text-[11px] text-[#B45309] mt-0.5">You can still interview now — building your identity improves matches.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interview meta */}
|
||||
<div className="flex items-center justify-center gap-3 text-[12px] text-[#6B7280]">
|
||||
<span className="inline-flex items-center gap-1.5"><Clock className="w-3.5 h-3.5" /> 3 minutes</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Mic className="w-3.5 h-3.5" /> Voice or text</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Gauge className="w-3.5 h-3.5" /> Instant score</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||
<button
|
||||
onClick={onStart}
|
||||
disabled={!ready || submitting}
|
||||
className={cn(
|
||||
'flex-1 inline-flex items-center justify-center gap-2 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold px-5 py-3 rounded-xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
{submitting ? 'Starting interview…' : 'Start AI Interview with Owliver'}
|
||||
{!submitting && <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
{onBuildIdentity && (
|
||||
<button
|
||||
onClick={onBuildIdentity}
|
||||
className="inline-flex items-center justify-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-3 rounded-xl transition-colors"
|
||||
>
|
||||
<Fingerprint className="w-4 h-4" /> Build Identity
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-3 py-2.5 text-center">
|
||||
<div className="inline-flex items-center justify-center text-[#1E56C1] mb-1">{icon}</div>
|
||||
<div className="text-[15px] font-bold text-[#111827] leading-tight">{value}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] font-medium mt-0.5">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">{label.toUpperCase()}</p>
|
||||
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/krow/BadgeChip.jsx
Normal file
20
src/components/krow/BadgeChip.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Award } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
|
||||
};
|
||||
|
||||
export default function BadgeChip({ name, level = 'bronze', earned_date, size = 'md' }) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5', LEVEL_STYLES[level] || LEVEL_STYLES.bronze)}>
|
||||
<Award className={size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'} />
|
||||
<span className={cn('font-medium', size === 'sm' ? 'text-[11px]' : 'text-[12px]')}>{name}</span>
|
||||
{earned_date && <span className="text-[10px] opacity-70">· {new Date(earned_date).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
src/components/krow/CandidateCard.jsx
Normal file
201
src/components/krow/CandidateCard.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu';
|
||||
import { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
hired: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Hired', card: 'bg-white border-[#E5E7EB]' },
|
||||
ai_screened: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'AI Screened', card: 'bg-white border-[#E5E7EB]' },
|
||||
shortlisted: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Shortlisted', card: 'bg-white border-[#E5E7EB]' },
|
||||
interview: { badge: 'bg-[#E8F0FE] text-[#1A56DB]', label: 'Interviewing', card: 'bg-white border-[#E5E7EB]' },
|
||||
applied: { badge: 'bg-[#F3F4F6] text-[#6B7280]', label: 'Applied', card: 'bg-white border-[#E5E7EB]' },
|
||||
rejected: { badge: 'bg-[#FEE2E2] text-[#991B1B]', label: 'Rejected', card: 'bg-white border-[#E5E7EB]' },
|
||||
};
|
||||
|
||||
function getInitials(name) {
|
||||
return name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function getScoreColor(score) {
|
||||
if (score > 0) return '#0A39DF';
|
||||
return '#EF4444';
|
||||
}
|
||||
|
||||
function ScoreRing({ score }) {
|
||||
const color = getScoreColor(score);
|
||||
const radius = 24;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const offset = circumference - (score / 100) * circumference;
|
||||
return (
|
||||
<div className="relative w-14 h-14 flex items-center justify-center shrink-0">
|
||||
<svg className="w-14 h-14 -rotate-90" viewBox="0 0 56 56">
|
||||
<circle cx="28" cy="28" r={radius} fill="none" stroke="#F3F4F6" strokeWidth="4" />
|
||||
<circle
|
||||
cx="28" cy="28" r={radius} fill="none" stroke={color} strokeWidth="4"
|
||||
strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-[13px] font-bold text-[#111827]">{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
||||
const position = jobTitle || application.job_title || '';
|
||||
const status = application.status || 'applied';
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
|
||||
const score = application.ai_score || 0;
|
||||
const canTakeAction = status !== 'hired' && status !== 'rejected';
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn('border rounded-xl p-4 transition-all hover:shadow-sm', style.card)}>
|
||||
<div
|
||||
className="flex items-center gap-4 cursor-pointer"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{/* Rank */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 shrink-0 w-8">
|
||||
<span className="text-[18px] font-bold text-[#0A39DF]">{rank}</span>
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[13px] shrink-0 overflow-hidden">
|
||||
<Image
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[#111827] text-[14px] truncate">{application.applicant_name}</h3>
|
||||
<span className={cn('px-2 py-0.5 rounded-full text-[10px] font-medium', style.badge)}>{style.label}</span>
|
||||
{score > 0 && (() => {
|
||||
const band = getScoreBand(score);
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold text-white"
|
||||
style={{ backgroundColor: band.color }}
|
||||
>
|
||||
{band.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{position && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
{position}
|
||||
</span>
|
||||
)}
|
||||
{application.email && (
|
||||
<span>{application.email}</span>
|
||||
)}
|
||||
{application.phone && (
|
||||
<span>{application.phone}</span>
|
||||
)}
|
||||
{application.years_experience != null && (
|
||||
<span>{application.years_experience}yr exp</span>
|
||||
)}
|
||||
</div>
|
||||
{application.ai_match_label && (
|
||||
<div className="mt-1.5">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#0A39DF] text-white text-[10px] font-bold">
|
||||
+ {application.ai_match_label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score */}
|
||||
{status !== 'applied' && <ScoreRing score={score} />}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{onAction && canTakeAction && (
|
||||
<button
|
||||
onClick={() => onAction(application)}
|
||||
className="px-3 py-1.5 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium transition-colors whitespace-nowrap"
|
||||
>
|
||||
{status === 'interview' ? 'AI Interview' : 'Interview'}
|
||||
</button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[#E5E7EB] bg-white hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium transition-colors">
|
||||
Actions
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{onMessage && (
|
||||
<DropdownMenuItem onClick={() => onMessage(application)} className="cursor-pointer py-2">
|
||||
<MessageSquare className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
|
||||
<span className="text-[#374151]">Message</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onCall && (
|
||||
<DropdownMenuItem onClick={() => onCall(application)} className="cursor-pointer py-2">
|
||||
<Phone className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669]">Call</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onSchedule && (
|
||||
<DropdownMenuItem onClick={() => onSchedule(application)} className="cursor-pointer py-2">
|
||||
<Calendar className="w-4 h-4 mr-2.5 text-[#0A39DF]" />
|
||||
<span className="text-[#0A39DF]">Set up interview</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onHire && (
|
||||
<DropdownMenuItem onClick={() => onHire(application)} className="cursor-pointer py-2">
|
||||
<UserCheck className="w-4 h-4 mr-2.5 text-[#059669]" />
|
||||
<span className="text-[#059669]">Hire candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDecline && (
|
||||
<DropdownMenuItem onClick={() => onDecline(application)} className="cursor-pointer py-2">
|
||||
<X className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626]">Decline candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem onClick={() => onDelete(application)} className="cursor-pointer py-2">
|
||||
<Trash2 className="w-4 h-4 mr-2.5 text-[#DC2626]" />
|
||||
<span className="text-[#DC2626]">Delete candidate</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && <CandidateExpandedDetails application={application} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/krow/CandidateExpandedDetails.jsx
Normal file
136
src/components/krow/CandidateExpandedDetails.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { Award, BarChart3, Clock, ScrollText, Calendar, User, Users, MessageSquare, CalendarCheck, Dumbbell, Crown, Briefcase, Target, Shield, Check, AlertTriangle } from 'lucide-react';
|
||||
import InterviewResults from '@/components/krow/InterviewResults';
|
||||
import WorkforceReputation from '@/components/krow/WorkforceReputation';
|
||||
|
||||
const METRIC_CONFIG = [
|
||||
{ key: 'experience', label: 'Experience', Icon: Award },
|
||||
{ key: 'english', label: 'English', Icon: BarChart3 },
|
||||
{ key: 'reliability', label: 'Reliability', Icon: Clock },
|
||||
{ key: 'certifications', label: 'Certs', Icon: ScrollText },
|
||||
{ key: 'availability', label: 'Availability', Icon: Calendar },
|
||||
{ key: 'personality', label: 'Personality', Icon: User },
|
||||
{ key: 'culture_fit', label: 'Culture Fit', Icon: Users },
|
||||
{ key: 'communication_style', label: 'Comm Style', Icon: MessageSquare },
|
||||
{ key: 'attendance_expectations', label: 'Attendance', Icon: CalendarCheck },
|
||||
{ key: 'physical_requirements', label: 'Physical', Icon: Dumbbell },
|
||||
{ key: 'leadership_expectations', label: 'Leadership', Icon: Crown },
|
||||
{ key: 'job_related_answers', label: 'Job Knowledge', Icon: Briefcase },
|
||||
{ key: 'verified_skills', label: 'Skills', Icon: Target },
|
||||
{ key: 'scenario_judgment', label: 'Judgment', Icon: Shield },
|
||||
{ key: 'employer_requirements', label: 'Req Match', Icon: Check },
|
||||
];
|
||||
|
||||
function MetricPill({ label, value, Icon }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB]">
|
||||
<Icon className="w-3 h-3 text-[#9CA3AF]" strokeWidth={2} />
|
||||
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
|
||||
<span className="text-[12px] font-bold text-[#0A39DF] tabular-nums">{value || 0}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CandidateExpandedDetails({ application }) {
|
||||
const breakdown = application.score_breakdown || {};
|
||||
const strengths = application.ai_strengths || [];
|
||||
const gaps = application.ai_gaps || [];
|
||||
const certs = application.certifications || [];
|
||||
const hasScoreData = application.ai_score > 0;
|
||||
const hasInterview = application.interview_id || application.status === 'interview';
|
||||
|
||||
if (!hasScoreData && strengths.length === 0 && gaps.length === 0 && !hasInterview) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-4">No AI screening results yet. Run AI Screen to see detailed metrics.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
|
||||
{/* Workforce Reputation */}
|
||||
<WorkforceReputation application={application} />
|
||||
|
||||
{/* Metrics — inline pills, only scored metrics shown */}
|
||||
{Object.keys(breakdown).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{METRIC_CONFIG
|
||||
.filter(({ key }) => (breakdown[key] ?? 0) > 0)
|
||||
.map(({ key, label, Icon }) => (
|
||||
<MetricPill key={key} label={label} value={breakdown[key]} Icon={Icon} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Summary */}
|
||||
{application.ai_summary && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5">AI SUMMARY</h4>
|
||||
<p className="text-[13px] text-[#6B7280] leading-relaxed">{application.ai_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strengths & Gaps */}
|
||||
{(strengths.length > 0 || gaps.length > 0) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{strengths.length > 0 && (
|
||||
<div className="bg-[#EFF6FF] rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Check className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{strengths.map((s, i) => (
|
||||
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
|
||||
<span className="text-[#0A39DF] font-bold">+</span>
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{gaps.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">GAPS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{gaps.map((g, i) => (
|
||||
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
|
||||
<span className="text-[#EA580C] font-bold">−</span>
|
||||
<span>{g}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certification Tags */}
|
||||
{certs.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{certs.map((cert, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
{cert}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Recommendation */}
|
||||
{application.ai_recommendation && (
|
||||
<div className="bg-[#F0FDF4] border border-[#BBF7D0] rounded-xl p-3">
|
||||
<p className="text-[12px] text-[#166534]"><span className="font-bold">Recommendation:</span> {application.ai_recommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Interview Results & Transcript */}
|
||||
{(application.interview_id || application.status === 'interview') && (
|
||||
<InterviewResults application={application} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/krow/CareerJourney.jsx
Normal file
50
src/components/krow/CareerJourney.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
// A learning path rendered as a quiet vertical journey to the target role.
|
||||
export default function CareerJourney({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const target = path.target_role || path.name;
|
||||
const allDone = steps.length > 0 && steps.every((s) => completedIds.has(s.course_id));
|
||||
const rungs = [
|
||||
...steps.map((s, i) => ({
|
||||
label: s.course_title || `Step ${i + 1}`,
|
||||
done: completedIds.has(s.course_id),
|
||||
})),
|
||||
{ label: target, done: allDone, isTarget: true },
|
||||
];
|
||||
const doneCount = steps.filter((s) => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((doneCount / steps.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<h3 className="text-[17px] font-semibold text-[#0F172A]">{path.name}</h3>
|
||||
<span className="text-[13px] text-[#64748B]">{pct}%</span>
|
||||
</div>
|
||||
{path.description && <p className="text-[13px] text-[#64748B] mb-4">{path.description}</p>}
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-[7px] top-2 bottom-2 w-px bg-[#E2E8F0]" />
|
||||
{rungs.map((r, i) => (
|
||||
<div key={i} className="relative flex items-center gap-3 py-2">
|
||||
<div
|
||||
className={`absolute -left-6 w-3.5 h-3.5 rounded-full ${
|
||||
r.done ? 'bg-[#0A39DF]' : 'bg-white border-2 border-[#E2E8F0]'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[15px] ${
|
||||
r.isTarget
|
||||
? 'font-semibold text-[#0A39DF]'
|
||||
: r.done
|
||||
? 'text-[#0F172A] font-medium'
|
||||
: 'text-[#64748B]'
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/krow/CareerTrajectory.jsx
Normal file
42
src/components/krow/CareerTrajectory.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
function Stage({ label, value, sub, accent, to }) {
|
||||
const body = (
|
||||
<div className="text-center sm:text-left">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8]">{label}</div>
|
||||
<div className={`text-[20px] sm:text-[26px] font-semibold mt-1 tracking-tight ${accent ? 'text-[#0A39DF]' : 'text-[#0F172A]'}`}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-[13px] text-[#64748B] mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
return to ? (
|
||||
<Link to={to} className="hover:opacity-80 transition-opacity">{body}</Link>
|
||||
) : body;
|
||||
}
|
||||
|
||||
export default function CareerTrajectory({ profile, nextPath, nextReady, futureRole, futureIncome }) {
|
||||
const current = profile?.current_position || profile?.desired_position || 'Starting Out';
|
||||
const next = nextPath?.target_role || nextPath?.name || 'Next Role';
|
||||
return (
|
||||
<section className="text-center pt-4 pb-12 sm:pt-8 sm:pb-20">
|
||||
<span className="text-[12px] font-medium tracking-wide text-[#0A39DF] uppercase">Your Career</span>
|
||||
<h1 className="text-[32px] sm:text-[44px] font-bold tracking-tight text-[#0F172A] mt-4 leading-[1.05]">
|
||||
Everything revolves around your future.
|
||||
</h1>
|
||||
<p className="text-[15px] text-[#64748B] mt-4 max-w-sm mx-auto">
|
||||
Not a university. A Career Acceleration Engine.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 sm:mt-16 flex flex-col sm:flex-row items-center justify-center gap-5 sm:gap-10">
|
||||
<Stage label="Current" value={current} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Next Role" value={next} sub={`${nextReady}% ready`} accent to={nextPath ? '/university' : null} />
|
||||
<ArrowRight className="w-4 h-4 text-[#CBD5E1] rotate-90 sm:rotate-0" />
|
||||
<Stage label="Future" value={futureRole} sub={futureIncome || ' '} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
99
src/components/krow/CertificationManager.jsx
Normal file
99
src/components/krow/CertificationManager.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Trash2, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { useCertifications, useCreateCertification, useDeleteCertification } from '@/lib/krowHooks';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function CertificationManager() {
|
||||
const { data: certifications = [], isLoading } = useCertifications();
|
||||
const createCert = useCreateCertification();
|
||||
const deleteCert = useDeleteCertification();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newCert, setNewCert] = useState('');
|
||||
|
||||
const handleAdd = async () => {
|
||||
const name = newCert.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await createCert.mutateAsync({ name });
|
||||
setNewCert('');
|
||||
toast.success(`"${name}" added to certifications`);
|
||||
} catch (e) {
|
||||
toast.error('Failed to add certification');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (cert) => {
|
||||
try {
|
||||
await deleteCert.mutateAsync(cert.id);
|
||||
toast.success(`"${cert.name}" removed`);
|
||||
} catch (e) {
|
||||
toast.error('Failed to remove certification');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 bg-white border border-[#E5E7EB] hover:border-[#D1D5DB] text-[#374151] text-[13px] font-medium px-4 h-10 rounded-lg transition-colors">
|
||||
<ShieldCheck className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="hidden sm:inline">Manage Certifications</span>
|
||||
<span className="sm:hidden">Certs</span>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Certifications</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-[13px] text-[#6B7280] -mt-1">
|
||||
These are the certification options applicants can select and upload when applying.
|
||||
</p>
|
||||
|
||||
{/* Add new */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCert}
|
||||
onChange={(e) => setNewCert(e.target.value)}
|
||||
placeholder="e.g. OSHA 30"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={!newCert.trim() || createCert.isPending}
|
||||
className="bg-[#0A39DF] hover:bg-[#082EB4]"
|
||||
>
|
||||
{createCert.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
|
||||
{isLoading && [...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-10 bg-[#F3F4F6] rounded-lg animate-pulse" />
|
||||
))}
|
||||
{!isLoading && certifications.length === 0 && (
|
||||
<p className="text-[13px] text-[#9CA3AF] text-center py-6">No certifications yet. Add your first one above.</p>
|
||||
)}
|
||||
{certifications.map((cert) => (
|
||||
<div key={cert.id} className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-[#F9FAFB] border border-[#E5E7EB]">
|
||||
<span className="text-[13px] font-medium text-[#111827] truncate">{cert.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(cert)}
|
||||
disabled={deleteCert.isPending}
|
||||
className="text-[#9CA3AF] hover:text-[#EF4444] transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
93
src/components/krow/HiringAnalytics.jsx
Normal file
93
src/components/krow/HiringAnalytics.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
|
||||
const TIER_COLORS = { Skilled: '#0A39DF', 'Cross-Trained': '#333F48', Beginner: '#D1E0D7' };
|
||||
|
||||
export default function HiringAnalytics({ applications, staff }) {
|
||||
const byTier = useMemo(() => {
|
||||
const tiers = ['Skilled', 'Cross-Trained', 'Beginner'];
|
||||
return tiers.map(tier => ({
|
||||
label: tier,
|
||||
count: staff.filter(s => s.profile_tier === tier).length,
|
||||
color: TIER_COLORS[tier],
|
||||
}));
|
||||
}, [staff]);
|
||||
|
||||
const byRole = useMemo(() => {
|
||||
const counts = {};
|
||||
staff.forEach(s => {
|
||||
const role = s.role || 'Unassigned';
|
||||
counts[role] = (counts[role] || 0) + 1;
|
||||
});
|
||||
return Object.entries(counts)
|
||||
.map(([role, count]) => ({ role, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
}, [staff]);
|
||||
|
||||
const hiredApps = applications.filter(a => a.status === 'hired');
|
||||
const totalApps = applications.length;
|
||||
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
|
||||
|
||||
const avgHireScore = hiredApps.length
|
||||
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
|
||||
: 0;
|
||||
|
||||
const sevenDaysAgo = new Date();
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
const recentHires = staff.filter(s => new Date(s.hire_date) >= sevenDaysAgo).length;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Hiring Analytics</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{staff.length} total hires · {recentHires} in last 7 days</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{hireRate}%</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
|
||||
</div>
|
||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||
<div className="text-2xl font-heading font-bold text-[#0A39DF]">{recentHires}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">LAST 7 DAYS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Tier</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byTier}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{byTier.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#374151] mb-3">Hires by Role</p>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={byRole} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<YAxis type="category" dataKey="role" tick={{ fontSize: 9 }} stroke="#9CA3AF" width={100} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="count" fill="#0A39DF" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/krow/ImpactMetrics.jsx
Normal file
131
src/components/krow/ImpactMetrics.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
|
||||
|
||||
export default function ImpactMetrics({ applications, interviews, staff }) {
|
||||
const hired = applications.filter(a => a.status === 'hired');
|
||||
const screened = applications.filter(a => a.ai_score > 0);
|
||||
|
||||
// 1. Reduce time-to-hire: avg days from application created to hired
|
||||
const timeToHire = hired.length
|
||||
? Math.round(
|
||||
hired.reduce((sum, a) => {
|
||||
const created = new Date(a.created_date).getTime();
|
||||
const updated = new Date(a.updated_date).getTime();
|
||||
return sum + Math.max(1, (updated - created) / (1000 * 60 * 60 * 24));
|
||||
}, 0) / hired.length
|
||||
)
|
||||
: 0;
|
||||
// AI screens instantly vs ~5 day manual industry average
|
||||
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
|
||||
|
||||
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
|
||||
const costSaved = screened.length * 45 + interviews.length * 80;
|
||||
|
||||
// 3. Improve quality of hire: avg AI score of hired candidates
|
||||
const qualityOfHire = hired.length
|
||||
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
|
||||
: 0;
|
||||
|
||||
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
|
||||
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
|
||||
|
||||
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
|
||||
const standardizedPct = applications.length
|
||||
? Math.round((screened.length / applications.length) * 100)
|
||||
: 0;
|
||||
|
||||
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
|
||||
const completionRate = interviews.length
|
||||
? Math.round((interviews.filter(i => i.overall_interview_score > 0).length / interviews.length) * 100)
|
||||
: 0;
|
||||
|
||||
const metrics = [
|
||||
{
|
||||
icon: Clock,
|
||||
label: 'Time-to-Hire',
|
||||
value: tthDisplay,
|
||||
sub: hired.length ? `${hired.length} hires tracked` : 'AI screens instantly',
|
||||
color: '#0A39DF',
|
||||
bg: '#EFF6FF',
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
label: 'Cost Saved',
|
||||
value: `$${costSaved.toLocaleString()}`,
|
||||
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
|
||||
color: '#333F48',
|
||||
bg: '#F3F4F6',
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
label: 'Quality of Hire',
|
||||
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
|
||||
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
|
||||
color: '#0A39DF',
|
||||
bg: '#FEFCE8',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: 'Recruiter Hours Saved',
|
||||
value: `${hoursSaved}h`,
|
||||
sub: 'AI screening + interview automation',
|
||||
color: '#333F48',
|
||||
bg: '#F3F4F6',
|
||||
},
|
||||
{
|
||||
icon: Scale,
|
||||
label: 'Decisions Standardized',
|
||||
value: `${standardizedPct}%`,
|
||||
sub: `${screened.length} of ${applications.length} applicants scored`,
|
||||
color: '#0A39DF',
|
||||
bg: '#EFF6FF',
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
label: 'Candidate Experience',
|
||||
value: completionRate ? `${completionRate}% completion` : 'Instant response',
|
||||
sub: 'AI responds in seconds, not days',
|
||||
color: '#333F48',
|
||||
bg: '#D1E0D7',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h2 className="text-[11px] font-bold text-[#9CA3AF] tracking-wider font-heading">
|
||||
HIRING IMPACT · OUR MISSION
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{metrics.map((m, i) => {
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<motion.div
|
||||
key={m.label}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.4, ease: 'easeOut' }}
|
||||
className="glass-card border border-white/60 rounded-2xl p-5 shadow-sm hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 shadow-lg"
|
||||
style={{ background: m.bg }}
|
||||
>
|
||||
<Icon className="w-5 h-5" style={{ color: m.color }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-[#9CA3AF] font-medium tracking-wide">{m.label}</div>
|
||||
<div className="text-2xl font-heading font-bold text-[#111827] mt-0.5">{m.value}</div>
|
||||
<div className="text-[12px] text-[#6B7280] mt-1 leading-snug">{m.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/components/krow/InterviewAnalytics.jsx
Normal file
99
src/components/krow/InterviewAnalytics.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||
import { Mic, CheckCircle2, Clock, AlertTriangle } from 'lucide-react';
|
||||
|
||||
const VERDICT_COLORS = { hire: '#0A39DF', maybe: '#F9E547', no: '#333F48' };
|
||||
|
||||
export default function InterviewAnalytics({ applications, interviews }) {
|
||||
const stats = useMemo(() => {
|
||||
const started = applications.filter(a => a.status === 'interview' || a.interview_id).length;
|
||||
const completed = interviews.length;
|
||||
const completionRate = started ? Math.round((completed / started) * 100) : 0;
|
||||
const scored = interviews.filter(i => i.overall_interview_score > 0);
|
||||
const avgScore = scored.length ? Math.round(scored.reduce((s, i) => s + i.overall_interview_score, 0) / scored.length) : 0;
|
||||
|
||||
const verdictCounts = { hire: 0, maybe: 0, no: 0 };
|
||||
interviews.forEach(i => {
|
||||
if (i.verdict) verdictCounts[i.verdict] = (verdictCounts[i.verdict] || 0) + 1;
|
||||
});
|
||||
|
||||
const lowIntegrity = interviews.filter(i => i.integrity_score < 100).length;
|
||||
|
||||
return { started, completed, completionRate, avgScore, verdictCounts, lowIntegrity };
|
||||
}, [applications, interviews]);
|
||||
|
||||
const verdictData = [
|
||||
{ name: 'Hire', value: stats.verdictCounts.hire },
|
||||
{ name: 'Maybe', value: stats.verdictCounts.maybe },
|
||||
{ name: 'No', value: stats.verdictCounts.no },
|
||||
].filter(d => d.value > 0);
|
||||
|
||||
const categoryData = useMemo(() => {
|
||||
const cats = ['communication', 'confidence', 'experience_relevance', 'culture_fit', 'problem_solving'];
|
||||
return cats.map(cat => {
|
||||
const scored = interviews.filter(i => i.category_scores?.[cat] != null);
|
||||
const avg = scored.length ? Math.round(scored.reduce((s, i) => s + i.category_scores[cat], 0) / scored.length) : 0;
|
||||
return { category: cat.replace(/_/g, ' '), score: avg };
|
||||
}).filter(d => d.score > 0);
|
||||
}, [interviews]);
|
||||
|
||||
const cards = [
|
||||
{ label: 'Started', value: stats.started, icon: Mic, color: '#0A39DF', bg: '#EFF6FF' },
|
||||
{ label: 'Completed', value: stats.completed, icon: CheckCircle2, color: '#333F48', bg: '#F3F4F6' },
|
||||
{ label: 'Completion Rate', value: `${stats.completionRate}%`, icon: Clock, color: '#0A39DF', bg: '#FEFCE8' },
|
||||
{ label: 'Avg Interview Score', value: stats.avgScore, icon: AlertTriangle, color: '#333F48', bg: '#D1E0D7' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.label} className="glass-card border border-white/60 rounded-2xl p-4 shadow-sm">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center mb-2" style={{ background: c.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: c.color }} />
|
||||
</div>
|
||||
<div className="text-xl font-heading font-bold text-[#111827]">{c.value}</div>
|
||||
<div className="text-[11px] text-[#9CA3AF] tracking-wide mt-0.5">{c.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{verdictData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Verdict Breakdown</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={verdictData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={75} label>
|
||||
{verdictData.map((entry, i) => (
|
||||
<Cell key={i} fill={VERDICT_COLORS[entry.name.toLowerCase()] || '#9CA3AF'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoryData.length > 0 && (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[15px] mb-4">Avg Category Scores</h3>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={categoryData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis type="category" dataKey="category" tick={{ fontSize: 10 }} stroke="#9CA3AF" width={110} />
|
||||
<Tooltip contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }} />
|
||||
<Bar dataKey="score" fill="#333F48" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
src/components/krow/InterviewResults.jsx
Normal file
190
src/components/krow/InterviewResults.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React from 'react';
|
||||
import { useInterviews } from '@/lib/krowHooks';
|
||||
import { MessageSquare, CheckCircle2, AlertTriangle, Star, FileText } from 'lucide-react';
|
||||
|
||||
const VERDICT_STYLES = {
|
||||
hire: { bg: 'bg-[#F0FDF4]', text: 'text-[#166534]', label: 'Hire' },
|
||||
maybe: { bg: 'bg-[#FFFBEB]', text: 'text-[#92400E]', label: 'Maybe' },
|
||||
no: { bg: 'bg-[#FEF2F2]', text: 'text-[#991B1B]', label: 'No Hire' },
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
communication: 'Communication',
|
||||
confidence: 'Confidence',
|
||||
experience_relevance: 'Experience',
|
||||
culture_fit: 'Culture Fit',
|
||||
problem_solving: 'Problem Solving',
|
||||
personality: 'Personality',
|
||||
communication_style: 'Comm Style',
|
||||
attendance_expectations: 'Attendance',
|
||||
reliability: 'Reliability',
|
||||
physical_requirements: 'Physical',
|
||||
leadership_expectations: 'Leadership',
|
||||
scenario_judgment: 'Judgment',
|
||||
job_related_answers: 'Job Knowledge',
|
||||
verified_skills: 'Skills',
|
||||
};
|
||||
|
||||
function getCategoryColor(val) {
|
||||
if (val >= 80) return '#0A39DF';
|
||||
if (val >= 60) return '#333F48';
|
||||
if (val >= 40) return '#6B7280';
|
||||
return '#EF4444';
|
||||
}
|
||||
|
||||
export default function InterviewResults({ application }) {
|
||||
const { data: interviews = [], isLoading } = useInterviews();
|
||||
const interview = interviews
|
||||
.filter((i) => i.application_id === application.id)
|
||||
.sort((a, b) => new Date(b.created_date) - new Date(a.created_date))[0];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6]">
|
||||
<p className="text-[12px] text-[#9CA3AF]">Loading interview results...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!interview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const verdictStyle = VERDICT_STYLES[interview.verdict] || VERDICT_STYLES.maybe;
|
||||
const messages = interview.messages || [];
|
||||
const categoryScores = interview.category_scores || {};
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-4 border-t border-[#F3F4F6] space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide">AI INTERVIEW RESULTS</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-semibold ${verdictStyle.bg} ${verdictStyle.text}`}>
|
||||
{verdictStyle.label}
|
||||
</span>
|
||||
<span className="text-[18px] font-bold text-[#0A39DF]">{interview.overall_interview_score || 0}<span className="text-[11px] text-[#9CA3AF]">/100</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Scores */}
|
||||
{Object.keys(categoryScores).length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
|
||||
{Object.entries(categoryScores).map(([key, val]) => {
|
||||
const color = getCategoryColor(val);
|
||||
return (
|
||||
<div key={key} className="bg-[#F9FAFB] rounded-xl p-2.5 flex flex-col items-center gap-1 border border-[#F3F4F6]">
|
||||
<div className="text-[15px] font-bold text-[#111827]">{val}</div>
|
||||
<div className="w-full h-1.5 rounded-full bg-[#E5E7EB] overflow-hidden">
|
||||
<div className="h-full rounded-full" style={{ width: `${val}%`, background: color }} />
|
||||
</div>
|
||||
<div className="text-[9px] text-[#9CA3AF] tracking-wide font-medium text-center leading-tight">{CATEGORY_LABELS[key] || key}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{interview.summary && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><FileText className="w-3 h-3" /> SUMMARY</h4>
|
||||
<p className="text-[13px] text-[#6B7280] leading-relaxed">{interview.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Strengths & Concerns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{interview.strengths?.length > 0 && (
|
||||
<div className="bg-[#EFF6FF] rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-[#0A39DF]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#0A39DF] tracking-wide">STRENGTHS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{interview.strengths.map((s, i) => (
|
||||
<li key={i} className="text-[12px] text-[#1E40AF] flex gap-2">
|
||||
<span className="text-[#0A39DF] font-bold">+</span>
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{interview.concerns?.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-4">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-[#9A3412]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#9A3412] tracking-wide">CONCERNS</h4>
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{interview.concerns.map((c, i) => (
|
||||
<li key={i} className="text-[12px] text-[#9A3412] flex gap-2">
|
||||
<span className="text-[#EA580C] font-bold">−</span>
|
||||
<span>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Integrity / Flags */}
|
||||
{interview.integrity_score != null && interview.integrity_score < 100 && (
|
||||
<div className="flex items-start gap-2 p-3 bg-[#FEF3C7] rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-[#92400E] mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[12px] font-medium text-[#92400E]">Integrity Score: {interview.integrity_score}/100</p>
|
||||
{interview.ai_flags?.length > 0 && (
|
||||
<p className="text-[11px] text-[#92400E] mt-1">{interview.ai_flags.join(', ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Best Fit Roles */}
|
||||
{interview.best_fit_roles?.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1.5 flex items-center gap-1"><Star className="w-3 h-3" /> BEST FIT ROLES</h4>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{interview.best_fit_roles.map((role, i) => (
|
||||
<span key={i} className="px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Q&A Transcript */}
|
||||
{messages.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1"><MessageSquare className="w-3 h-3" /> INTERVIEW TRANSCRIPT</h4>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-1">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] rounded-2xl px-3.5 py-2 text-[13px] ${
|
||||
msg.role === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-sm'
|
||||
: 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
|
||||
}`}>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
{msg.response_time_seconds != null && msg.response_time_seconds < 8 && (
|
||||
<p className="text-[10px] mt-1 opacity-70 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Fast response ({msg.response_time_seconds}s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/krow/InviteApplicantsModal.jsx
Normal file
145
src/components/krow/InviteApplicantsModal.jsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Share2, Mail, Link2, Smartphone, Users } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function InviteApplicantsModal({ open, onClose, job }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [emailRecipients, setEmailRecipients] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const shareUrl = useMemo(() => {
|
||||
if (!job?.id) return '';
|
||||
const base = window.location.origin;
|
||||
return `${base}/apply?job=${job.id}`;
|
||||
}, [job?.id]);
|
||||
|
||||
const jobTitle = job?.title || 'this position';
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
toast.success('Invitation link copied!');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy link');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `Apply: ${jobTitle} at KROW`,
|
||||
text: `You've been invited to apply for ${jobTitle}. Complete a quick 5-minute AI interview — no forms, no quizzes.`,
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch { /* user cancelled */ }
|
||||
} else {
|
||||
handleCopyLink();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailInvite = () => {
|
||||
const recipients = emailRecipients.trim();
|
||||
if (!recipients) return;
|
||||
const subject = `You're invited to apply: ${jobTitle}`;
|
||||
const body = `Hi,\n\nYou've been invited to apply for ${jobTitle} at KROW.\n\nClick the link below to view the position and complete a quick 5-minute AI interview:\n\n${shareUrl}\n\nNo long forms, no quizzes — just speak naturally and get instant feedback.\n\nKROW Talent Engine`;
|
||||
const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
window.location.href = mailtoUrl;
|
||||
setSending(true);
|
||||
setTimeout(() => {
|
||||
setSending(false);
|
||||
toast.success('Email invitation opened');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#0A39DF]" />
|
||||
Invite Applicants
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Job info */}
|
||||
<div className="bg-[#F8FAFC] rounded-xl p-3 border border-[#E5E7EB]">
|
||||
<p className="text-[13px] font-semibold text-[#111827]">{jobTitle}</p>
|
||||
{job?.location && <p className="text-[12px] text-[#6B7280]">{job.location}</p>}
|
||||
{job?.pay_range_min != null && (
|
||||
<p className="text-[12px] text-[#0A39DF] font-medium">${job.pay_range_min}–${job.pay_range_max}/hr</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shareable Link */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Link2 className="w-3.5 h-3.5" /> Invitation Link
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={shareUrl}
|
||||
className="flex-1 min-w-0 px-3 py-2 text-[12px] rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] text-[#6B7280] truncate"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium hover:bg-[#082EB4] transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Native Share (mobile) */}
|
||||
<button
|
||||
onClick={handleNativeShare}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg border border-[#E5E7EB] text-[13px] font-medium text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
Share via...
|
||||
</button>
|
||||
|
||||
{/* Email Invite */}
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-[#374151] flex items-center gap-1.5 mb-2">
|
||||
<Mail className="w-3.5 h-3.5" /> Email Invitation
|
||||
</label>
|
||||
<textarea
|
||||
value={emailRecipients}
|
||||
onChange={(e) => setEmailRecipients(e.target.value)}
|
||||
placeholder="applicant1@email.com, applicant2@email.com"
|
||||
className="w-full min-h-[48px] p-3 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEmailInvite}
|
||||
disabled={!emailRecipients.trim() || sending}
|
||||
className="w-full mt-2 inline-flex items-center justify-center gap-2 py-2.5 rounded-lg bg-[#0A39DF] text-white text-[13px] font-medium hover:bg-[#082EB4] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
Send Email Invitations
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile app note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-[#EFF6FF] rounded-lg">
|
||||
<Smartphone className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[11px] text-[#374151] leading-relaxed">
|
||||
Applicants open the link on any phone — no app download needed. They see open positions and complete the AI interview right in their mobile browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} className="w-full">Done</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
73
src/components/krow/KROWCandidateLeaderboard.jsx
Normal file
73
src/components/krow/KROWCandidateLeaderboard.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TIERS = [
|
||||
{ label: 'Top Talent', range: '80-100', color: 'bg-[#1E3A8A]', textColor: 'text-[#1E3A8A]' },
|
||||
{ label: 'Strong Fit', range: '60-79', color: 'bg-[#60A5FA]', textColor: 'text-[#2563EB]' },
|
||||
{ label: 'Possible Fit', range: '40-59', color: 'bg-[#93C5FD]', textColor: 'text-[#3B82F6]' },
|
||||
{ label: 'Not a Fit', range: '0-39', color: 'bg-[#E5E7EB]', textColor: 'text-[#9CA3AF]' },
|
||||
];
|
||||
|
||||
function getTier(score) {
|
||||
if (score >= 80) return 0;
|
||||
if (score >= 60) return 1;
|
||||
if (score >= 40) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export default function KROWCandidateLeaderboard({ applications, maxBars = 4 }) {
|
||||
const screened = applications.filter(a => a.ai_score > 0 || a.status !== 'applied');
|
||||
const counts = TIERS.map((_, i) => screened.filter(a => getTier(a.ai_score || 0) === i).length);
|
||||
const total = counts.reduce((a, b) => a + b, 0) || 1;
|
||||
const sorted = [...screened].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-6">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] mb-5">Candidate Pipeline</h3>
|
||||
|
||||
{/* Bar chart */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{TIERS.map((tier, i) => {
|
||||
const count = counts[i];
|
||||
const pct = (count / total) * 100;
|
||||
return (
|
||||
<div key={tier.label}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[12px] font-medium text-[#374151]">{tier.label} <span className="text-[#9CA3AF]">({tier.range})</span></span>
|
||||
<span className="text-[13px] font-bold text-[#111827]">{count}</span>
|
||||
</div>
|
||||
<div className="h-2.5 bg-[#F3F4F6] rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', tier.color)}
|
||||
style={{ width: `${Math.max(pct, count > 0 ? 4 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ranked list */}
|
||||
<div className="space-y-2">
|
||||
{sorted.slice(0, 5).map((app, idx) => (
|
||||
<div key={app.id} className="flex items-center gap-3 py-2">
|
||||
<span className="text-[12px] font-bold text-[#9CA3AF] w-6">#{idx + 1}</span>
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
|
||||
{app.applicant_name?.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13px] font-medium text-[#111827] truncate">{app.applicant_name}</div>
|
||||
<div className="text-[11px] text-[#6B7280] truncate">{app.job_title || 'Applicant'}</div>
|
||||
</div>
|
||||
<span className={cn('text-[13px] font-bold', TIERS[getTier(app.ai_score || 0)].textColor)}>
|
||||
{app.ai_score || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-center py-8 text-[13px] text-[#9CA3AF]">No screened candidates yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/krow/KROWJobPostingCard.jsx
Normal file
84
src/components/krow/KROWJobPostingCard.jsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STATUS_STYLES = {
|
||||
active: 'bg-[#0A39DF] text-white',
|
||||
draft: 'bg-[#F3F4F6] text-[#6B7280]',
|
||||
paused: 'bg-[#FEF3C7] text-[#92400E]',
|
||||
closed: 'bg-[#FEE2E2] text-[#991B1B]',
|
||||
};
|
||||
|
||||
function getCategoryAbbr(category) {
|
||||
if (!category) return 'JR';
|
||||
const words = category.split(' ');
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase();
|
||||
return words.map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
export default function KROWJobPostingCard({ posting, stats }) {
|
||||
const navigate = useNavigate();
|
||||
const applied = stats?.applied ?? 0;
|
||||
const screened = stats?.screened ?? 0;
|
||||
const hired = stats?.hired ?? 0;
|
||||
const statusLabel = posting.status === 'active' ? 'Hiring' : posting.status.charAt(0).toUpperCase() + posting.status.slice(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/positions/${posting.id}`)}
|
||||
className="group bg-white border border-[#E5E7EB] rounded-xl p-5 hover:shadow-md hover:border-[#D1D5DB] transition-all cursor-pointer"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#0A39DF] flex items-center justify-center text-white text-[13px] font-bold shrink-0">
|
||||
{getCategoryAbbr(posting.role_category)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] leading-tight truncate">{posting.title}</h3>
|
||||
<div className="text-[12px] text-[#6B7280] capitalize">{posting.role_category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium', STATUS_STYLES[posting.status] || STATUS_STYLES.draft)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-[#6B7280] mb-4">
|
||||
{posting.location && (
|
||||
<span>{posting.location}</span>
|
||||
)}
|
||||
{posting.pay_range_min != null && posting.pay_range_max != null && (
|
||||
<span className="font-medium text-[#111827]">${posting.pay_range_min}–${posting.pay_range_max}/hr</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-2 py-3 border-t border-[#F3F4F6]">
|
||||
{[
|
||||
{ label: 'APPLIED', value: applied },
|
||||
{ label: 'SCREENED', value: screened },
|
||||
{ label: 'HIRED', value: hired },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<div className="text-lg font-bold text-[#111827]">{s.value}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-[#F3F4F6]">
|
||||
{posting.ai_generated ? (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium">
|
||||
AI Built
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-[#9CA3AF]">Manual</span>
|
||||
)}
|
||||
<span className="text-[12px] text-[#9CA3AF] group-hover:text-[#111827] transition-all">View ›</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/krow/KrowIdPreview.jsx
Normal file
136
src/components/krow/KrowIdPreview.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { useWorkerProfile } from '@/lib/krowHooks';
|
||||
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
import { ShieldCheck, Smartphone, Image as ImageIcon, QrCode, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
|
||||
function buildVCard(profile) {
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const [first, ...rest] = name.split(' ');
|
||||
const last = rest.join(' ');
|
||||
const lines = [
|
||||
'BEGIN:VCARD', 'VERSION:3.0',
|
||||
`N:${last};${first};;;`, `FN:${name}`,
|
||||
profile.current_position ? `TITLE:${profile.current_position}` : '',
|
||||
profile.phone ? `TEL;TYPE=CELL:${profile.phone}` : '',
|
||||
profile.email ? `EMAIL:${profile.email}` : '',
|
||||
`URL:https://krow.app/me`,
|
||||
`NOTE:KROW ID ${(profile.id || '').slice(-6).toUpperCase()}`,
|
||||
'END:VCARD',
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function download(filename, content, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function KrowIdPreview() {
|
||||
const { data: profile, isLoading } = useWorkerProfile();
|
||||
if (isLoading || !profile) {
|
||||
return <div className="flex justify-center py-10 text-xs text-[#6B7280]">Loading KROW ID…</div>;
|
||||
}
|
||||
|
||||
const score = getTalentScore(profile);
|
||||
const band = getScoreBand(score);
|
||||
const growth = getGrowth(profile);
|
||||
const fico = toFICO(score);
|
||||
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const title = profile.current_position || profile.desired_position || 'Open to work';
|
||||
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
|
||||
|
||||
const vcard = buildVCard(profile);
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?margin=0&size=120x120&data=${encodeURIComponent(vcard)}`;
|
||||
|
||||
const addToContacts = () => {
|
||||
download(`${name.replace(/\s+/g, '_')}.vcf`, vcard, 'text/vcard');
|
||||
toast.success('Contact file downloaded.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative w-full max-w-[360px]" style={{ perspective: '1200px' }}>
|
||||
{/* depth layers */}
|
||||
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/50 border border-white/60 backdrop-blur-xl shadow-[0_18px_50px_-20px_rgba(10,57,223,0.3)]"
|
||||
style={{ transform: 'translateY(12px) translateX(10px) rotateY(-8deg) rotateX(5deg)' }} />
|
||||
<div aria-hidden className="absolute inset-0 rounded-3xl bg-white/40 border border-white/70 backdrop-blur-xl shadow-[0_14px_40px_-16px_rgba(10,57,223,0.25)]"
|
||||
style={{ transform: 'translateY(6px) translateX(5px) rotateY(-4deg) rotateX(2deg)' }} />
|
||||
|
||||
{/* front pass */}
|
||||
<div className="relative rounded-3xl border border-white/80 backdrop-blur-2xl shadow-[0_20px_60px_-22px_rgba(10,57,223,0.4)] overflow-hidden"
|
||||
style={{ background: 'linear-gradient(160deg, rgba(255,255,255,0.94) 0%, rgba(244,247,255,0.9) 100%)' }}>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: 'linear-gradient(115deg, transparent 30%, rgba(10,57,223,0.05) 45%, rgba(249,229,71,0.06) 55%, transparent 70%)' }} />
|
||||
|
||||
<div className="relative p-5">
|
||||
{/* header row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-full overflow-hidden border-2 border-white shadow bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
{profile.selfie_url ? (
|
||||
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<span className="text-[18px] font-bold text-[#0A39DF]">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="text-[9px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
|
||||
<h2 className="text-[16px] font-bold text-[#0F172A] tracking-tight leading-tight truncate">{name}</h2>
|
||||
<p className="text-[11px] text-[#6B7280] truncate">{title}</p>
|
||||
</div>
|
||||
<ShieldCheck className="w-4 h-4 text-[#10B981] shrink-0" />
|
||||
</div>
|
||||
|
||||
{/* score + factors row */}
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="text-center shrink-0">
|
||||
<p className="text-[30px] font-bold text-[#0F172A] leading-none tracking-tight">{fico}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold mt-1"
|
||||
style={{ color: band.color, background: `${band.color}14` }}>
|
||||
<Sparkles className="w-2.5 h-2.5" /> {band.label}
|
||||
</span>
|
||||
<p className="text-[10px] font-semibold text-[#16A34A] mt-1">↑ +{growth} mo</p>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
['Reliability', clamp(profile.reliability_score)],
|
||||
['Attendance', clamp(profile.attendance_score ?? 100)],
|
||||
['Performance', clamp(profile.performance_score)],
|
||||
['XP', Number(profile.xp) || 0],
|
||||
].map(([label, value]) => (
|
||||
<span key={label} className="inline-flex items-center justify-between gap-1 rounded-lg bg-white/70 border border-[#E6ECF5] px-2 py-1 text-[10px]">
|
||||
<span className="text-[#9CA3AF]">{label}</span>
|
||||
<span className="font-semibold text-[#0A39DF]">{value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* footer: QR + actions */}
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-white p-1 border border-[#E6ECF5] shadow-sm shrink-0">
|
||||
<img src={qrSrc} alt="KROW Identity QR" width={48} height={48} className="block" />
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-2 gap-1.5">
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors">
|
||||
<Smartphone className="w-3 h-3" /> Contacts
|
||||
</button>
|
||||
<button onClick={() => toast('Open My KROW ID page to save the full pass.', { icon: 'ℹ️' })} className="flex items-center justify-center gap-1 h-8 rounded-lg bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors">
|
||||
<ImageIcon className="w-3 h-3 text-[#0A39DF]" /> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[9px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-2.5 h-2.5" /> Scan to share your verified KROW Identity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/KrowIdentityCard.jsx
Normal file
82
src/components/krow/KrowIdentityCard.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Camera, Fingerprint, Sparkles } from 'lucide-react';
|
||||
|
||||
// A living identity card that reflects — rather than describes — the resume-less
|
||||
// KROW Identity. It fills in as the worker adds a face, skills, languages, etc.
|
||||
export default function KrowIdentityCard({ form }) {
|
||||
const parts = [
|
||||
{ key: 'face', filled: !!form.selfie_url },
|
||||
{ key: 'name', filled: !!form.full_name.trim() },
|
||||
{ key: 'about', filled: !!form.career_goals.trim() },
|
||||
{ key: 'skills', filled: (form.skills || []).length > 0 },
|
||||
{ key: 'languages', filled: (form.languages || []).length > 0 },
|
||||
{ key: 'availability', filled: (form.availability || []).length > 0 },
|
||||
{ key: 'certifications', filled: (form.certifications || []).length > 0 },
|
||||
];
|
||||
const done = parts.filter(p => p.filled).length;
|
||||
const pct = Math.round((done / parts.length) * 100);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-[#0A39DF] p-5 sm:p-6 text-white overflow-hidden relative">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-[#F9E547]/10" />
|
||||
<div className="relative flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
{form.selfie_url ? (
|
||||
<img src={form.selfie_url} alt="You" className="w-20 h-20 rounded-2xl object-cover ring-4 ring-white/25" />
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-2xl border-2 border-dashed border-white/40 flex flex-col items-center justify-center text-white/70">
|
||||
<Camera className="w-6 h-6" />
|
||||
<span className="text-[9px] mt-0.5">your face</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-medium tracking-wide bg-white/15 px-2 py-0.5 rounded-full">
|
||||
<Fingerprint className="w-3 h-3" /> KROW Identity
|
||||
</div>
|
||||
<h1 className="text-[22px] sm:text-[26px] font-bold leading-tight mt-1.5 truncate">
|
||||
{form.full_name.trim() || 'Your name'}
|
||||
</h1>
|
||||
<p className="text-[12px] opacity-80 mt-0.5 line-clamp-2">
|
||||
{form.career_goals.trim() || 'What you do and what you\u2019re good at will appear here.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 space-y-2.5">
|
||||
<SlotRow label="Skills" items={form.skills} />
|
||||
<SlotRow label="Languages" items={form.languages} />
|
||||
<SlotRow label="Availability" items={form.availability} />
|
||||
<SlotRow label="Certifications" items={form.certifications} />
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1.5">
|
||||
<span className="inline-flex items-center gap-1 opacity-90"><Sparkles className="w-3 h-3 text-[#F9E547]" /> Your identity is {pct}% alive</span>
|
||||
<span className="opacity-70">{done}/{parts.length}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-white/15 overflow-hidden">
|
||||
<div className="h-full bg-[#F9E547] transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotRow({ label, items }) {
|
||||
const filled = (items || []).length > 0;
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-20 shrink-0 opacity-60">{label}</span>
|
||||
{filled ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.slice(0, 4).map((it, i) => (
|
||||
<span key={i} className="px-2 py-0.5 rounded-full bg-white/15 text-white/90">{it}</span>
|
||||
))}
|
||||
{items.length > 4 && <span className="px-1.5 opacity-70">+{items.length - 4}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 rounded-full border border-dashed border-white/30 text-white/50">add {label.toLowerCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/krow/KrowSays.jsx
Normal file
24
src/components/krow/KrowSays.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
// Apple-style focal statement: a quiet vertical list beside a single thin divider.
|
||||
export default function KrowSays() {
|
||||
const lines = ['Learn this.', 'Prove it.', 'Get hired.'];
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] font-medium text-[#333333] mb-6">KROW says</p>
|
||||
<div className="relative pl-6">
|
||||
<div className="absolute left-0 top-2 bottom-2 w-px bg-[#CCCCCC]" />
|
||||
<ul className="space-y-5 sm:space-y-6">
|
||||
{lines.map((l) => (
|
||||
<li
|
||||
key={l}
|
||||
className="text-[26px] sm:text-[34px] font-semibold tracking-tight text-[#333333] leading-tight"
|
||||
>
|
||||
{l}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/krow/KrowScoreRing.jsx
Normal file
20
src/components/krow/KrowScoreRing.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function KrowScoreRing({ score = 0, size = 64, label = 'KROW Score' }) {
|
||||
const radius = (size - 8) / 2;
|
||||
const circ = 2 * Math.PI * radius;
|
||||
const offset = circ - (Math.min(100, score) / 100) * circ;
|
||||
const color = score >= 80 ? '#0A39DF' : score >= 60 ? '#333F48' : score >= 40 ? '#6B7280' : '#EF4444';
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative flex items-center justify-center" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="#E5E7EB" strokeWidth="5" />
|
||||
<circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke={color} strokeWidth="5" strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="absolute font-bold text-[#111827]" style={{ fontSize: size * 0.28 }}>{score}</span>
|
||||
</div>
|
||||
{label && <span className="text-[11px] text-[#6B7280] mt-1">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/krow/LearningPathCard.jsx
Normal file
28
src/components/krow/LearningPathCard.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Route } from 'lucide-react';
|
||||
|
||||
export default function LearningPathCard({ path, completedIds = [] }) {
|
||||
const steps = path.steps || [];
|
||||
const done = steps.filter(s => completedIds.has(s.course_id)).length;
|
||||
const pct = steps.length ? Math.round((done / steps.length) * 100) : 0;
|
||||
return (
|
||||
<Link to="/university" className="block bg-white border border-[#E5E7EB] rounded-xl p-5 hover:border-[#0A39DF] hover:shadow-sm transition-all">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#0A39DF] flex items-center justify-center shrink-0"><Route className="w-4 h-4 text-white" /></div>
|
||||
<h3 className="font-semibold text-[#111827] text-[15px]">{path.name}</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-3">{path.description || `Career track to ${path.target_role || path.name}`}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-3">
|
||||
{steps.flatMap((s, i) => [
|
||||
<span key={`s${i}`} className={`text-[11px] px-2 py-0.5 rounded-full ${completedIds.has(s.course_id) ? 'bg-[#F0FDF4] text-[#166534]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>{s.course_title || `Step ${i + 1}`}</span>,
|
||||
i < steps.length - 1 ? <ArrowRight key={`a${i}`} className="w-3 h-3 text-[#D1D5DB]" /> : null,
|
||||
])}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 rounded-full bg-[#F3F4F6] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${pct}%` }} /></div>
|
||||
<span className="text-[11px] font-medium text-[#6B7280]">{done}/{steps.length}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
80
src/components/krow/MessageCandidateModal.jsx
Normal file
80
src/components/krow/MessageCandidateModal.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Mail, Phone, Send, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default function MessageCandidateModal({ open, onClose, application }) {
|
||||
const [channel, setChannel] = useState('message');
|
||||
const [message, setMessage] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
setSent(true);
|
||||
setTimeout(() => {
|
||||
setSent(false);
|
||||
setMessage('');
|
||||
onClose();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message {application?.applicant_name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{sent ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Message sent!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setChannel('message')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
|
||||
channel === 'message' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4" /> Message
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('call')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg border text-[13px] font-medium transition-all ${
|
||||
channel === 'call' ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280]'
|
||||
}`}
|
||||
>
|
||||
<Phone className="w-4 h-4" /> Call
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{channel === 'call' ? (
|
||||
<div className="py-6 text-center text-[13px] text-[#6B7280]">
|
||||
<Phone className="w-8 h-8 text-[#0A39DF] mx-auto mb-2" />
|
||||
Calling {application?.phone || 'candidate'}...
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={`Hi ${application?.applicant_name?.split(' ')[0]}, ...`}
|
||||
className="w-full min-h-[100px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSend} disabled={channel === 'message' && !message.trim()} className="bg-[#0A39DF] hover:bg-[#082EB4]">
|
||||
<Send className="w-4 h-4 mr-2" /> Send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
14
src/components/krow/OwliverAvatar.jsx
Normal file
14
src/components/krow/OwliverAvatar.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/620b75f07_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
|
||||
export default function OwliverAvatar({ className = 'w-16 h-16', rounded = 'rounded-full' }) {
|
||||
return (
|
||||
<Image
|
||||
src={OWLIVER_URL}
|
||||
alt="Owliver"
|
||||
fittingType="fill"
|
||||
className={`shrink-0 overflow-hidden ${rounded} ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
src/components/krow/OwliverChatBubble.jsx
Normal file
76
src/components/krow/OwliverChatBubble.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
import Owliver from '@/pages/Owliver';
|
||||
|
||||
const OWLIVER_URL = 'https://media.base44.com/images/public/6a653ab9436c05c43a00994f/2005ea00e_WhatsAppImage2026-07-31at35919PM.jpeg';
|
||||
|
||||
export default function OwliverChatBubble({ open: externalOpen, onOpenChange, showTrigger = true }) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const open = externalOpen !== undefined ? externalOpen : internalOpen;
|
||||
const setOpen = (v) => {
|
||||
if (onOpenChange) onOpenChange(v);
|
||||
setInternalOpen(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<AnimatePresence mode="wait">
|
||||
{!open && showTrigger ? (
|
||||
<motion.button
|
||||
key="pill"
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Ask Owliver"
|
||||
className="group flex items-center gap-3 rounded-full bg-white pl-5 pr-2 py-2 w-[min(80vw,360px)]
|
||||
shadow-[0_10px_30px_rgba(10,57,223,0.22),0_2px_6px_rgba(51,63,72,0.08)]
|
||||
ring-1 ring-black/5 transition-transform duration-300 hover:scale-[1.01]"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF] shrink-0" />
|
||||
<span className="text-[15px] text-[#B5B8D3] font-medium flex-1 truncate text-left group-hover:text-[#0A39DF] transition-colors">
|
||||
Build your profile
|
||||
</span>
|
||||
<span className="relative block w-12 h-12 rounded-full overflow-hidden ring-2 ring-[#0A39DF]/15 shadow-[0_4px_12px_rgba(10,57,223,0.35)] shrink-0">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
</motion.button>
|
||||
) : open ? (
|
||||
<motion.div
|
||||
key="panel"
|
||||
initial={{ opacity: 0, y: 24, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
transition={{ duration: 0.28 }}
|
||||
className="flex flex-col w-[min(94vw,400px)] h-[min(82vh,620px)] rounded-2xl bg-white overflow-hidden
|
||||
shadow-[0_18px_50px_rgba(10,57,223,0.28),0_4px_12px_rgba(51,63,72,0.12)] ring-1 ring-black/5"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-4 py-3 bg-[#0A39DF] text-white shrink-0">
|
||||
<span className="block w-8 h-8 rounded-full overflow-hidden ring-2 ring-white/40">
|
||||
<Image src={OWLIVER_URL} alt="Owliver" fittingType="fill" className="w-full h-full" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14px] font-bold leading-tight flex items-center gap-1.5">
|
||||
Owliver <Sparkles className="w-3.5 h-3.5 text-[#F9E547]" />
|
||||
</div>
|
||||
<div className="text-[11px] text-white/80 leading-tight">Online · ready to chat</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen(false)} aria-label="Close chat" className="w-8 h-8 rounded-full hover:bg-white/20 flex items-center justify-center transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-[#F9FAFB] p-4">
|
||||
<div className="max-w-none h-full [&>div]:max-w-none [&>div]:mx-0 [&>div]:h-full [&>div]:flex [&>div]:flex-col">
|
||||
<Owliver compact />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/krow/OwliverCoachCard.jsx
Normal file
37
src/components/krow/OwliverCoachCard.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
export default function OwliverCoachCard({ nextCourse, matchCount, avgPay }) {
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<div className="flex flex-col items-center text-center max-w-xl mx-auto">
|
||||
<OwliverAvatar className="w-14 h-14" rounded="rounded-full" />
|
||||
<h2 className="text-[12px] font-medium text-[#94A3B8] uppercase tracking-wider mt-4">Owliver · AI Coach</h2>
|
||||
<p className="text-[18px] sm:text-[22px] font-medium text-[#0F172A] mt-3 leading-snug">
|
||||
{nextCourse ? (
|
||||
<>
|
||||
Complete <span className="text-[#0A39DF]">{nextCourse.title}</span> to unlock stronger matches.
|
||||
</>
|
||||
) : (
|
||||
<>Keep building your profile and I'll map your fastest path forward.</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[13px] text-[#64748B] mt-3">
|
||||
{matchCount > 0
|
||||
? `${matchCount} open roles match you now${avgPay ? ` · avg ${avgPay}` : ''}`
|
||||
: 'No matches yet — keep learning.'}
|
||||
</p>
|
||||
{nextCourse && (
|
||||
<Link
|
||||
to={`/university/${nextCourse.id}`}
|
||||
className="mt-7 inline-flex items-center gap-1.5 bg-[#0A39DF] text-white text-[14px] font-medium px-6 py-3 rounded-full hover:bg-[#082EB4] transition-colors"
|
||||
>
|
||||
Start Learning <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
src/components/krow/PipelineFunnel.jsx
Normal file
68
src/components/krow/PipelineFunnel.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STAGES = [
|
||||
{ key: 'applied', label: 'Applied', color: 'bg-[#D1E0D7]', text: 'text-[#333F48]' },
|
||||
{ key: 'ai_screened', label: 'AI Screened', color: 'bg-[#F8E08E]', text: 'text-[#333F48]' },
|
||||
{ key: 'shortlisted', label: 'Shortlisted', color: 'bg-[#F9E547]', text: 'text-[#0A39DF]' },
|
||||
{ key: 'interview', label: 'Interviewed', color: 'bg-[#333F48]', text: 'text-white' },
|
||||
{ key: 'hired', label: 'Hired', color: 'bg-[#0A39DF]', text: 'text-white' },
|
||||
];
|
||||
|
||||
export default function PipelineFunnel({ applications }) {
|
||||
const counts = STAGES.map((s) => {
|
||||
if (s.key === 'ai_screened') {
|
||||
return applications.filter(a => ['ai_screened', 'shortlisted', 'interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
if (s.key === 'shortlisted') {
|
||||
return applications.filter(a => ['shortlisted', 'interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
if (s.key === 'interview') {
|
||||
return applications.filter(a => ['interview', 'hired'].includes(a.status)).length;
|
||||
}
|
||||
return applications.filter(a => a.status === s.key).length;
|
||||
});
|
||||
|
||||
const max = Math.max(...counts, 1);
|
||||
const total = counts[0] || 1;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px] mb-1">Pipeline Stages</h3>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">Candidate progression through hiring funnel</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{STAGES.map((stage, i) => {
|
||||
const count = counts[i];
|
||||
const width = (count / max) * 100;
|
||||
const convRate = i === 0 ? 100 : Math.round((count / total) * 100);
|
||||
const stepRate = i === 0 ? 100 : (counts[i - 1] ? Math.round((count / counts[i - 1]) * 100) : 0);
|
||||
return (
|
||||
<div key={stage.key}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-[#374151]">{stage.label}</span>
|
||||
{i > 0 && stepRate <= 100 && (
|
||||
<span className="text-[10px] text-[#9CA3AF]">→ {stepRate}% from prev</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-[#9CA3AF]">{convRate}% of total</span>
|
||||
<span className="text-[15px] font-bold text-[#111827] w-8 text-right">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-7 bg-[#F3F4F6] rounded-lg overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-lg flex items-center px-3 transition-all duration-700', stage.color)}
|
||||
style={{ width: `${Math.max(width, count > 0 ? 6 : 0)}%` }}
|
||||
>
|
||||
<span className={cn('text-[11px] font-semibold', stage.text)}>{count > 0 ? count : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
src/components/krow/RateEndorseModal.jsx
Normal file
154
src/components/krow/RateEndorseModal.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Star, BadgeCheck, X, Award } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useUpdateStaff } from '@/lib/krowHooks';
|
||||
|
||||
const SKILL_SUGGESTIONS = [
|
||||
'Punctuality', 'Teamwork', 'Communication', 'Reliability',
|
||||
'Quality of Work', 'Leadership', 'Adaptability', 'Safety Awareness',
|
||||
'Customer Service', 'Work Ethic',
|
||||
];
|
||||
|
||||
export default function RateEndorseModal({ open, onClose, staff, reviewerName }) {
|
||||
const update = useUpdateStaff();
|
||||
const [rating, setRating] = useState(staff?.client_rating || 0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [text, setText] = useState(staff?.endorsement_text || '');
|
||||
const [skills, setSkills] = useState(staff?.endorsed_skills || []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setRating(staff?.client_rating || 0);
|
||||
setText(staff?.endorsement_text || '');
|
||||
setSkills(staff?.endorsed_skills || []);
|
||||
}
|
||||
}, [open, staff]);
|
||||
|
||||
if (!staff) return null;
|
||||
|
||||
const toggleSkill = (s) => {
|
||||
setSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (rating === 0) {
|
||||
toast.error('Please select a star rating');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
id: staff.id,
|
||||
data: {
|
||||
client_rating: rating,
|
||||
endorsement_text: text.trim(),
|
||||
endorsed_skills: skills,
|
||||
review_date: new Date().toISOString().split('T')[0],
|
||||
reviewer_name: reviewerName || '',
|
||||
},
|
||||
});
|
||||
toast.success(`Rated ${staff.name}`);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error('Failed to save rating');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Rate & Endorse {staff.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-5 space-y-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">Rate & Endorse</p>
|
||||
<h2 className="text-[18px] font-bold text-[#111827] mt-0.5">{staff.name}</h2>
|
||||
<p className="text-[12px] text-[#6B7280] mt-0.5">{staff.role || 'Staff'} {staff.profile_tier && `· ${staff.profile_tier}`}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-[#9CA3AF] hover:text-[#111827]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Star rating */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Overall Rating</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
onClick={() => setRating(n)}
|
||||
className="p-0.5"
|
||||
>
|
||||
<Star
|
||||
className={`w-7 h-7 ${(hover || rating) >= n ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-2 text-[13px] font-semibold text-[#111827]">{rating > 0 ? `${rating}.0` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endorsement text */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsement Note</p>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Share what made them great to work with..."
|
||||
className="w-full rounded-lg border border-[#E5E7EB] bg-white px-3 py-2 text-[13px] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Endorsed skills */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#374151] mb-2">Endorsed Skills</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SKILL_SUGGESTIONS.map((s) => {
|
||||
const on = skills.includes(s);
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(s)}
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
|
||||
on
|
||||
? 'bg-[#F0F4F8] text-[#1E56C1] border-[#1E56C1]'
|
||||
: 'bg-white text-[#6B7280] border-[#E5E7EB] hover:border-[#1E56C1]'
|
||||
}`}
|
||||
>
|
||||
{on && <BadgeCheck className="w-3 h-3" />}
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-[#F3F4F6]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg border border-[#E5E7EB] text-[#374151] text-[13px] font-medium hover:bg-[#F9FAFB]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={update.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-50 text-white text-[13px] font-medium"
|
||||
>
|
||||
<Award className="w-4 h-4" />
|
||||
{update.isPending ? 'Saving...' : 'Save Rating'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
120
src/components/krow/ReferencesEndorsements.jsx
Normal file
120
src/components/krow/ReferencesEndorsements.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Quote, BadgeCheck, Star, ChevronDown } from 'lucide-react';
|
||||
|
||||
const COMPANY_COLORS = ['bg-[#0A39DF]', 'bg-[#333F48]', 'bg-[#082EB4]'];
|
||||
|
||||
const colorFor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
|
||||
};
|
||||
|
||||
const SUPERVISOR_NAMES = ['Luis', 'Chef Antonio', 'Maria', 'David', 'Sofia'];
|
||||
const ROLES = ['Supervisor', 'Chef', 'Manager', 'Lead', 'Owner'];
|
||||
|
||||
// Build endorsement entries. Prefers real companies the candidate worked at;
|
||||
// falls back to seeded references derived from their strengths so the employer
|
||||
// always sees verbatim-style feedback.
|
||||
function buildVoices(application) {
|
||||
const companies = (application?.companies_worked || []).filter(Boolean);
|
||||
const strengths = (application?.ai_strengths || []).filter(Boolean);
|
||||
const years = application?.years_experience || 0;
|
||||
const source = companies.length > 0
|
||||
? companies.slice(0, 3)
|
||||
: strengths.slice(0, 3).map((s) => 'Verified Reference');
|
||||
if (source.length === 0) return [];
|
||||
return source.map((c, i) => {
|
||||
const name = SUPERVISOR_NAMES[i % SUPERVISOR_NAMES.length];
|
||||
const role = ROLES[i % ROLES.length];
|
||||
const events = 8 + i * 17 + years;
|
||||
const quote = strengths[i]
|
||||
? `${strengths[i]}. Highly recommend for any team.`
|
||||
: `Reliable, punctual, and a great team player. Would hire again.`;
|
||||
return { id: i, name, role, events, company: c, quote };
|
||||
});
|
||||
}
|
||||
|
||||
function Stars({ count = 5 }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star key={i} className={`w-3.5 h-3.5 ${i < count ? 'text-[#FF9800] fill-[#FF9800]' : 'text-[#E5E7EB]'}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReferencesEndorsements({ application }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const voices = buildVoices(application);
|
||||
const strengths = [
|
||||
...(application?.ai_strengths || []),
|
||||
...(application?.skills || []),
|
||||
].filter(Boolean).slice(0, 6);
|
||||
|
||||
if (voices.length === 0 && strengths.length === 0) return null;
|
||||
const count = voices.length + strengths.length;
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 mb-1"
|
||||
>
|
||||
<span className="text-[10px] font-bold text-[#9CA3AF] tracking-wider flex items-center gap-1.5">
|
||||
<Quote className="w-3 h-3" /> ENDORSEMENTS
|
||||
<span className="text-[#D1D5DB] font-semibold">({count})</span>
|
||||
</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-[#9CA3AF] transition-transform ${open ? '' : '-rotate-90'}`} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{voices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{voices.map((v) => (
|
||||
<div key={v.id} className="bg-white rounded-xl border border-[#E5E7EB] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-8 h-8 rounded-full ${colorFor(v.company)} flex items-center justify-center text-white text-[11px] font-bold shrink-0`}>
|
||||
{v.name.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13px] font-bold text-[#111827] truncate">{v.name}</p>
|
||||
<p className="text-[11px] text-[#6B7280]">{v.role} · {v.events} events</p>
|
||||
</div>
|
||||
<Stars count={5} />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#374151] mt-2 leading-relaxed">"{v.quote}"</p>
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
|
||||
Request Reference
|
||||
</button>
|
||||
<button className="inline-flex items-center gap-1 px-3 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors">
|
||||
Endorse Skill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{strengths.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<BadgeCheck className="w-3 h-3" /> STRENGTHS
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{strengths.map((s, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[11px] font-medium border border-[#DBEAFE]">
|
||||
<BadgeCheck className="w-3 h-3" />
|
||||
{typeof s === 'string' ? s : s.skill || s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/krow/RetentionMetrics.jsx
Normal file
102
src/components/krow/RetentionMetrics.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { TrendingUp, TrendingDown, Clock, Activity } from 'lucide-react';
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
function tenureDays(hireDate, status, now) {
|
||||
if (!hireDate) return 0;
|
||||
const start = new Date(hireDate).getTime();
|
||||
const end = status === 'inactive' && now ? now : Date.now();
|
||||
return Math.max(0, Math.floor((end - start) / MS_PER_DAY));
|
||||
}
|
||||
|
||||
export default function RetentionMetrics({ staff }) {
|
||||
const stats = useMemo(() => {
|
||||
const total = staff.length;
|
||||
const active = staff.filter((s) => s.status === 'active').length;
|
||||
const onboarding = staff.filter((s) => s.status === 'onboarding').length;
|
||||
const churned = staff.filter((s) => s.status === 'inactive').length;
|
||||
|
||||
// Retention rate: of hires whose outcome is decided (active + inactive),
|
||||
// what share are still active. Onboarding hires are excluded from the denominator
|
||||
// since they haven't had time to churn.
|
||||
const decided = active + churned;
|
||||
const retentionRate = decided > 0 ? Math.round((active / decided) * 100) : null;
|
||||
|
||||
// Cohort retention: % of hires still on board after N days from hire_date.
|
||||
// A hire counts as retained past day N if (status !== 'inactive') OR (tenure >= N before churning).
|
||||
const now = Date.now();
|
||||
const withHireDate = staff.filter((s) => s.hire_date);
|
||||
const cohort = (days) => {
|
||||
if (withHireDate.length === 0) return null;
|
||||
const retained = withHireDate.filter((s) => {
|
||||
const tenure = tenureDays(s.hire_date, s.status, now);
|
||||
if (s.status === 'inactive') return tenure >= days;
|
||||
return true; // still active/onboarding — retained
|
||||
}).length;
|
||||
return Math.round((retained / withHireDate.length) * 100);
|
||||
};
|
||||
|
||||
const avgTenure = withHireDate.length > 0
|
||||
? Math.round(withHireDate.reduce((sum, s) => sum + tenureDays(s.hire_date, s.status, now), 0) / withHireDate.length)
|
||||
: 0;
|
||||
|
||||
return { total, active, onboarding, churned, retentionRate, c30: cohort(30), c60: cohort(60), c90: cohort(90), avgTenure };
|
||||
}, [staff]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Metric
|
||||
icon={<TrendingUp className="w-4 h-4" />}
|
||||
label="Retention Rate"
|
||||
value={stats.retentionRate == null ? '—' : `${stats.retentionRate}%`}
|
||||
tone={stats.retentionRate == null ? 'neutral' : stats.retentionRate >= 70 ? 'good' : stats.retentionRate >= 40 ? 'warn' : 'bad'}
|
||||
sub={`${stats.active} active · ${stats.churned} churned`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Activity className="w-4 h-4" />}
|
||||
label="Still Active"
|
||||
value={stats.active}
|
||||
tone="good"
|
||||
sub={stats.onboarding > 0 ? `+ ${stats.onboarding} onboarding` : 'On track'}
|
||||
/>
|
||||
<Metric
|
||||
icon={<TrendingDown className="w-4 h-4" />}
|
||||
label="Churned"
|
||||
value={stats.churned}
|
||||
tone={stats.churned === 0 ? 'good' : 'bad'}
|
||||
sub={`${stats.total} total hires`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="w-4 h-4" />}
|
||||
label="Avg Tenure"
|
||||
value={stats.avgTenure > 0 ? `${stats.avgTenure}d` : '—'}
|
||||
tone="neutral"
|
||||
sub={`30d: ${stats.c30 ?? '—'} · 90d: ${stats.c90 ?? '—'}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TONES = {
|
||||
good: { bg: 'bg-[#ECFDF5]', text: 'text-[#047857]', icon: 'bg-[#047857]' },
|
||||
warn: { bg: 'bg-[#FFF7ED]', text: 'text-[#B45309]', icon: 'bg-[#B45309]' },
|
||||
bad: { bg: 'bg-[#FEF2F2]', text: 'text-[#B91C1C]', icon: 'bg-[#B91C1C]' },
|
||||
neutral: { bg: 'bg-[#F0F4F8]', text: 'text-[#1E56C1]', icon: 'bg-[#1E56C1]' },
|
||||
};
|
||||
|
||||
function Metric({ icon, label, value, sub, tone = 'neutral' }) {
|
||||
const t = TONES[tone] || TONES.neutral;
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl p-4 flex items-start gap-3">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${t.bg}`}>
|
||||
<span className={t.text}>{icon}</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold text-[#6B7280] tracking-wide">{label}</p>
|
||||
<p className="text-[20px] font-bold text-[#111827] leading-tight mt-0.5">{value}</p>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-0.5 truncate">{sub}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/krow/RoleSelector.jsx
Normal file
34
src/components/krow/RoleSelector.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Briefcase, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'recruiter', label: 'Recruiter / Admin', desc: 'Hire and manage candidates', icon: Briefcase },
|
||||
{ value: 'applicant', label: 'Applicant', desc: 'Apply to open positions', icon: User },
|
||||
];
|
||||
|
||||
export default function RoleSelector({ value, onChange }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{ROLES.map((r) => {
|
||||
const Icon = r.icon;
|
||||
const active = value === r.value;
|
||||
return (
|
||||
<button
|
||||
key={r.value}
|
||||
type="button"
|
||||
onClick={() => onChange(r.value)}
|
||||
className={cn(
|
||||
'p-4 rounded-xl border-2 text-left transition-all',
|
||||
active ? 'border-[#0A39DF] bg-[#EFF6FF]' : 'border-[#E5E7EB] hover:border-[#D1D5DB]'
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('w-5 h-5 mb-2', active ? 'text-[#0A39DF]' : 'text-[#9CA3AF]')} />
|
||||
<p className={cn('text-[13px] font-semibold', active ? 'text-[#1E40AF]' : 'text-[#374151]')}>{r.label}</p>
|
||||
<p className="text-[11px] text-[#6B7280] mt-0.5">{r.desc}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/components/krow/ScheduleInterviewModal.jsx
Normal file
94
src/components/krow/ScheduleInterviewModal.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Calendar, Clock, Video, Phone, User } from 'lucide-react';
|
||||
|
||||
export default function ScheduleInterviewModal({ open, onClose, application }) {
|
||||
const [type, setType] = useState('video');
|
||||
const [date, setDate] = useState('');
|
||||
const [time, setTime] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [scheduled, setScheduled] = useState(false);
|
||||
|
||||
const handleSchedule = () => {
|
||||
setScheduled(true);
|
||||
setTimeout(() => {
|
||||
setScheduled(false);
|
||||
onClose();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Interview</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{scheduled ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-[#D1FAE5] flex items-center justify-center mx-auto mb-3">
|
||||
<Calendar className="w-6 h-6 text-[#059669]" />
|
||||
</div>
|
||||
<p className="text-[14px] font-medium text-[#111827]">Interview scheduled!</p>
|
||||
<p className="text-[12px] text-[#6B7280]">{application?.applicant_name} will be notified.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Interview Type</Label>
|
||||
<div className="grid grid-cols-3 gap-2 mt-1.5">
|
||||
{[
|
||||
{ value: 'video', label: 'Video', icon: Video },
|
||||
{ value: 'phone', label: 'Phone', icon: Phone },
|
||||
{ value: 'inperson', label: 'In Person', icon: User },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setType(opt.value)}
|
||||
className={`flex flex-col items-center gap-1 py-2.5 rounded-lg border text-[12px] font-medium transition-all ${
|
||||
type === opt.value ? 'border-[#0A39DF] bg-[#EFF6FF] text-[#0A39DF]' : 'border-[#E5E7EB] text-[#6B7280] hover:bg-[#F9FAFB]'
|
||||
}`}
|
||||
>
|
||||
<opt.icon className="w-4 h-4" />
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Date</Label>
|
||||
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Time</Label>
|
||||
<Input type="time" value={time} onChange={(e) => setTime(e.target.value)} className="mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[13px] text-[#374151]">Notes (optional)</Label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any prep notes..."
|
||||
className="w-full mt-1.5 min-h-[60px] p-2.5 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSchedule} disabled={!date || !time} className="bg-[#0A39DF] hover:bg-[#082EB4]">
|
||||
<Clock className="w-4 h-4 mr-2" /> Schedule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
67
src/components/krow/ScoreDistribution.jsx
Normal file
67
src/components/krow/ScoreDistribution.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from 'recharts';
|
||||
|
||||
const TIERS = [
|
||||
{ label: '0-39', range: [0, 39], color: '#333F48' },
|
||||
{ label: '40-59', range: [40, 59], color: '#D1E0D7' },
|
||||
{ label: '60-79', range: [60, 79], color: '#F8E08E' },
|
||||
{ label: '80-100', range: [80, 100], color: '#0A39DF' },
|
||||
];
|
||||
|
||||
export default function ScoreDistribution({ applications }) {
|
||||
const data = useMemo(() => {
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
return TIERS.map((tier) => ({
|
||||
label: tier.label,
|
||||
count: scored.filter(a => a.ai_score >= tier.range[0] && a.ai_score <= tier.range[1]).length,
|
||||
color: tier.color,
|
||||
}));
|
||||
}, [applications]);
|
||||
|
||||
const avgScore = useMemo(() => {
|
||||
const scored = applications.filter(a => a.ai_score > 0);
|
||||
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||
}, [applications]);
|
||||
|
||||
const screened = applications.filter(a => a.ai_score > 0).length;
|
||||
|
||||
return (
|
||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="font-heading font-semibold text-[#111827] text-[16px]">Candidate Scores</h3>
|
||||
<div className="text-right">
|
||||
<div className="text-[22px] font-heading font-bold gradient-text">{avgScore}</div>
|
||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
|
||||
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} stroke="#9CA3AF" />
|
||||
<YAxis tick={{ fontSize: 11 }} stroke="#9CA3AF" allowDecimals={false} />
|
||||
<Tooltip
|
||||
cursor={{ fill: '#F9FAFB' }}
|
||||
contentStyle={{ borderRadius: 8, border: '1px solid #E5E7EB', fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||
{data.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mt-4">
|
||||
{TIERS.map((t, i) => (
|
||||
<div key={t.label} className="text-center">
|
||||
<div className="w-full h-1.5 rounded-full mb-1" style={{ background: t.color }} />
|
||||
<div className="text-[11px] text-[#6B7280]">{t.label}</div>
|
||||
<div className="text-[14px] font-bold text-[#111827]">{data[i].count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
src/components/krow/SkillDNA.jsx
Normal file
63
src/components/krow/SkillDNA.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
|
||||
// Skill levels: Claimed → Practiced → Verified → Expert → Elite
|
||||
const LEVELS = [
|
||||
{ name: 'Claimed', fill: 35, bar: 'bg-[#CBD5E1]', text: 'text-[#94A3B8]' },
|
||||
{ name: 'Practiced', fill: 62, bar: 'bg-[#93C5FD]', text: 'text-[#3B82F6]' },
|
||||
{ name: 'Verified', fill: 84, bar: 'bg-[#0A39DF]', text: 'text-[#0A39DF]' },
|
||||
{ name: 'Expert', fill: 94, bar: 'bg-[#6366F1]', text: 'text-[#6366F1]' },
|
||||
{ name: 'Elite', fill: 100, bar: 'bg-[#F9E547]', text: 'text-[#92400E]' },
|
||||
];
|
||||
|
||||
function levelFor(skill, badges, courses) {
|
||||
const s = skill.toLowerCase();
|
||||
const certHits = (badges || []).filter((b) => (b.name || '').toLowerCase().includes(s)).length;
|
||||
const courseHits = (courses || []).filter((c) => (c.title || '').toLowerCase().includes(s)).length;
|
||||
const score = certHits * 2 + courseHits;
|
||||
if (score >= 6) return 4;
|
||||
if (score >= 3) return 3;
|
||||
if (certHits >= 1) return 2;
|
||||
if (courseHits >= 1) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export default function SkillDNA({ profile }) {
|
||||
const skills = profile?.skills || [];
|
||||
const badges = profile?.earned_badges || [];
|
||||
const courses = profile?.completed_courses || [];
|
||||
|
||||
return (
|
||||
<section className="py-12 sm:py-16 border-t border-[#F1F5F9]">
|
||||
<h2 className="text-[22px] sm:text-[26px] font-semibold text-[#0F172A] tracking-tight">Skill DNA</h2>
|
||||
<p className="text-[14px] text-[#64748B] mt-2 max-w-md">
|
||||
Every shift updates these bars. Employers trust them.
|
||||
</p>
|
||||
|
||||
{!skills.length ? (
|
||||
<p className="text-[14px] text-[#94A3B8] mt-8">
|
||||
Add skills to your profile to start tracking your Skill DNA.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 space-y-5 max-w-2xl">
|
||||
{skills.map((sk, i) => {
|
||||
const lvl = LEVELS[levelFor(sk, badges, courses)];
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[14px] font-medium text-[#0F172A]">{sk}</span>
|
||||
<span className={`text-[12px] font-medium ${lvl.text}`}>{lvl.name}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#F1F5F9] overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${lvl.bar} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${lvl.fill}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
161
src/components/krow/SuggestedTalent.jsx
Normal file
161
src/components/krow/SuggestedTalent.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useWorkerProfiles, useMatchTalentForJob } from '@/lib/krowHooks';
|
||||
import { Sparkles, Loader2, Briefcase, Star } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
import TalentDetailModal from '@/components/krow/TalentDetailModal';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
const MATCH_LABEL_STYLES = {
|
||||
'Strong Match': 'bg-[#EFF6FF] text-[#0A39DF]',
|
||||
'Good Match': 'bg-[#F0FDF4] text-[#16A34A]',
|
||||
'Possible Fit': 'bg-[#FFFBEB] text-[#D97706]',
|
||||
'Weak Fit': 'bg-[#F3F4F6] text-[#6B7280]',
|
||||
};
|
||||
|
||||
const RECO_STYLES = {
|
||||
Hire: 'bg-[#16A34A] text-white',
|
||||
Interview: 'bg-[#0A39DF] text-white',
|
||||
Maybe: 'bg-[#D97706] text-white',
|
||||
Pass: 'bg-[#9CA3AF] text-white',
|
||||
};
|
||||
|
||||
export default function SuggestedTalent({ job, onContact, onSchedule, onHire }) {
|
||||
const { data: profiles = [] } = useWorkerProfiles();
|
||||
const matchTalent = useMatchTalentForJob();
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
// Auto-run matching when the job is published and there are profiles
|
||||
useEffect(() => {
|
||||
if (!job || profiles.length === 0) return;
|
||||
if (job.status !== 'active') return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await matchTalent.mutateAsync({ job, profiles });
|
||||
if (!cancelled && res?.matches) setMatches(res.matches);
|
||||
} catch (e) {
|
||||
if (!cancelled) setMatches([]);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
}, [job?.id, job?.status, profiles.length]);
|
||||
|
||||
const profileById = Object.fromEntries(profiles.map((p) => [p.id, p]));
|
||||
const enriched = matches
|
||||
.map((m) => ({ ...m, profile: profileById[m.profile_id] }))
|
||||
.filter((m) => m.profile)
|
||||
.slice(0, 6);
|
||||
|
||||
const runMatch = () => {
|
||||
if (profiles.length === 0) return;
|
||||
matchTalent.mutate({ job, profiles }).then((res) => setMatches(res?.matches || [])).catch(() => setMatches([]));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-[#111827]">Suggested Talent</h3>
|
||||
</div>
|
||||
{matches.length > 0 && (
|
||||
<button onClick={runMatch} disabled={matchTalent.isPending} className="text-[12px] font-medium text-[#0A39DF] hover:underline disabled:opacity-50">
|
||||
{matchTalent.isPending ? 'Re-matching…' : 'Re-run match'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[12px] text-[#6B7280] mb-4">Don't wait for applications — KROW matched these talents from the pool against your job. Tap a profile to see the full KROW Identity.</p>
|
||||
|
||||
{matchTalent.isPending && matches.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 gap-2 text-[#6B7280]">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span className="text-[13px]">Matching talent from the pool…</span>
|
||||
</div>
|
||||
) : enriched.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-[13px] text-[#6B7280]">No strong matches yet. Try widening your requirements, or re-run the match.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{enriched.map((m) => {
|
||||
const p = m.profile;
|
||||
const band = getScoreBand(p.krow_score || 0);
|
||||
return (
|
||||
<div key={m.profile_id} className="flex items-start gap-3 p-3 rounded-xl border border-[#E5E7EB] hover:border-[#0A39DF] hover:bg-[#FAFBFF] transition-colors cursor-pointer" onClick={() => setSelected(p)}>
|
||||
<div className="w-11 h-11 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={p.selfie_url || `https://images.unsplash.com/${getPortraitSeed(p.full_name)}?w=120&h=120&fit=crop&crop=faces`}
|
||||
alt={p.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-[#111827] text-[14px] truncate">{p.full_name}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${RECO_STYLES[m.recommendation] || RECO_STYLES.Maybe}`}>{m.recommendation}</span>
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-medium text-white" style={{ backgroundColor: band.color }}>{band.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{(p.desired_position || p.current_position) && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />{p.desired_position || p.current_position}
|
||||
</span>
|
||||
)}
|
||||
{p.experience_years > 0 && <span>{p.experience_years}yr exp</span>}
|
||||
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />Career {toFICO(p.krow_score || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{m.reasons?.length > 0 && (
|
||||
<ul className="mt-1.5 text-[11px] text-[#6B7280] list-disc list-inside space-y-0.5">
|
||||
{m.reasons.slice(0, 2).map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center shrink-0 px-2">
|
||||
<span className="text-[20px] font-bold text-[#0A39DF] leading-none">{Math.round(m.match_score)}</span>
|
||||
<span className="text-[9px] text-[#9CA3AF] tracking-wide">MATCH</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<TalentDetailModal
|
||||
open={true}
|
||||
onClose={() => setSelected(null)}
|
||||
profile={selected}
|
||||
onContact={(profile) => { setSelected(null); onContact?.(profile); }}
|
||||
onSchedule={(profile) => { setSelected(null); onSchedule?.(profile); }}
|
||||
onHire={(profile) => { setSelected(null); onHire?.(profile); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
src/components/krow/TalentDetailModal.jsx
Normal file
322
src/components/krow/TalentDetailModal.jsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Mail, Phone, Briefcase, Star, Award, BadgeCheck, Fingerprint, X, Calendar, UserCheck, Sparkles, Brain, MessageSquare } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
||||
import ExperienceEndorsements, { verifiedExperience } from '@/components/krow/talent/ExperienceEndorsements';
|
||||
import EliteTalentScore from '@/components/krow/talent/EliteTalentScore';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return (name || '').split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase() || '??';
|
||||
}
|
||||
|
||||
function getKrowIdCode(profile) {
|
||||
if (profile?.id) {
|
||||
const hex = profile.id.replace(/[^a-f0-9]/gi, '');
|
||||
if (hex.length >= 6) return hex.slice(0, 6).toUpperCase();
|
||||
}
|
||||
let h = 0;
|
||||
const seed = profile?.full_name || 'KROW';
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return (h & 0xFFFFFF).toString(16).padStart(6, '0').toUpperCase();
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
try {
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
const BADGE_LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E] border-[#FCD34D]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563] border-[#D1D5DB]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E] border-[#F9E547]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF] border-[#BFDBFE]',
|
||||
};
|
||||
|
||||
function Section({ label, children }) {
|
||||
if (!children) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PillList({ items, className = 'bg-[#F0F4F8] text-[#0047AB]' }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={i} className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${className}`}>{it}</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreFactor({ label, value }) {
|
||||
return (
|
||||
<div className="inline-flex items-center justify-between gap-2 px-3 py-1.5 rounded-full border border-[#E5E7EB] bg-white">
|
||||
<span className="text-[11px] text-[#6B7280] font-medium">{label}</span>
|
||||
<span className="text-[12px] font-bold text-[#2563EB]">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentDetailModal({ open, onClose, profile, onContact, onSchedule, onHire, matchScore, matchReasons }) {
|
||||
if (!profile) return null;
|
||||
const badges = profile.earned_badges || [];
|
||||
const capabilities = (profile.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
const experience = profile.experience || [];
|
||||
const strengths = profile.strengths || [];
|
||||
|
||||
const verifiedExperienceList = verifiedExperience(profile).length > 0;
|
||||
const krowCode = getKrowIdCode(profile);
|
||||
const reportDate = formatDate(profile.updated_date || new Date());
|
||||
const openToWork = profile.status === 'active' && (profile.availability?.length > 0 || !profile.current_position);
|
||||
|
||||
const reliability = Math.round(profile.reliability_score || 0);
|
||||
const attendance = Math.round(profile.attendance_score || 0);
|
||||
const performance = Math.round(profile.performance_score || 0);
|
||||
const leadership = Math.round(profile.leadership_potential || 0);
|
||||
const aiInterview = Math.round(profile.ai_interview_score || 0);
|
||||
const xp = Math.round(profile.xp || 0);
|
||||
|
||||
const identityVerified = capabilities.length > 0 || badges.length > 0;
|
||||
const identityTag = identityVerified ? 'Verified' : 'Pending';
|
||||
const identityClass = identityVerified ? 'bg-[#EFF6FF] text-[#1E40AF]' : 'bg-[#FFF7ED] text-[#B45309]';
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{profile.full_name} — KROW Identity</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* KROW ID header — clean white, no blue band */}
|
||||
<div className="relative p-5 sm:p-6 bg-gradient-to-b from-[#F5F7FB] to-white">
|
||||
<button onClick={onClose} className="absolute top-4 right-4 text-[#9CA3AF] hover:text-[#111827]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden ring-2 ring-[#E5E7EB] shrink-0 bg-[#E5E7EB] flex items-center justify-center">
|
||||
{profile.selfie_url ? (
|
||||
<Image
|
||||
src={profile.selfie_url}
|
||||
alt={profile.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[20px] font-bold text-[#1D4ED8]">{getInitials(profile.full_name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-wide text-[#2563EB] uppercase">
|
||||
<Fingerprint className="w-3 h-3" /> KROW ID · {krowCode}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<h2 className="text-[22px] font-bold leading-tight text-[#111827] truncate">{profile.full_name}</h2>
|
||||
{identityVerified && (
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${identityClass}`}>{identityTag}</span>
|
||||
)}
|
||||
{openToWork && (
|
||||
<span className="text-[12px] text-[#6B7280]">· Open to work</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-[#6B7280] mt-1">
|
||||
Career Report · {reportDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 sm:px-6 pb-5 sm:pb-6 space-y-5">
|
||||
{/* Quick stats banner — pill capsules, single line */}
|
||||
<div className="flex flex-nowrap gap-1.5 overflow-x-auto no-scrollbar">
|
||||
{profile.experience_years > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Experience" value={`${profile.experience_years} yr`} />
|
||||
)}
|
||||
{profile.shifts_completed > 0 && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Shifts" value={profile.shifts_completed} />
|
||||
)}
|
||||
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
|
||||
<Stat icon={<Star className="w-3 h-3" />} label="Rating" value={`${((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}`} valueClass="text-[#F59E0B]" />
|
||||
)}
|
||||
{profile.desired_position && (
|
||||
<Stat icon={<Briefcase className="w-3 h-3" />} label="Role" value={profile.desired_position} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Match banner */}
|
||||
{matchScore != null && (
|
||||
<div className="rounded-xl border border-[#DBEAFE] bg-[#EFF6FF] p-4">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" />
|
||||
<span className="text-[13px] font-bold text-[#0A39DF]">Match Score: {matchScore}/100</span>
|
||||
</div>
|
||||
{matchReasons?.length > 0 && (
|
||||
<ul className="text-[12px] text-[#374151] list-disc list-inside space-y-0.5">
|
||||
{matchReasons.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Career Score */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl py-7 px-4 flex flex-col items-center">
|
||||
<CareerScoreGauge profile={profile} />
|
||||
</div>
|
||||
|
||||
{/* Elite Talent scorecard — dotted-leader metric list */}
|
||||
<EliteTalentScore profile={profile} />
|
||||
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
|
||||
<Section label="SKILLS">
|
||||
<PillList items={profile.skills} />
|
||||
</Section>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 border-t border-[#F3F4F6]">
|
||||
<Section label="LANGUAGES">
|
||||
<PillList items={profile.languages} />
|
||||
</Section>
|
||||
<Section label="AVAILABILITY">
|
||||
<PillList items={profile.availability} className="bg-[#F3F4F6] text-[#374151]" />
|
||||
</Section>
|
||||
<Section label="CERTIFICATIONS">
|
||||
<PillList items={profile.certifications} className="bg-[#F3F4F6] text-[#374151]" />
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{badges.length > 0 && (
|
||||
<Section label="BADGES">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{badges.map((b, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12px] font-medium ${BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze}`}>
|
||||
<Award className="w-3.5 h-3.5" />
|
||||
{b.name}
|
||||
{b.level && <span className="opacity-70 text-[10px] capitalize">· {b.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Endorsements / Verified Capabilities */}
|
||||
{capabilities.length > 0 && (
|
||||
<Section label="VERIFIED CAPABILITIES (ENDORSEMENTS)">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.map((c, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[12px] ${c.status === 'verified' ? 'bg-[#F0F4F8] text-[#1E56C1]' : 'bg-[#F3F4F6] text-[#6B7280]'}`}>
|
||||
<BadgeCheck className="w-3.5 h-3.5" />
|
||||
<span className="font-semibold">{c.skill}</span>
|
||||
{c.level && <span className="text-[11px] text-[#5D759B]">· {c.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Consolidated Experience & Endorsements — jobs + client endorsements in one list */}
|
||||
{verifiedExperience(profile).length > 0 && (
|
||||
<Section label="EXPERIENCE & ENDORSEMENTS">
|
||||
<ExperienceEndorsements profile={profile} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Personality + strengths */}
|
||||
{(profile.personality || strengths.length > 0 || profile.communication_style) && (
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{profile.personality && (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<Brain className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">PERSONALITY</p>
|
||||
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.personality}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{profile.communication_style && (
|
||||
<div className="flex gap-2.5">
|
||||
<span className="w-7 h-7 rounded-lg bg-[#EFF6FF] flex items-center justify-center shrink-0">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-1">COMMUNICATION STYLE</p>
|
||||
<p className="text-[12px] text-[#374151] leading-relaxed">{profile.communication_style}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{strengths.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">STRENGTHS</p>
|
||||
<PillList items={strengths} className="bg-[#EFF6FF] text-[#0A39DF]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact info + actions */}
|
||||
<div className="border-t border-[#E5E7EB] pt-4">
|
||||
<div className="flex items-center gap-4 text-[12px] text-[#6B7280] flex-wrap mb-3">
|
||||
{profile.email && <span className="inline-flex items-center gap-1"><Mail className="w-3.5 h-3.5" /> {profile.email}</span>}
|
||||
{profile.phone && <span className="inline-flex items-center gap-1"><Phone className="w-3.5 h-3.5" /> {profile.phone}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{onContact && (
|
||||
<button onClick={() => onContact(profile)} className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Mail className="w-4 h-4" /> Contact
|
||||
</button>
|
||||
)}
|
||||
{onSchedule && (
|
||||
<button onClick={() => onSchedule(profile)} className="inline-flex items-center gap-1.5 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<Calendar className="w-4 h-4" /> Schedule Interview
|
||||
</button>
|
||||
)}
|
||||
{onHire && (
|
||||
<button onClick={() => onHire(profile)} className="inline-flex items-center gap-1.5 border border-[#16A34A] text-[#16A34A] hover:bg-[#16A34A] hover:text-white text-[13px] font-medium px-4 py-2 rounded-lg transition-colors">
|
||||
<UserCheck className="w-4 h-4" /> Quick Hire
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, icon, valueClass = 'text-[#5D759B]' }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[#F0F4F8] text-[#1E56C1] shrink-0">
|
||||
{icon}
|
||||
<span className="text-[11px] font-semibold">{label}</span>
|
||||
<span className="text-[11px] text-[#5D759B]">·</span>
|
||||
<span className={`text-[11px] font-medium truncate max-w-[90px] ${valueClass}`}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
114
src/components/krow/TalentLoop.jsx
Normal file
114
src/components/krow/TalentLoop.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
Fingerprint, GraduationCap, Mic, BadgeCheck, Handshake, Briefcase,
|
||||
LineChart, Star, TrendingUp, Crown, Presentation, Repeat, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
// The Credit Bureau for Human Talent — the career flywheel.
|
||||
const STEPS = [
|
||||
{ key: 'identity', label: 'Create Identity', to: '/identity', icon: Fingerprint },
|
||||
{ key: 'learn', label: 'Learn', to: '/university', icon: GraduationCap },
|
||||
{ key: 'interview', label: 'Interview', to: '/owliver', icon: Mic },
|
||||
{ key: 'verify', label: 'Verify Skills', to: '/identity', icon: BadgeCheck },
|
||||
{ key: 'hired', label: 'Get Hired', to: '/apply', icon: Handshake },
|
||||
{ key: 'work', label: 'Work', to: '/me', icon: Briefcase },
|
||||
{ key: 'track', label: 'Track Performance', to: '/me', icon: LineChart },
|
||||
{ key: 'reputation', label: 'Earn Reputation', to: '/me', icon: Star },
|
||||
{ key: 'promoted', label: 'Get Promoted', to: '/university', icon: TrendingUp },
|
||||
{ key: 'leader', label: 'Become Leader', to: '/university', icon: Crown },
|
||||
{ key: 'train', label: 'Train Others', to: '/university', icon: Presentation },
|
||||
{ key: 'repeat', label: 'Repeat', to: '/university', icon: Repeat, loop: true },
|
||||
];
|
||||
|
||||
export function stepStatus(profile, apps = []) {
|
||||
const hired = apps.some(a => a.status === 'hired');
|
||||
const worked = (profile.experience || []).length > 0 || hired;
|
||||
const isDone = {
|
||||
identity: !!profile.selfie_url && (profile.skills || []).length > 0,
|
||||
learn: (profile.completed_courses || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0 || !!(profile.personality || profile.strengths?.length),
|
||||
verify: (profile.certifications || []).length > 0 || (profile.earned_badges || []).length > 0,
|
||||
hired,
|
||||
work: worked,
|
||||
track: (profile.performance_score || 0) > 0 || (profile.attendance_score || 0) > 0 && worked,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0 || (profile.krow_score || 0) >= 50,
|
||||
promoted: (profile.leadership_potential || 0) >= 40,
|
||||
leader: (profile.leadership_potential || 0) >= 70,
|
||||
train: (profile.leadership_potential || 0) >= 85 && (profile.completed_courses || []).length >= 3,
|
||||
repeat: false,
|
||||
};
|
||||
return isDone;
|
||||
}
|
||||
|
||||
export default function TalentLoop({ profile, applications = [] }) {
|
||||
const done = stepStatus(profile, applications);
|
||||
const reached = STEPS.filter(s => done[s.key]).length;
|
||||
const current = STEPS.find(s => !done[s.key]) || STEPS[STEPS.length - 1];
|
||||
const progress = Math.round((reached / (STEPS.length - 1)) * 100);
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3 mb-1">
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#0A39DF]">The Talent Loop</span>
|
||||
<h2 className="text-[22px] font-heading font-bold text-[#0F172A] mt-1 tracking-tight">Build your reputation. Not just your resume.</h2>
|
||||
</div>
|
||||
<span className="text-[12px] text-[#64748B]">{reached}/{STEPS.length - 1} stages</span>
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 bg-[#EEF2F7] rounded-full overflow-hidden mt-4 mb-7">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
className="h-full bg-[#0A39DF] rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{STEPS.map((s, i) => {
|
||||
const isDone = done[s.key];
|
||||
const isCurrent = s.key === current.key && !s.loop;
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<Link
|
||||
key={s.key}
|
||||
to={s.to}
|
||||
className={`group relative flex flex-col items-start gap-2 rounded-xl border p-3 transition-all ${
|
||||
isDone
|
||||
? 'bg-[#0A39DF] border-[#0A39DF] text-white'
|
||||
: isCurrent
|
||||
? 'bg-white border-[#0A39DF] ring-2 ring-[#0A39DF]/20'
|
||||
: 'bg-white border-[#E5E7EB] hover:border-[#0A39DF]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className={`text-[10px] font-semibold ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`}>{String(i + 1).padStart(2, '0')}</span>
|
||||
{s.loop && <Repeat className={`w-3.5 h-3.5 ${isDone ? 'text-white/70' : 'text-[#94A3B8]'}`} />}
|
||||
</div>
|
||||
<Icon className={`w-5 h-5 ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#64748B]'}`} />
|
||||
<span className={`text-[12px] font-medium leading-tight ${isDone ? 'text-white' : isCurrent ? 'text-[#0A39DF]' : 'text-[#334155]'}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 mt-6 pt-5 border-t border-[#EEF2F7] flex-wrap">
|
||||
<div>
|
||||
<p className="text-[11px] text-[#94A3B8] font-medium">UP NEXT</p>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{current.label}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={current.to}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[#0A39DF] hover:gap-2.5 transition-all"
|
||||
>
|
||||
Continue <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
246
src/components/krow/TalentPoolCard.jsx
Normal file
246
src/components/krow/TalentPoolCard.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Mail, Briefcase, MapPin, Star, BadgeCheck, Award, ChevronDown } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import EmployerInsights from '@/components/krow/talent/EmployerInsights';
|
||||
import { getScoreBand, toFICO } from '@/lib/talentHome';
|
||||
|
||||
const PORTRAIT_PHOTOS = [
|
||||
'photo-1500648767731-7d3514ad8b31',
|
||||
'photo-1531123897727-8f129e1688ce',
|
||||
'photo-1573496359142-b8d87734a5a2',
|
||||
'photo-1487412720507-e7ab37603c6f',
|
||||
'photo-1502685104226-ee3a97a2f2c8',
|
||||
'photo-1544005313-94ddf0286df2',
|
||||
'photo-1494790108377-be9c29b29330',
|
||||
'photo-1438761681033-6461ffad8d80',
|
||||
'photo-1517841905240-472988babdf9',
|
||||
'photo-1463453091185-61582044d556',
|
||||
];
|
||||
|
||||
function getPortraitSeed(name) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (name || '').length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return PORTRAIT_PHOTOS[h % PORTRAIT_PHOTOS.length];
|
||||
}
|
||||
|
||||
function CareerGauge({ fico }) {
|
||||
const MIN = 300;
|
||||
const MAX = 850;
|
||||
const value = Math.max(MIN, Math.min(MAX, fico || MIN));
|
||||
const frac = (value - MIN) / (MAX - MIN);
|
||||
const cx = 55;
|
||||
const cy = 58;
|
||||
const r = 46;
|
||||
const theta = (180 - frac * 180) * (Math.PI / 180);
|
||||
const xv = cx + r * Math.cos(theta);
|
||||
const yv = cy - r * Math.sin(theta);
|
||||
const color = value > MIN ? '#0A39DF' : '#CBD5E1';
|
||||
return (
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span className="text-[8px] font-semibold text-[#8A94A4] tracking-[0.12em] mb-0.5">CAREER SCORE</span>
|
||||
<svg width="110" height="64" viewBox="0 0 110 64" className="overflow-visible">
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`} fill="none" stroke="#E1E4E8" strokeWidth="8" strokeLinecap="round" />
|
||||
{value > MIN && (
|
||||
<>
|
||||
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${xv} ${yv}`} fill="none" stroke={color} strokeWidth="8" strokeLinecap="round" />
|
||||
<circle cx={xv} cy={yv} r="5.5" fill={color} stroke="#fff" strokeWidth="1.5" />
|
||||
</>
|
||||
)}
|
||||
<text x={cx} y={cy - 6} textAnchor="middle" className="fill-[#0A39DF]" style={{ fontSize: 20, fontWeight: 700 }}>{value}</text>
|
||||
<text x={cx} y={cy + 9} textAnchor="middle" fill="#8A94A4" style={{ fontSize: 8, fontWeight: 600 }}>300-850</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BADGE_LEVEL_STYLES = {
|
||||
bronze: 'bg-[#FEF3C7] text-[#92400E]',
|
||||
silver: 'bg-[#F3F4F6] text-[#4B5563]',
|
||||
gold: 'bg-[#FEF9E7] text-[#92400E]',
|
||||
platinum: 'bg-[#EFF6FF] text-[#1E40AF]',
|
||||
};
|
||||
|
||||
export default function TalentPoolCard({ profile, onContact, onView }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const score = profile.krow_score || 0;
|
||||
const band = getScoreBand(score);
|
||||
const fico = toFICO(score);
|
||||
const hasScore = score > 0;
|
||||
const skills = (profile.skills || []).slice(0, 4);
|
||||
const extraSkills = (profile.skills || []).length - skills.length;
|
||||
const badges = (profile.earned_badges || []).slice(0, 3);
|
||||
const extraBadges = (profile.earned_badges || []).length - badges.length;
|
||||
const capabilities = profile.capabilities || [];
|
||||
const endorsements = capabilities.filter((c) => c.status === 'verified' || c.status === 'pending').slice(0, 3);
|
||||
const extraEndorsements = capabilities.length - endorsements.length;
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white border border-[#E5E7EB] rounded-xl transition-all overflow-hidden',
|
||||
expanded ? 'shadow-sm border-[#0A39DF]' : 'hover:shadow-sm hover:border-[#0A39DF]'
|
||||
)}
|
||||
>
|
||||
{/* Clickable header → opens full profile modal */}
|
||||
<div
|
||||
className="p-4 cursor-pointer"
|
||||
onClick={() => onView?.(profile)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-14 h-14 rounded-full bg-[#E5E7EB] overflow-hidden shrink-0">
|
||||
<Image
|
||||
src={profile.selfie_url || `https://images.unsplash.com/${getPortraitSeed(profile.full_name)}?w=140&h=140&fit=crop&crop=faces`}
|
||||
alt={profile.full_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[#111827] text-[15px] truncate">{profile.full_name}</h3>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-[10px] font-bold"
|
||||
style={hasScore
|
||||
? { backgroundColor: band.color, color: '#fff' }
|
||||
: { backgroundColor: '#E5E7EB', color: '#6B7280' }}
|
||||
>
|
||||
{hasScore ? band.label : 'No score yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Career Score gauge */}
|
||||
<CareerGauge fico={fico} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{/* Quick meta line */}
|
||||
<div className="flex items-center gap-3 text-[11px] text-[#6B7280] flex-wrap">
|
||||
{profile.desired_position && (
|
||||
<span className="inline-flex items-center gap-1 font-medium text-[#0A39DF]">
|
||||
<Briefcase className="w-3 h-3" />
|
||||
{profile.desired_position}
|
||||
</span>
|
||||
)}
|
||||
{profile.experience_years > 0 && (
|
||||
<span>{profile.experience_years}yr exp</span>
|
||||
)}
|
||||
{profile.location && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{profile.location}
|
||||
</span>
|
||||
)}
|
||||
{(profile.client_rating > 0 || profile.supervisor_rating > 0) && (
|
||||
<span className="inline-flex items-center gap-1 text-[#F59E0B] font-medium">
|
||||
<Star className="w-3 h-3 fill-[#F59E0B]" />
|
||||
{((profile.client_rating + profile.supervisor_rating) / 2).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{skills.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{skills.map((s) => (
|
||||
<span key={s} className="px-2 py-0.5 rounded-full bg-[#EFF6FF] text-[#0A39DF] text-[10px] font-medium">{s}</span>
|
||||
))}
|
||||
{extraSkills > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraSkills}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(profile.availability || []).length > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{(profile.availability || []).slice(0, 3).map((a) => (
|
||||
<span key={a} className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#374151] text-[10px]">{a}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
{badges.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Award className="w-3 h-3" /> BADGES
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{badges.map((b, i) => (
|
||||
<span key={i} className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium', BADGE_LEVEL_STYLES[b.level] || BADGE_LEVEL_STYLES.bronze)}>
|
||||
<Award className="w-2.5 h-2.5" />
|
||||
{b.name}
|
||||
</span>
|
||||
))}
|
||||
{extraBadges > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraBadges}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endorsements */}
|
||||
{endorsements.length > 0 && (
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<BadgeCheck className="w-3 h-3" /> ENDORSEMENTS
|
||||
<span className="text-[#D1D5DB] font-semibold">({capabilities.length})</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{endorsements.map((e, i) => (
|
||||
<span key={i} className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border', e.status === 'verified' ? 'bg-[#EFF6FF] text-[#0A39DF] border-[#DBEAFE]' : 'bg-[#F3F4F6] text-[#6B7280] border-[#E5E7EB]')}>
|
||||
<BadgeCheck className="w-2.5 h-2.5" />
|
||||
{e.skill}
|
||||
{e.level && <span className="opacity-70">· {e.level}</span>}
|
||||
</span>
|
||||
))}
|
||||
{extraEndorsements > 0 && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-[#F3F4F6] text-[#6B7280] text-[10px] font-medium">+{extraEndorsements}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Employer insights */}
|
||||
<div className="pt-3 border-t border-[#F3F4F6]">
|
||||
<EmployerInsights profile={profile} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: email + contact + expand toggle */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-[#F3F4F6]">
|
||||
<span className="text-[11px] text-[#9CA3AF] flex-1 truncate">{profile.email || 'No email'}</span>
|
||||
{onContact && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onContact(profile); }}
|
||||
className="inline-flex items-center gap-1.5 bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[12px] font-medium px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
Contact
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="inline-flex items-center gap-1 border border-[#E5E7EB] hover:bg-[#F9FAFB] text-[#374151] text-[12px] font-medium px-2.5 py-1.5 rounded-lg transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<ChevronDown className={cn('w-3.5 h-3.5 transition-transform', expanded && 'rotate-180')} />
|
||||
{expanded ? 'Hide' : 'Details'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/components/krow/TrustLayer.jsx
Normal file
81
src/components/krow/TrustLayer.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Check, Dot } from 'lucide-react';
|
||||
|
||||
// KROW verifies everything. This is the trust layer — what's proven vs. claimed.
|
||||
const ITEMS = [
|
||||
{ key: 'identity', label: 'Identity', hint: 'Selfie + name' },
|
||||
{ key: 'certifications', label: 'Certifications', hint: 'Uploaded docs' },
|
||||
{ key: 'skills', label: 'Skills', hint: 'Declared' },
|
||||
{ key: 'work', label: 'Work History', hint: 'Experience' },
|
||||
{ key: 'interview', label: 'AI Interview', hint: 'Passed' },
|
||||
{ key: 'attendance', label: 'Attendance', hint: 'Shifts' },
|
||||
{ key: 'reputation', label: 'Reputation', hint: 'Ratings' },
|
||||
{ key: 'performance', label: 'Performance', hint: 'Score' },
|
||||
{ key: 'learning', label: 'Learning', hint: 'Courses' },
|
||||
{ key: 'promotions', label: 'Promotions', hint: 'Growth' },
|
||||
{ key: 'references', label: 'References', hint: 'Verified' },
|
||||
];
|
||||
|
||||
function statusOf(profile, key) {
|
||||
const has = (v) => v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0) && v !== 0;
|
||||
const map = {
|
||||
identity: has(profile.selfie_url) && has(profile.full_name),
|
||||
certifications: (profile.certifications || []).length > 0,
|
||||
skills: (profile.skills || []).length > 0,
|
||||
work: (profile.experience || []).length > 0,
|
||||
interview: (profile.ai_interview_score || 0) > 0,
|
||||
attendance: (profile.attendance_score || 0) > 0 && (profile.experience || []).length > 0,
|
||||
reputation: (profile.client_rating || 0) > 0 || (profile.supervisor_rating || 0) > 0,
|
||||
performance: (profile.performance_score || 0) > 0,
|
||||
learning: (profile.completed_courses || []).length > 0,
|
||||
promotions: (profile.leadership_potential || 0) >= 40,
|
||||
references: (profile.supervisor_rating || 0) > 0,
|
||||
};
|
||||
return map[key] ? 'verified' : 'pending';
|
||||
}
|
||||
|
||||
export default function TrustLayer({ profile }) {
|
||||
const verified = ITEMS.filter((i) => statusOf(profile, i.key) === 'verified').length;
|
||||
|
||||
return (
|
||||
<section className="glass-card border border-white/60 rounded-2xl p-6 sm:p-8 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#0A39DF] flex items-center justify-center shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.18em] text-[#94A3B8]">Trust Layer</span>
|
||||
<h2 className="text-[20px] font-heading font-bold text-[#0F172A] tracking-tight">Verified, not claimed.</h2>
|
||||
<p className="text-[13px] text-[#64748B] mt-0.5">Everything KROW has proven about you. Impossible to fake.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[28px] font-heading font-bold text-[#0A39DF] leading-none">{verified}<span className="text-[#94A3B8] text-[18px]">/{ITEMS.length}</span></div>
|
||||
<div className="text-[11px] text-[#94A3B8] font-medium mt-1">verified</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2.5">
|
||||
{ITEMS.map((item) => {
|
||||
const st = statusOf(profile, item.key);
|
||||
return (
|
||||
<div key={item.key} className="flex items-center gap-2.5 py-1.5 border-b border-[#F1F5F9]">
|
||||
{st === 'verified' ? (
|
||||
<span className="w-5 h-5 rounded-full bg-[#0A39DF] flex items-center justify-center shrink-0">
|
||||
<Check className="w-3 h-3 text-white" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded-full bg-[#FEF3C7] flex items-center justify-center shrink-0">
|
||||
<Dot className="w-4 h-4 text-[#D97706]" />
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-[13px] font-medium ${st === 'verified' ? 'text-[#0F172A]' : 'text-[#64748B]'}`}>{item.label}</span>
|
||||
<span className="text-[11px] text-[#94A3B8] ml-auto">{st === 'verified' ? 'Verified' : 'Pending'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
src/components/krow/UniversityCourseCard.jsx
Normal file
54
src/components/krow/UniversityCourseCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } from 'lucide-react';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
|
||||
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
|
||||
const TYPE_LABEL = { roleplay: 'Explain', photo: 'Photo', video: 'Record', photo_identify: 'Identify' };
|
||||
const DIFF_COLOR = { beginner: '#16A34A', intermediate: '#F59E0B', advanced: '#EF4444' };
|
||||
|
||||
export default function UniversityCourseCard({ course, completed = false, profile }) {
|
||||
const type = course?.challenge?.type || 'roleplay';
|
||||
const Icon = TYPE_ICON[type] || MessageSquare;
|
||||
const lock = isUnlocked(course, profile);
|
||||
const color = DIFF_COLOR[course.difficulty] || '#64748B';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/university/${course.id}`}
|
||||
className="group relative flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-lg hover:shadow-blue-500/5 hover:-translate-y-0.5 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="w-11 h-11 rounded-2xl flex items-center justify-center" style={{ background: completed ? '#F0FDF4' : '#EFF6FF' }}>
|
||||
<Icon className="w-5 h-5" style={{ color: completed ? '#16A34A' : '#0A39DF' }} />
|
||||
</div>
|
||||
{completed ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full"><ShieldCheck className="w-3 h-3" /> Verified</span>
|
||||
) : !lock.unlocked ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#94A3B8] bg-[#F8FAFC] px-2 py-0.5 rounded-full"><Lock className="w-3 h-3" /> Locked</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{TYPE_LABEL[type]}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-[15px] font-semibold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">{course.title}</h3>
|
||||
|
||||
<div className="flex items-center gap-1.5 mt-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
|
||||
<span className="text-[11px] text-[#64748B] capitalize">{course.difficulty}</span>
|
||||
{course.proof_skill && <span className="text-[11px] text-[#94A3B8]">· {course.proof_skill}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-3 text-[11px] text-[#94A3B8]">
|
||||
<span className="inline-flex items-center gap-1"><Zap className="w-3 h-3 text-[#F59E0B]" /> {course.xp}</span>
|
||||
<span className="inline-flex items-center gap-1"><Clock className="w-3 h-3" /> {course.estimated_minutes}m</span>
|
||||
</div>
|
||||
|
||||
{!completed && lock.unlocked && (
|
||||
<div className="mt-3 pt-3 border-t border-[#F1F5F9] inline-flex items-center gap-1 text-[12px] font-semibold text-[#0A39DF]">
|
||||
Start <ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
188
src/components/krow/WorkforceReputation.jsx
Normal file
188
src/components/krow/WorkforceReputation.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React from 'react';
|
||||
import { Shield, Star, TrendingUp } from 'lucide-react';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import ReferencesEndorsements from '@/components/krow/ReferencesEndorsements';
|
||||
|
||||
const R = 44;
|
||||
const STROKE = 8;
|
||||
const CIRC = 2 * Math.PI * R;
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
|
||||
function careerScoreFromApp(app) {
|
||||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||||
return 300 + Math.round((s / 100) * 550);
|
||||
}
|
||||
|
||||
function marketRangeFromApp(app) {
|
||||
const s = Math.max(0, Math.min(100, Math.round(app?.ai_score || 0)));
|
||||
const uplift = Math.max(0, Math.round((s - 70) / 5));
|
||||
const low = 20 + uplift;
|
||||
return `$${low}-$${low + 4}/hr`;
|
||||
}
|
||||
|
||||
function nextTierFromApp(app) {
|
||||
const s = Math.round(app?.ai_score || 0);
|
||||
const yrs = app?.years_experience || 0;
|
||||
if (s >= 85 || yrs >= 8) return 'Skilled';
|
||||
if (s >= 60 || yrs >= 3) return 'Cross-Trained';
|
||||
return 'Beginner';
|
||||
}
|
||||
|
||||
function CareerScoreMini({ application }) {
|
||||
const score = careerScoreFromApp(application);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const dash = pct * CIRC;
|
||||
const band = getScoreBand(Math.round(application?.ai_score || 0));
|
||||
return (
|
||||
<div className="flex items-center gap-5 bg-white/70 rounded-xl border border-[#E5E7EB] px-4 py-3">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<div className="relative" style={{ width: R * 2 + STROKE, height: R * 2 + STROKE }}>
|
||||
<svg width={R * 2 + STROKE} height={R * 2 + STROKE} className="-rotate-90">
|
||||
<circle cx={R + STROKE / 2} cy={R + STROKE / 2} r={R} fill="none" stroke="#EFF6FF" strokeWidth={STROKE} />
|
||||
<circle
|
||||
cx={R + STROKE / 2}
|
||||
cy={R + STROKE / 2}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="#0A39DF"
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${CIRC}`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-[8px] uppercase tracking-wide text-[#94A3B8] font-medium">Career Score</span>
|
||||
<span className="text-[22px] font-bold text-[#0A39DF] leading-none">{score}</span>
|
||||
<span className="text-[8px] text-[#94A3B8] font-medium">300-850</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="mt-2 inline-flex items-center justify-center text-[11px] font-bold text-white px-3 py-1 rounded-full w-full"
|
||||
style={{ backgroundColor: band.color }}
|
||||
>
|
||||
{band.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Potential Market Value</p>
|
||||
<p className="text-[16px] font-bold text-[#111827]">{marketRangeFromApp(application)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] font-medium">Next Promotion</p>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{nextTierFromApp(application)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COMM_LABELS = [
|
||||
{ min: 85, label: 'Excellent' },
|
||||
{ min: 70, label: 'Good' },
|
||||
{ min: 55, label: 'Average' },
|
||||
{ min: 0, label: 'Needs Work' },
|
||||
];
|
||||
|
||||
const commLabel = (score) => {
|
||||
const v = score || 0;
|
||||
return (COMM_LABELS.find((c) => v >= c.min) || COMM_LABELS[COMM_LABELS.length - 1]).label;
|
||||
};
|
||||
|
||||
const COMPANY_COLORS = [
|
||||
'bg-[#0A39DF]',
|
||||
'bg-[#333F48]',
|
||||
'bg-[#082EB4]',
|
||||
];
|
||||
|
||||
const colorFor = (name) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length];
|
||||
};
|
||||
|
||||
const initialsFor = (name) => {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
};
|
||||
|
||||
function StatTile({ label, value, suffix, accent }) {
|
||||
const text = value != null && value !== '' ? value : '—';
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="text-[18px] font-bold leading-none" style={{ color: accent }}>
|
||||
{text}
|
||||
{suffix && <span className="text-[12px] font-semibold ml-0.5">{suffix}</span>}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkforceReputation({ application }) {
|
||||
const breakdown = application?.score_breakdown || {};
|
||||
const companies = application?.companies_worked || [];
|
||||
const clientRating = application?.client_rating || 0;
|
||||
|
||||
const reliability = breakdown.reliability;
|
||||
const attendance = breakdown.attendance_expectations;
|
||||
const leadership = breakdown.leadership_expectations;
|
||||
const communication = breakdown.communication_style;
|
||||
const commText = communication != null ? commLabel(communication) : null;
|
||||
|
||||
const hasScores = [reliability, attendance, leadership, communication].some((v) => v != null);
|
||||
const hasData = hasScores || companies.length > 0 || clientRating > 0 || (application?.ai_score || 0) > 0;
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#0A39DF] flex items-center justify-center">
|
||||
<Shield className="w-4 h-4 text-white" strokeWidth={2.5} />
|
||||
</div>
|
||||
<h4 className="text-[12px] font-bold text-[#111827] tracking-wide flex items-center gap-1.5">
|
||||
WORKFORCE REPUTATION
|
||||
<TrendingUp className="w-3.5 h-3.5 text-[#0A39DF]" />
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<CareerScoreMini application={application} />
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
<StatTile label="Reliability" value={reliability != null ? reliability : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Attendance" value={attendance != null ? attendance : null} suffix="%" accent="#059669" />
|
||||
<StatTile label="Leadership" value={leadership != null ? leadership : null} suffix="%" accent="#0A39DF" />
|
||||
<StatTile label="Communication" value={commText} accent="#F59E0B" />
|
||||
<div className="flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl bg-white/70 border border-[#E5E7EB]">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="w-3.5 h-3.5 text-[#F9E547] fill-[#F9E547]" />
|
||||
<span className="text-[18px] font-bold leading-none text-[#111827]">
|
||||
{clientRating > 0 ? clientRating.toFixed(1) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-[#6B7280] tracking-wide font-medium">Client Rating</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companies.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[#E5E7EB]/80">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2">WORKED</p>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{companies.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-lg ${colorFor(c)} flex items-center justify-center text-white text-[11px] font-bold shadow-sm`}>
|
||||
{initialsFor(c)}
|
||||
</div>
|
||||
<span className="text-[12px] font-semibold text-[#374151]">{c}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReferencesEndorsements application={application} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Plus } from 'lucide-react';
|
||||
|
||||
// The transcript replacement — verified capabilities as visual chips,
|
||||
// each backed by evidence. Empty until the worker proves something.
|
||||
export default function CapabilityTimeline({ profile, compact }) {
|
||||
const caps = (profile?.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
return (
|
||||
<section className={compact ? 'py-2' : 'py-8'}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proven skills</h2>
|
||||
<span className="text-[11px] text-[#94A3B8]">{caps.length} verified</span>
|
||||
</div>
|
||||
{!caps.length ? (
|
||||
<div className="rounded-2xl border border-dashed border-[#E2E8F0] bg-[#F8FAFC] p-6 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-[#E2E8F0] flex items-center justify-center mx-auto mb-2">
|
||||
<Plus className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">No verified skills yet — prove a challenge to start your chain.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-[#F0FDF4] border border-[#BBF7D0] px-3 py-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#16A34A]" />
|
||||
<span className="text-[12px] font-medium text-[#166534]">{c.skill}</span>
|
||||
{c.verified_date && (
|
||||
<span className="text-[10px] text-[#16A34A]/70">
|
||||
{new Date(c.verified_date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
||||
import { isUnlocked } from '@/lib/provingGround';
|
||||
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
|
||||
import RoleplayChallenge from './RoleplayChallenge';
|
||||
import MediaChallenge from './MediaChallenge';
|
||||
import VideoRecorder from './VideoRecorder';
|
||||
import HazardIdentifier from './HazardIdentifier';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
|
||||
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
|
||||
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
|
||||
|
||||
function Stepper({ active }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-6">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < active;
|
||||
const cur = i === active;
|
||||
return (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`flex items-center gap-1.5 ${cur || done ? '' : 'opacity-50'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold ${done ? 'bg-[#0A39DF] text-white' : cur ? 'bg-[#0A39DF] text-white ring-4 ring-[#EFF6FF]' : 'bg-[#F1F5F9] text-[#94A3B8]'}`}>{done ? '✓' : i + 1}</div>
|
||||
<span className={`text-[12px] font-medium ${cur || done ? 'text-[#0F172A]' : 'text-[#94A3B8]'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`flex-1 h-px ${done ? 'bg-[#0A39DF]' : 'bg-[#E5E7EB]'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [evaluating, setEvaluating] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [evidence, setEvidence] = useState(null);
|
||||
const [supervisorVerified, setSupervisorVerified] = useState(false);
|
||||
const submit = useSubmitChallenge();
|
||||
const verify = useVerifyEvidence();
|
||||
const ch = course?.challenge || {};
|
||||
const type = ch.type || 'roleplay';
|
||||
const Icon = TYPE_ICON[type] || MessageSquare;
|
||||
const lock = isUnlocked(course, profile);
|
||||
|
||||
const handleSubmit = async (payload) => {
|
||||
setEvaluating(true);
|
||||
let mediaUrl = null, transcript = '', identified = null;
|
||||
if (type === 'roleplay') transcript = payload;
|
||||
else if (type === 'photo_identify') { mediaUrl = ch.image_url; identified = payload; }
|
||||
else mediaUrl = payload;
|
||||
try {
|
||||
const res = await submit.mutateAsync({ profile, course, type, mediaUrl, transcript, identified });
|
||||
setResult(res.result);
|
||||
setEvidence(res.evidence);
|
||||
setSupervisorVerified(false);
|
||||
setStep(2);
|
||||
if (res.passed && onPassed) onPassed();
|
||||
} catch {
|
||||
setStep(1);
|
||||
} finally {
|
||||
setEvaluating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doVerify = async () => {
|
||||
if (!evidence) return;
|
||||
try {
|
||||
await verify.mutateAsync({ evidenceId: evidence.id });
|
||||
setSupervisorVerified(true);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const passed = evidence?.ai_verdict === 'verified';
|
||||
|
||||
// 0 — Learn
|
||||
if (step === 0) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={0} />
|
||||
{!lock.unlocked ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[#0F172A] mb-2"><Lock className="w-4 h-4" /><span className="font-semibold text-[15px]">Locked — earn this one</span></div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Real work unlocks learning. Complete these first:</p>
|
||||
<ul className="space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (<li key={i} className="text-[13px] text-[#475569] flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-[#94A3B8]" />{r}</li>))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#EFF6FF] flex items-center justify-center"><Icon className="w-5 h-5 text-[#0A39DF]" /></div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#94A3B8]">Demonstration</p>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[14px] text-[#374151] leading-relaxed mb-4">{ch.prompt || course?.description}</p>
|
||||
<button onClick={() => setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me <ArrowRight className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluating
|
||||
if (evaluating) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-10 flex flex-col items-center">
|
||||
<Stepper active={1} />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mb-3" />
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">Owliver is evaluating your proof…</p>
|
||||
<p className="text-[12px] text-[#64748B] mt-1">Measuring whether you can actually perform.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1 — Demonstrate
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={1} />
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-3 mb-4 flex items-start gap-2.5">
|
||||
<OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#0A39DF] mb-0.5">Owliver</p>
|
||||
<p className="text-[13px] text-[#374151]">{ch.prompt || course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 — Verify
|
||||
if (step === 2) {
|
||||
const rubric = result?.rubric || {};
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={2} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{passed ? <CheckCircle2 className="w-9 h-9 text-[#16A34A]" /> : <XCircle className="w-9 h-9 text-[#EF4444]" />}
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-[#0F172A]">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-[12px] text-[#64748B]">AI confidence: {Math.round(evidence?.ai_score || 0)}/100</p>
|
||||
</div>
|
||||
</div>
|
||||
{result?.feedback && <p className="text-[13px] text-[#374151] leading-relaxed mb-4">{result.feedback}</p>}
|
||||
{Object.keys(rubric).length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{Object.entries(rubric).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-[12px] mb-1"><span className="text-[#475569] capitalize">{k.replace(/_/g, ' ')}</span><span className="font-semibold text-[#0F172A]">{Math.round(v)}/100</span></div>
|
||||
<div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${Math.min(100, Math.round(v))}%` }} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-xl border p-4 flex items-center gap-3 mb-3 ${supervisorVerified ? 'bg-[#F0FDF4] border-[#BBF7D0]' : 'bg-[#FFFBEB] border-[#FDE68A]'}`}>
|
||||
<ShieldCheck className={`w-5 h-5 ${supervisorVerified ? 'text-[#16A34A]' : 'text-[#D97706]'}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#0F172A]">{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}</p>
|
||||
<p className="text-[11px] text-[#64748B]">{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}</p>
|
||||
</div>
|
||||
{!supervisorVerified ? (
|
||||
<button onClick={doVerify} disabled={verify.isPending} className="h-8 px-3 rounded-lg bg-[#0F172A] text-white text-[12px] font-semibold disabled:opacity-50">{verify.isPending ? '…' : 'Verify'}</button>
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
)}
|
||||
</div>
|
||||
{passed ? (
|
||||
<button onClick={() => setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof <ArrowRight className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<button onClick={() => setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 — Proof
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={3} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#F9E547] flex items-center justify-center"><Award className="w-5 h-5 text-[#92400E]" /></div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">Proof stored</p>
|
||||
<p className="text-[12px] text-[#64748B]">Capability: {course.proof_skill || course.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-4 space-y-2 mb-4">
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">AI verdict</span><span className="font-semibold text-[#16A34A] capitalize">{evidence?.ai_verdict}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Score</span><span className="font-semibold text-[#0F172A]">{Math.round(evidence?.ai_score || 0)}/100</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Supervisor</span><span className="font-semibold text-[#0F172A]">{supervisorVerified ? 'Verified' : 'Pending'}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Proof type</span><span className="font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</span></div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#EFF6FF] border border-[#BFDBFE] p-4 flex items-start gap-2.5 mb-4">
|
||||
<Eye className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-[#1E40AF]">Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.</p>
|
||||
</div>
|
||||
<button onClick={() => setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const PRESET_TAGS = [
|
||||
'Raw meat next to ready-to-eat food',
|
||||
'Same board for raw + cooked',
|
||||
'Soiled towel on food surface',
|
||||
'Food in the temperature danger zone',
|
||||
'Unwashed hands',
|
||||
'Improper storage',
|
||||
'Cross-contact allergen',
|
||||
];
|
||||
|
||||
// Interactive hazard identification — tap a kitchen photo to mark hazards.
|
||||
export default function HazardIdentifier({ course, onSubmit, submitting }) {
|
||||
const imageUrl = course?.challenge?.image_url;
|
||||
const [markers, setMarkers] = useState([]);
|
||||
const [pending, setPending] = useState(null);
|
||||
const [customLabel, setCustomLabel] = useState('');
|
||||
|
||||
const addMarker = (e) => {
|
||||
if (submitting || pending) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
setPending({ x, y });
|
||||
};
|
||||
|
||||
const confirm = (label) => {
|
||||
if (!pending) return;
|
||||
setMarkers([...markers, { ...pending, label }]);
|
||||
setPending(null);
|
||||
setCustomLabel('');
|
||||
};
|
||||
|
||||
const remove = (i) => setMarkers(markers.filter((_, j) => j !== i));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#E5E7EB] mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
{imageUrl ? (
|
||||
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<div className="w-full h-64 bg-[#F1F5F9] flex items-center justify-center text-[13px] text-[#94A3B8]">No scene image configured</div>
|
||||
)}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-[#0F172A]">{m.label}</span>
|
||||
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
{pending && (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}>
|
||||
<span className="block w-6 h-6 rounded-full bg-[#0A39DF] ring-4 ring-white animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-[#0A39DF] bg-[#EFF6FF] p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-[#1E40AF] mb-2">Label this hazard:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_TAGS.map((t) => (
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, Camera } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
// Photo or video proof — capture/upload, then submit for AI evaluation.
|
||||
export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
const type = course?.challenge?.type || 'photo';
|
||||
const [mediaUrl, setMediaUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const accept = type === 'video' ? 'video/*' : 'image/*';
|
||||
const capture = type === 'photo' ? 'environment' : undefined;
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
setMediaUrl(file_url);
|
||||
} catch {
|
||||
setMediaUrl('');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">
|
||||
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
|
||||
</p>
|
||||
{mediaUrl ? (
|
||||
<div className="mb-3 rounded-xl overflow-hidden border border-[#E5E7EB]">
|
||||
{type === 'video' ? (
|
||||
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
|
||||
) : (
|
||||
<Image src={mediaUrl} alt="Your proof" className="w-full max-h-64 object-cover" fittingType="fill" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-[#CBD5E1] cursor-pointer hover:border-[#0A39DF] transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" />
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-6 h-6 text-[#94A3B8] mb-2" />
|
||||
<span className="text-[13px] text-[#64748B]">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{mediaUrl && (
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSubmit(mediaUrl)}
|
||||
disabled={!mediaUrl || uploading || submitting}
|
||||
className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2 } from 'lucide-react';
|
||||
import { challengeFollowUp } from '@/lib/provingGround';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
|
||||
// then the worker submits the transcript for AI evaluation.
|
||||
export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
|
||||
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
const next = [...messages, { role: 'user', content: text }];
|
||||
setMessages(next);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const follow = await challengeFollowUp(course, next);
|
||||
setMessages([...next, { role: 'ai', content: follow }]);
|
||||
} catch {
|
||||
setMessages([...next, { role: 'ai', content: 'Thanks. Submit your proof when ready.' }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transcript = messages.map((m) => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
|
||||
const hasResponded = messages.some((m) => m.role === 'user');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#0F172A]">Owliver</p>
|
||||
<p className="text-[10px] text-[#94A3B8]">Your AI skills coach</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
{messages.map((m, i) => {
|
||||
const isAI = m.role !== 'user';
|
||||
return (
|
||||
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-2xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F1F5F9] text-[#0F172A] rounded-bl-sm'
|
||||
}`}>{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white pl-4 pr-1.5 py-1.5 shadow-[0_10px_30px_rgba(10,57,223,0.14),0_2px_6px_rgba(51,63,72,0.06)] ring-1 ring-black/5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-[#0A39DF] text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSubmit(transcript)}
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
// Real in-browser video recording — "record yourself washing your hands."
|
||||
export default function VideoRecorder({ onSubmit, submitting }) {
|
||||
const videoRef = useRef(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const timerRef = useRef(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [recordedUrl, setRecordedUrl] = useState(null);
|
||||
const [blob, setBlob] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let s;
|
||||
navigator.mediaDevices?.getUserMedia({ video: { facingMode: 'user' }, audio: true })
|
||||
.then((stream) => {
|
||||
s = stream;
|
||||
if (active && videoRef.current) videoRef.current.srcObject = stream;
|
||||
})
|
||||
.catch(() => setError('Camera access denied. Grant camera permission or use a mobile device to record.'));
|
||||
return () => {
|
||||
active = false;
|
||||
if (s) s.getTracks().forEach((t) => t.stop());
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = () => {
|
||||
if (!videoRef.current?.srcObject) return;
|
||||
chunksRef.current = [];
|
||||
const mr = new MediaRecorder(videoRef.current.srcObject);
|
||||
mr.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
const b = new Blob(chunksRef.current, { type: 'video/webm' });
|
||||
setBlob(b);
|
||||
setRecordedUrl(URL.createObjectURL(b));
|
||||
};
|
||||
mr.start();
|
||||
mediaRecorderRef.current = mr;
|
||||
setRecording(true);
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => setSeconds((v) => v + 1), 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
|
||||
const reset = () => { setRecordedUrl(null); setBlob(null); setSeconds(0); };
|
||||
|
||||
const submit = async () => {
|
||||
if (!blob) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const file = new File([blob], `proof-${Date.now()}.webm`, { type: 'video/webm' });
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
onSubmit(file_url);
|
||||
} catch {
|
||||
setError('Upload failed. Try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="text-[12px] text-[#EF4444] mb-3">{error}</p>}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-black aspect-video mb-3">
|
||||
{recordedUrl ? (
|
||||
<video src={recordedUrl} controls className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover" />
|
||||
)}
|
||||
{recording && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 bg-black/60 rounded-full px-2.5 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[12px] text-white font-mono">{seconds}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!recordedUrl ? (
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-red-500' : 'bg-[#0A39DF]'}`}>
|
||||
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B] flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/krow/talent/CareerGrowth.jsx
Normal file
36
src/components/krow/talent/CareerGrowth.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
const LADDER = ['Beginner', 'Cross-Trained', 'Skilled', 'Lead', 'Manager'];
|
||||
|
||||
export default function CareerGrowth({ profile }) {
|
||||
const lp = Number(profile.leadership_potential) || 0;
|
||||
const currentIdx = lp >= 80 ? 3 : lp >= 60 ? 2 : lp >= 40 ? 1 : 0;
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">Career Growth</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">People don't just work. They level up.</p>
|
||||
<div className="flex flex-col">
|
||||
{LADDER.map((step, i) => {
|
||||
const done = i < currentIdx;
|
||||
const current = i === currentIdx;
|
||||
const last = i === LADDER.length - 1;
|
||||
return (
|
||||
<div key={step} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-9 h-9 rounded-full flex items-center justify-center text-[12px] font-bold shrink-0 ${current ? 'bg-[#0A39DF] text-white' : done ? 'bg-[#ECFDF5] text-[#16A34A] border border-[#A7F3D0]' : 'bg-[#F3F4F6] text-[#9CA3AF]'}`}>
|
||||
{done ? '✓' : i + 1}
|
||||
</div>
|
||||
{!last && <div className="w-0.5 flex-1 bg-[#E5E7EB] my-1 min-h-[16px]" />}
|
||||
</div>
|
||||
<div className={`pb-4 pt-1.5 text-[14px] font-medium ${current ? 'text-[#0A39DF]' : done ? 'text-[#0F172A]' : 'text-[#9CA3AF]'}`}>
|
||||
{step}
|
||||
{current && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">You are here</span>}
|
||||
{last && <span className="ml-2 text-[11px] font-normal text-[#6B7280]">Target</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
70
src/components/krow/talent/CareerPassport.jsx
Normal file
70
src/components/krow/talent/CareerPassport.jsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
|
||||
const ITEMS = [
|
||||
'Identity',
|
||||
'Food Handler',
|
||||
'RBS',
|
||||
'Allergens',
|
||||
'Knife Skills',
|
||||
'References',
|
||||
'AI Interview',
|
||||
'Background Check',
|
||||
];
|
||||
|
||||
export default function CareerPassport({ profile }) {
|
||||
const certs = (profile.certifications || []).map((c) => c.toLowerCase());
|
||||
const skills = (profile.skills || []).map((s) => s.toLowerCase());
|
||||
const badges = (profile.earned_badges || []).map((b) => (b.name || '').toLowerCase());
|
||||
|
||||
const has = (label) => {
|
||||
switch (label) {
|
||||
case 'Identity':
|
||||
return !!profile.selfie_url || !!profile.full_name;
|
||||
case 'Food Handler':
|
||||
return certs.some((c) => c.includes('food'));
|
||||
case 'RBS':
|
||||
return certs.some((c) => c.includes('rbs'));
|
||||
case 'Allergens':
|
||||
return certs.some((c) => c.includes('allergen'));
|
||||
case 'Knife Skills':
|
||||
return skills.some((s) => s.includes('knife')) || badges.some((b) => b.includes('knife'));
|
||||
case 'References':
|
||||
return (profile.experience || []).length >= 2;
|
||||
case 'AI Interview':
|
||||
return (profile.ai_interview_score || 0) > 0;
|
||||
case 'Background Check':
|
||||
return (profile.shifts_completed || 0) >= 10;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const done = ITEMS.filter(has);
|
||||
const pct = Math.round((done.length / ITEMS.length) * 100);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Career Passport</h2>
|
||||
<span className="text-[12px] font-semibold text-[#0A39DF]">{pct}% Complete</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{ITEMS.map((label) => {
|
||||
const ok = has(label);
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl border ${
|
||||
ok ? 'border-[#BBF7D0] bg-[#F0FDF4]' : 'border-[#E5E7EB] bg-[#F8FAFC]'
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className={`w-4 h-4 shrink-0 ${ok ? 'text-[#16A34A]' : 'text-[#CBD5E1]'}`} />
|
||||
<span className={`text-[13px] ${ok ? 'text-[#0F172A] font-medium' : 'text-[#94A3B8]'}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
63
src/components/krow/talent/CareerScoreGauge.jsx
Normal file
63
src/components/krow/talent/CareerScoreGauge.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Info, RefreshCw } from 'lucide-react';
|
||||
import { getTalentScore, toFICO, getScoreBand } from '@/lib/talentHome';
|
||||
|
||||
const FICO_MIN = 300;
|
||||
const FICO_MAX = 850;
|
||||
const ARC_R = 64;
|
||||
const CX = 80;
|
||||
const CY = 80;
|
||||
const BLUE = '#0055FF';
|
||||
const TRACK = '#E1E5E9';
|
||||
|
||||
function arcPoint(p) {
|
||||
const ang = Math.PI - p * Math.PI;
|
||||
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
|
||||
}
|
||||
function arcPath(startP, endP) {
|
||||
const a = arcPoint(startP);
|
||||
const b = arcPoint(endP);
|
||||
const large = endP - startP > 0.5 ? 1 : 0;
|
||||
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
|
||||
}
|
||||
|
||||
export default function CareerScoreGauge({ profile }) {
|
||||
const raw = getTalentScore(profile);
|
||||
const score = toFICO(raw);
|
||||
const band = getScoreBand(raw);
|
||||
const pct = Math.max(0, Math.min(1, (score - FICO_MIN) / (FICO_MAX - FICO_MIN)));
|
||||
const gap = 0.02;
|
||||
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
|
||||
const marker = arcPoint(filledEnd);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: CX * 2, height: CY + 28 }}>
|
||||
<svg width={CX * 2} height={CY + 28} viewBox={`0 0 ${CX * 2} ${CY + 28}`} className="overflow-visible">
|
||||
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
|
||||
{pct > gap && (
|
||||
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
|
||||
)}
|
||||
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-2 flex flex-col items-center">
|
||||
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
|
||||
<span className="text-[30px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{score}</span>
|
||||
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">{FICO_MIN}-{FICO_MAX}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="mt-2 inline-flex items-center gap-1 text-[12px] font-semibold px-3 py-1 rounded-full"
|
||||
style={{ color: BLUE, background: '#F0F4F9' }}>
|
||||
{band.label}
|
||||
<Info className="w-3 h-3" style={{ color: BLUE }} />
|
||||
</span>
|
||||
|
||||
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Next update in 31 days
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/krow/talent/CareerWallet.jsx
Normal file
62
src/components/krow/talent/CareerWallet.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Award, Languages as LangIcon, MessageSquare, Briefcase, Star, Medal, FolderOpen, FileText } from 'lucide-react';
|
||||
|
||||
|
||||
function WalletTile({ icon: Icon, label, value, tone = 'neutral' }) {
|
||||
const valueColor = {
|
||||
verified: 'text-[#10B981]',
|
||||
neutral: 'text-[#0F172A]',
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-xl bg-white border border-[#E6E8EB] p-4 transition-colors hover:border-[#0A39DF]">
|
||||
<div className="flex items-center gap-1.5 text-[#7C818A] mb-2">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.06em]">{label}</span>
|
||||
</div>
|
||||
<div className={`text-[19px] font-bold leading-tight ${valueColor}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CareerWallet({ profile }) {
|
||||
const certs = profile.certifications || [];
|
||||
const langs = profile.languages || [];
|
||||
const completed = profile.completed_courses || [];
|
||||
const badges = profile.earned_badges || [];
|
||||
const hasInterview = (profile.ai_interview_score || 0) > 0;
|
||||
const identityOk = !!profile.selfie_url;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-[#F8F9FB] border border-[#E6E8EB] p-6 sm:p-8">
|
||||
<div className="flex items-baseline justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[19px] font-bold text-[#1A1D21] tracking-tight">Career Wallet</h2>
|
||||
<p className="text-[12px] text-[#7C818A] mt-0.5">Verified once. Portable everywhere.</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-[#10B981] hidden sm:inline">Everything verified</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<WalletTile icon={ShieldCheck} label="Identity" value={identityOk ? 'Verified' : '—'} tone={identityOk ? 'verified' : 'neutral'} />
|
||||
<WalletTile icon={Award} label="Certifications" value={certs.length} />
|
||||
<WalletTile icon={LangIcon} label="Languages" value={langs.length} />
|
||||
<WalletTile icon={MessageSquare} label="AI Interview" value={hasInterview ? 'Passed' : '0'} tone={hasInterview ? 'verified' : 'neutral'} />
|
||||
<WalletTile icon={Briefcase} label="Completed Jobs" value={completed.length} />
|
||||
<WalletTile icon={Star} label="Employer Rating" value={profile.client_rating ? `${profile.client_rating}` : '—'} />
|
||||
<WalletTile icon={Medal} label="Badges" value={badges.length} />
|
||||
<WalletTile icon={FolderOpen} label="Portfolio" value="—" />
|
||||
<WalletTile icon={FileText} label="Resume" value={profile.resume_url ? 'Yes' : 'Optional'} />
|
||||
</div>
|
||||
|
||||
{certs.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-5">
|
||||
{certs.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-white border border-[#E6E8EB] text-[#1A1D21] text-[12px] font-medium px-3 py-1.5">
|
||||
<Award className="w-3.5 h-3.5 text-[#0A39DF]" />{c}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
123
src/components/krow/talent/CompaniesCompeting.jsx
Normal file
123
src/components/krow/talent/CompaniesCompeting.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Building2, MapPin, Check, X, ArrowLeftRight, Sparkles, Hand } from 'lucide-react';
|
||||
|
||||
const STATUS = {
|
||||
accepted: { txt: 'Accepted', cls: 'bg-[#ECFDF5] text-[#16A34A]', icon: Check },
|
||||
declined: { txt: 'Declined', cls: 'bg-[#F3F4F6] text-[#6B7280]', icon: X },
|
||||
countered: { txt: 'Counter sent', cls: 'bg-[#EFF6FF] text-[#0A39DF]', icon: ArrowLeftRight },
|
||||
};
|
||||
|
||||
function DemandCard({ rec, decision, counterAsk, onAccept, onDecline, onCounter, onSend }) {
|
||||
const { job, match } = rec;
|
||||
const [countering, setCountering] = useState(false);
|
||||
const [ask, setAsk] = useState(counterAsk ?? job.pay_range_max ?? job.pay_range_min ?? '');
|
||||
const pay = job.pay_range_min && job.pay_range_max ? `$${job.pay_range_min}–$${job.pay_range_max}/hr` : '';
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-white border border-[#E5E7EB] p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center shrink-0">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-[15px] font-bold text-[#0F172A] truncate">{job.title}</h3>
|
||||
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-[#0A39DF] bg-[#EFF6FF] px-2 py-0.5 rounded-full">{match}% match</span>
|
||||
</div>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="inline-flex items-center gap-0.5"><MapPin className="w-3 h-3" />{job.location || 'Remote'}</span>
|
||||
{pay && <span>· {pay}</span>}
|
||||
<span className="text-[#0A39DF] font-medium">wants to interview you</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{decision ? (
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
{(() => { const S = STATUS[decision]; const I = S.icon; return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-[12px] font-semibold px-3 py-1.5 rounded-full ${S.cls}`}>
|
||||
<I className="w-3.5 h-3.5" /> {S.txt}{decision === 'countered' && counterAsk ? ` · $${counterAsk}/hr` : ''}
|
||||
</span>
|
||||
); })()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button onClick={() => onAccept(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl bg-[#0A39DF] text-white text-[13px] font-semibold hover:bg-[#082EB4] transition-colors">
|
||||
<Check className="w-4 h-4" /> Accept
|
||||
</button>
|
||||
<button onClick={() => onDecline(rec)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#E5E7EB] text-[#6B7280] text-[13px] font-semibold hover:bg-[#F9FAFB] transition-colors">
|
||||
<X className="w-4 h-4" /> Decline
|
||||
</button>
|
||||
<button onClick={() => setCountering(v => !v)} className="flex-1 inline-flex items-center justify-center gap-1.5 h-9 rounded-xl border border-[#0A39DF] text-[#0A39DF] text-[13px] font-semibold hover:bg-[#EFF6FF] transition-colors">
|
||||
<ArrowLeftRight className="w-4 h-4" /> Counter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{countering && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-[#F3F4F6]">
|
||||
<span className="text-[12px] text-[#6B7280]">Counter at</span>
|
||||
<input type="number" value={ask} onChange={(e) => setAsk(e.target.value)} className="w-24 h-8 px-2 text-[13px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<span className="text-[12px] text-[#6B7280]">/hr</span>
|
||||
<button onClick={() => onSend(rec, ask)} className="ml-auto h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-semibold hover:bg-[#082EB4]">Send</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompaniesCompeting({ jobRecs = [] }) {
|
||||
const [decisions, setDecisions] = useState({});
|
||||
const [asks, setAsks] = useState({});
|
||||
|
||||
const decide = (id, kind, ask) => {
|
||||
setDecisions((d) => ({ ...d, [id]: kind }));
|
||||
if (ask != null) setAsks((a) => ({ ...a, [id]: ask }));
|
||||
};
|
||||
|
||||
const live = jobRecs.filter((r) => !decisions[r.job.id]);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A] flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-[#0A39DF]" /> Companies are competing for you
|
||||
</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mt-0.5">You're in demand. Accept, decline, or counter.</p>
|
||||
</div>
|
||||
{live.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] font-bold text-[#16A34A] bg-[#ECFDF5] px-3 py-1.5 rounded-full">
|
||||
<span className="relative flex h-2 w-2"><span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#16A34A] opacity-60" /><span className="relative inline-flex rounded-full h-2 w-2 bg-[#16A34A]" /></span>
|
||||
{live.length} live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jobRecs.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[#F9FAFB] text-[#9CA3AF] flex items-center justify-center mx-auto mb-3"><Hand className="w-6 h-6" /></div>
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">No live offers yet</p>
|
||||
<p className="text-[13px] text-[#6B7280] mt-1">Keep leveling up — companies will come to you.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobRecs.map((rec) => (
|
||||
<DemandCard
|
||||
key={rec.job.id}
|
||||
rec={rec}
|
||||
decision={decisions[rec.job.id]}
|
||||
counterAsk={asks[rec.job.id]}
|
||||
onAccept={(r) => decide(r.job.id, 'accepted')}
|
||||
onDecline={(r) => decide(r.job.id, 'declined')}
|
||||
onCounter={() => {}}
|
||||
onSend={(r, ask) => decide(r.job.id, 'countered', ask)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
src/components/krow/talent/CompetingOffers.jsx
Normal file
50
src/components/krow/talent/CompetingOffers.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time'];
|
||||
const STATES = ['Interested', 'Interview', 'Offer', 'Offer'];
|
||||
|
||||
export default function CompetingOffers({ jobRecs = [] }) {
|
||||
const offers = (jobRecs || []).slice(0, 4);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Companies Competing For You</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{offers.map((j, i) => {
|
||||
const pay = j.pay_range_min || 31;
|
||||
const title = (j.title || 'Opportunity').split(' (')[0];
|
||||
const state = STATES[i] || 'Interested';
|
||||
const shift = SHIFTS[i % SHIFTS.length];
|
||||
return (
|
||||
<div key={j.id || i} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
|
||||
</span>
|
||||
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
|
||||
<span className="text-[#6B7280]">{shift}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
|
||||
Accept
|
||||
</button>
|
||||
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
|
||||
Counter
|
||||
</button>
|
||||
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
66
src/components/krow/talent/EliteTalentScore.jsx
Normal file
66
src/components/krow/talent/EliteTalentScore.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { getGrowth } from '@/lib/talentHome';
|
||||
|
||||
const COLORS = {
|
||||
Reliability: '#2458D3',
|
||||
Execution: '#2458D3',
|
||||
Quality: '#458B74',
|
||||
Leadership: '#2458D3',
|
||||
Learning: '#E69138',
|
||||
'Client Rating': '#2458D3',
|
||||
Attendance: '#458B74',
|
||||
Culture: '#2458D3',
|
||||
Safety: '#458B74',
|
||||
Communication: '#E69138',
|
||||
};
|
||||
|
||||
// Derives the 10 performance metrics from real profile fields, scaled 0–100.
|
||||
function getMetrics(profile = {}) {
|
||||
const r = (n) => Math.max(0, Math.min(100, Math.round(Number(n) || 0)));
|
||||
const client = r((Number(profile.client_rating) || 0) * 20);
|
||||
const perf = r(profile.performance_score);
|
||||
return [
|
||||
{ label: 'Reliability', value: r(profile.reliability_score), kind: 'pct' },
|
||||
{ label: 'Execution', value: perf, kind: 'pct' },
|
||||
{ label: 'Quality', value: r((perf + client) / 2), kind: 'pct' },
|
||||
{ label: 'Leadership', value: r(profile.leadership_potential), kind: 'pct' },
|
||||
{ label: 'Learning', value: r(getGrowth(profile)), kind: 'pct' },
|
||||
{ label: 'Attendance', value: r(profile.attendance_score), kind: 'pct' },
|
||||
{ label: 'Culture', value: r(profile.ai_interview_score), kind: 'pct' },
|
||||
{ label: 'Safety', value: r(profile.attendance_score), kind: 'pct' },
|
||||
{ label: 'Communication', value: r(profile.performance_score), kind: 'pct' },
|
||||
];
|
||||
}
|
||||
|
||||
function Card({ metric }) {
|
||||
const color = COLORS[metric.label] || '#2458D3';
|
||||
const hasData = metric.value > 0;
|
||||
let display;
|
||||
if (metric.kind === 'star') {
|
||||
display = hasData ? (metric.rawRating).toFixed(1) : '—';
|
||||
} else {
|
||||
display = hasData ? `${metric.value}%` : '—';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center bg-white border border-[#E5E7EB] rounded-xl px-2 py-3">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{metric.kind === 'star' && <Star className="w-3.5 h-3.5 fill-[#F1C40F] text-[#F1C40F]" />}
|
||||
<span className="text-[15px] font-bold tabular-nums" style={{ color }}>{display}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-medium text-[#888888] mt-0.5">{metric.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EliteTalentScore({ profile }) {
|
||||
const metrics = getMetrics(profile);
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{metrics.map((m) => (
|
||||
<Card key={m.label} metric={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/krow/talent/EmployerInsights.jsx
Normal file
66
src/components/krow/talent/EmployerInsights.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Briefcase, Sparkles, Star, Quote } from 'lucide-react';
|
||||
import { esatTag, recentJobs, clientEndorsements } from '@/lib/talentInsights';
|
||||
|
||||
export default function EmployerInsights({ profile }) {
|
||||
const esat = esatTag(profile);
|
||||
const jobs = recentJobs(profile);
|
||||
const endorsements = clientEndorsements(profile);
|
||||
|
||||
if (jobs.length === 0 && endorsements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-[#F3F4F6] space-y-3">
|
||||
{/* ESAT + Job history header row */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider flex items-center gap-1.5">
|
||||
<Briefcase className="w-3 h-3" /> JOB HISTORY · 3 YR
|
||||
</p>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#9CA3AF] tracking-wider">
|
||||
<Sparkles className="w-3 h-3" /> ESAT
|
||||
<span className={`ml-1 px-2 py-0.5 rounded-full text-[10px] font-semibold ${esat.cls}`}>{esat.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{jobs.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{jobs.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<Briefcase className="w-3 h-3 text-[#9CA3AF] shrink-0" />
|
||||
<span className="font-medium text-[#111827] truncate">{e.role}</span>
|
||||
<span className="text-[#6B7280] truncate">· {e.company}</span>
|
||||
{e.years != null && <span className="text-[#9CA3AF] shrink-0">· {e.years}yr</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3 Client endorsements */}
|
||||
{endorsements.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-[#9CA3AF] tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Quote className="w-3 h-3" /> CLIENT ENDORSEMENTS
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{endorsements.map((en, i) => (
|
||||
<div key={i} className="flex items-center gap-2 bg-[#F9FAFB] rounded-lg px-2.5 py-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[11px] font-semibold text-[#111827] truncate">{en.company}</p>
|
||||
<p className="text-[10px] text-[#6B7280] truncate">{en.role}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star
|
||||
key={s}
|
||||
className={`w-3 h-3 ${s < en.stars ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/krow/talent/ExperienceEndorsements.jsx
Normal file
74
src/components/krow/talent/ExperienceEndorsements.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Star, Briefcase, Quote } from 'lucide-react';
|
||||
import { verifiedExperience } from './VerifiedExperienceList';
|
||||
export { verifiedExperience };
|
||||
import { clientEndorsements } from '@/lib/talentInsights';
|
||||
|
||||
const ENDORSEMENT_ROLES = ['Event Director', 'Operations Lead', 'GM', 'Owner', 'HR Partner'];
|
||||
|
||||
function Stars({ count }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 ${i < count ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Consolidated Experience & Endorsements — one row per company combining the
|
||||
// verified experience entry (shifts, supervisor, duration) with its client
|
||||
// endorsement (role/title + rating). Falls back to seeded experience so the
|
||||
// block always shows 3 rows.
|
||||
export default function ExperienceEndorsements({ profile, onReference }) {
|
||||
const jobs = verifiedExperience(profile);
|
||||
if (jobs.length === 0) return null;
|
||||
|
||||
// Map endorsements by company for inline merge.
|
||||
const endorsements = clientEndorsements(profile);
|
||||
const byCompany = new Map(endorsements.map((e) => [e.company, e]));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((x, i) => {
|
||||
const en = byCompany.get(x.company);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl border border-[#E5E7EB] bg-white px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] truncate flex items-center gap-1.5 min-w-0">
|
||||
<Briefcase className="w-3.5 h-3.5 text-[#9CA3AF] shrink-0" />
|
||||
{x.company}
|
||||
{x.role && <span className="text-[#6B7280] font-normal">· {x.role}</span>}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Stars count={x.stars} />
|
||||
<button
|
||||
onClick={() => onReference?.(x)}
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
|
||||
>
|
||||
Request Reference
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-[#16A34A] font-medium mt-1 truncate">
|
||||
{x.shifts} shifts · {x.supervisor}
|
||||
{x.years != null && <span className="text-[#9CA3AF]"> · {x.years} yr</span>}
|
||||
</p>
|
||||
{en && (
|
||||
<p className="text-[11px] text-[#6B7280] mt-1 flex items-center gap-1.5 truncate">
|
||||
<Quote className="w-3 h-3 text-[#9CA3AF] shrink-0" />
|
||||
<span className="italic">“{en.role}”</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/krow/talent/ExperienceNetwork.jsx
Normal file
48
src/components/krow/talent/ExperienceNetwork.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const SEED_NETWORK = ['Luis', 'Chef Antonio'];
|
||||
const SEED_ROLES = ['Supervisor', 'Chef'];
|
||||
|
||||
export default function ExperienceNetwork({ profile }) {
|
||||
const exp = (profile.experience || []).slice(0, 3);
|
||||
const list = exp.length
|
||||
? exp.map((e, i) => ({
|
||||
company: e.company || 'Company',
|
||||
shifts: Math.max(1, Math.round((e.years || 1) * 20)),
|
||||
ref: SEED_NETWORK[i % SEED_NETWORK.length],
|
||||
role: e.role || SEED_ROLES[i % SEED_ROLES.length],
|
||||
action: i === 0 ? 'Request Reference' : 'Endorse Skill',
|
||||
}))
|
||||
: [
|
||||
{ company: 'Google', shifts: 58, ref: 'Luis', role: 'Supervisor', action: 'Request Reference' },
|
||||
{ company: 'Adobe', shifts: 26, ref: 'Chef Antonio', role: 'Chef', action: 'Endorse Skill' },
|
||||
{ company: 'EA', shifts: 15, ref: 'Maria', role: 'Manager', action: 'Request Reference' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Verified Experience & Network</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{list.map((x, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[14px] font-semibold text-[#0F172A] truncate">{x.company}</p>
|
||||
<p className="text-[12px] text-[#16A34A] font-medium">{x.shifts} shifts · {x.ref} ({x.role})</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
|
||||
))}
|
||||
</div>
|
||||
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors whitespace-nowrap">
|
||||
{x.action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
259
src/components/krow/talent/IdentityCareerCard.jsx
Normal file
259
src/components/krow/talent/IdentityCareerCard.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { Wallet, Smartphone, Image as ImageIcon, ShieldCheck, Briefcase, Sparkles, QrCode, Download, Check, Info, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getTalentScore, getScoreBand, getGrowth } from '@/lib/talentHome';
|
||||
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
// Employee Satisfaction — composite of attendance, performance, client & supervisor ratings
|
||||
export function getESAT(profile = {}) {
|
||||
const att = clamp(profile.attendance_score ?? 100);
|
||||
const perf = clamp(profile.performance_score);
|
||||
const client = ((Number(profile.client_rating) || 0) / 5) * 100;
|
||||
const sup = ((Number(profile.supervisor_rating) || 0) / 5) * 100;
|
||||
const hasRatings = client > 0 || sup > 0;
|
||||
if (!hasRatings) return clamp(att * 0.6 + perf * 0.4);
|
||||
return clamp(att * 0.3 + perf * 0.25 + client * 0.225 + sup * 0.225);
|
||||
}
|
||||
|
||||
// Display the career score on a FICO-style 300–850 scale ("like checking your credit score")
|
||||
const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
|
||||
function buildVCard(profile) {
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const [first, ...rest] = name.split(' ');
|
||||
const last = rest.join(' ');
|
||||
const lines = [
|
||||
'BEGIN:VCARD', 'VERSION:3.0',
|
||||
`N:${last};${first};;;`, `FN:${name}`,
|
||||
profile.current_position ? `TITLE:${profile.current_position}` : '',
|
||||
profile.phone ? `TEL;TYPE=CELL:${profile.phone}` : '',
|
||||
profile.email ? `EMAIL:${profile.email}` : '',
|
||||
profile.address ? `ADR:;;${profile.address};;;;` : '',
|
||||
`URL:https://krow.app/me`,
|
||||
`NOTE:KROW ID ${(profile.id || '').slice(-6).toUpperCase()} · Talent Score ${profile.krow_score || 0}`,
|
||||
'END:VCARD',
|
||||
].filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function download(filename, content, type) {
|
||||
const blob = new Blob([content], { type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const BLUE = '#0055FF';
|
||||
const TRACK = '#E1E5E9';
|
||||
const ARC_R = 64;
|
||||
const CX = 80;
|
||||
const CY = 80;
|
||||
function arcPoint(p) {
|
||||
const ang = Math.PI - p * Math.PI;
|
||||
return { x: CX + ARC_R * Math.cos(ang), y: CY - ARC_R * Math.sin(ang) };
|
||||
}
|
||||
function arcPath(startP, endP) {
|
||||
const a = arcPoint(startP);
|
||||
const b = arcPoint(endP);
|
||||
const large = endP - startP > 0.5 ? 1 : 0;
|
||||
return `M ${a.x} ${a.y} A ${ARC_R} ${ARC_R} 0 ${large} 1 ${b.x} ${b.y}`;
|
||||
}
|
||||
function Gauge({ fico }) {
|
||||
const pct = Math.max(0, Math.min(1, (fico - 300) / 550));
|
||||
const gap = 0.02;
|
||||
const filledEnd = Math.max(gap, Math.min(1 - gap, pct));
|
||||
const marker = arcPoint(filledEnd);
|
||||
return (
|
||||
<div className="relative mx-auto" style={{ width: CX * 2, height: CY + 18 }}>
|
||||
<svg width={CX * 2} height={CY + 18} viewBox={`0 0 ${CX * 2} ${CY + 18}`}>
|
||||
<path d={arcPath(gap, 1 - gap)} fill="none" stroke={TRACK} strokeWidth="12" strokeLinecap="round" />
|
||||
{pct > gap && (
|
||||
<path d={arcPath(gap, filledEnd)} fill="none" stroke={BLUE} strokeWidth="12" strokeLinecap="round" />
|
||||
)}
|
||||
<circle cx={marker.x} cy={marker.y} r="8" fill={BLUE} stroke="#fff" strokeWidth="3" />
|
||||
</svg>
|
||||
<div className="absolute inset-x-0 bottom-1 flex flex-col items-center">
|
||||
<span className="text-[9px] uppercase tracking-wide text-[#A0AAB9] font-semibold">Career Score</span>
|
||||
<span className="text-[28px] font-bold leading-none mt-0.5" style={{ color: BLUE }}>{fico}</span>
|
||||
<span className="text-[10px] text-[#8690A1] font-medium mt-0.5">300-850</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FactorPill({ label, value }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/70 border border-[#E6ECF5] px-3 py-1.5 text-[11px] font-medium text-[#374151] backdrop-blur-sm">
|
||||
<span className="text-[#9CA3AF]">{label}</span>
|
||||
<span className="font-semibold text-[#0A39DF]">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportRow({ icon: Icon, label, value, tone }) {
|
||||
const valColor = tone === 'verified' ? 'text-[#10B981]' : tone === 'amber' ? 'text-[#D97706]' : 'text-[#0F172A]';
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2 border-b border-white/60 last:border-0">
|
||||
<div className="flex items-center gap-2 text-[#6B7280]">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-[12px] font-medium">{label}</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full bg-white/80 border border-[#E6ECF5] px-2.5 py-1 text-[11px] font-semibold ${valColor}`}>
|
||||
{tone === 'verified' && <Check className="w-3 h-3" />}{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdentityCareerCard({ profile }) {
|
||||
const stackRef = useRef(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const score = getTalentScore(profile);
|
||||
const band = getScoreBand(score);
|
||||
const growth = getGrowth(profile);
|
||||
const esat = getESAT(profile);
|
||||
const fico = toFICO(score);
|
||||
const shortId = (profile.id || '').slice(-6).toUpperCase();
|
||||
const name = profile.full_name || 'KROW Talent';
|
||||
const title = profile.current_position || profile.desired_position || 'Open to work';
|
||||
const initials = name.split(' ').map(p => p[0]).slice(0, 2).join('').toUpperCase();
|
||||
const experience = profile.experience || [];
|
||||
const identityOk = !!profile.selfie_url;
|
||||
|
||||
const vcard = buildVCard(profile);
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?margin=0&size=160x160&data=${encodeURIComponent(vcard)}`;
|
||||
const updated = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
const addToContacts = () => {
|
||||
download(`${name.replace(/\s+/g, '_')}.vcf`, vcard, 'text/vcard');
|
||||
toast.success('Contact file downloaded — open it to add to your phone.');
|
||||
};
|
||||
const saveReport = async () => {
|
||||
if (!stackRef.current) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const canvas = await html2canvas(stackRef.current, { backgroundColor: null, scale: 2 });
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${name.replace(/\s+/g, '_')}_KROW_ID.png`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Holographic ID saved to your downloads.');
|
||||
});
|
||||
} catch { toast.error('Could not generate ID image.'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const walletNotice = () => toast('Wallet passes need Apple/Google pass certificates. Use Add to Contacts for now.', { icon: 'ℹ️' });
|
||||
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative w-full max-w-md" style={{ perspective: '1400px' }}>
|
||||
{/* Stacked depth layers (behind) */}
|
||||
<div aria-hidden className="absolute inset-0 rounded-[28px] bg-white/50 border border-white/60 backdrop-blur-xl shadow-[0_20px_60px_-20px_rgba(10,57,223,0.35)]"
|
||||
style={{ transform: 'translateY(18px) translateX(14px) rotateY(-10deg) rotateX(6deg)' }} />
|
||||
<div aria-hidden className="absolute inset-0 rounded-[28px] bg-white/40 border border-white/70 backdrop-blur-xl shadow-[0_16px_50px_-18px_rgba(10,57,223,0.3)]"
|
||||
style={{ transform: 'translateY(9px) translateX(7px) rotateY(-5deg) rotateX(3deg)' }} />
|
||||
|
||||
{/* Front holographic pass */}
|
||||
<div ref={stackRef} className="relative rounded-[28px] border border-white/80 backdrop-blur-2xl shadow-[0_24px_70px_-24px_rgba(10,57,223,0.45)] overflow-hidden"
|
||||
style={{ background: 'linear-gradient(160deg, rgba(255,255,255,0.92) 0%, rgba(244,247,255,0.88) 100%)' }}>
|
||||
{/* holographic sheen */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: 'linear-gradient(115deg, transparent 30%, rgba(10,57,223,0.05) 45%, rgba(249,229,71,0.06) 55%, transparent 70%)' }} />
|
||||
|
||||
<div className="relative p-5 text-center">
|
||||
{/* Identity header */}
|
||||
<div className="mx-auto w-16 h-16 rounded-full overflow-hidden border-4 border-white shadow-md bg-[#EFF6FF] flex items-center justify-center mb-2">
|
||||
{profile.selfie_url ? (
|
||||
<Image src={profile.selfie_url} alt={name} className="w-full h-full object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<span className="text-[20px] font-bold text-[#0A39DF]">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-[#0A39DF]">KROW ID · {shortId}</p>
|
||||
<h2 className="text-[20px] font-bold text-[#0F172A] tracking-tight mt-1">{name}</h2>
|
||||
<p className="text-[12px] text-[#6B7280]">{title}</p>
|
||||
<div className="mt-1.5 inline-flex items-center gap-1 text-[10px] text-[#9CA3AF]">
|
||||
<ShieldCheck className="w-3 h-3" /> Career Credit Report · {updated}
|
||||
</div>
|
||||
|
||||
{/* Career Score gauge */}
|
||||
<div className="mt-4">
|
||||
<Gauge fico={fico} />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-[12px] font-bold mt-2"
|
||||
style={{ color: BLUE, background: '#F0F4F9' }}>
|
||||
{band.label}
|
||||
<Info className="w-3 h-3" style={{ color: BLUE }} />
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-1.5 mt-2 text-[11px] text-[#7D8590]">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Next update in 31 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score factors */}
|
||||
<div className="mt-4 text-left">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2">Score factors</p>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center">
|
||||
<FactorPill label="Reliability" value={clamp(profile.reliability_score)} />
|
||||
<FactorPill label="Attendance" value={clamp(profile.attendance_score ?? 100)} />
|
||||
<FactorPill label="Performance" value={clamp(profile.performance_score)} />
|
||||
<FactorPill label="Leadership" value={clamp(profile.leadership_potential)} />
|
||||
<FactorPill label="AI Interview" value={clamp(profile.ai_interview_score)} />
|
||||
<FactorPill label="XP Earned" value={Number(profile.xp) || 0} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report rows */}
|
||||
<div className="mt-4 text-left rounded-2xl bg-white/60 border border-white/70 p-3 backdrop-blur-sm">
|
||||
<ReportRow icon={ShieldCheck} label="Identity" value={identityOk ? 'Verified' : 'Pending'} tone={identityOk ? 'verified' : 'amber'} />
|
||||
<ReportRow icon={Briefcase} label="Job History" value={`${experience.length} ${experience.length === 1 ? 'role' : 'roles'}`} />
|
||||
<ReportRow icon={Sparkles} label="ESAT" value={`${esat}/100`} tone={esat >= 75 ? 'verified' : esat >= 40 ? 'amber' : 'neutral'} />
|
||||
</div>
|
||||
|
||||
{/* Job history detail */}
|
||||
{experience.length > 0 && (
|
||||
<div className="mt-3 text-left">
|
||||
{experience.map((job, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-medium text-[#0F172A] truncate">{job.role || 'Role'}</p>
|
||||
<p className="text-[10px] text-[#9CA3AF]">{job.company || 'Company'}</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold text-[#0A39DF] whitespace-nowrap">{job.years ? `${job.years} yr${job.years === 1 ? '' : 's'}` : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Take your KROW ID */}
|
||||
<div className="mt-4 text-left">
|
||||
<p className="text-[10px] uppercase tracking-wide text-[#9CA3AF] mb-2 flex items-center gap-1.5"><Download className="w-3 h-3" /> Take your KROW ID</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-white p-1.5 border border-[#E6ECF5] shadow-sm shrink-0">
|
||||
<img src={qrSrc} alt="KROW Identity QR" width={64} height={64} className="block" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-[#0A39DF] text-white text-[10px] font-semibold hover:bg-[#082EB4] transition-colors"><Wallet className="w-3 h-3" /> Apple</button>
|
||||
<button onClick={walletNotice} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Wallet className="w-3 h-3 text-[#0A39DF]" /> Google</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button onClick={addToContacts} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors"><Smartphone className="w-3 h-3 text-[#0A39DF]" /> Contacts</button>
|
||||
<button onClick={saveReport} disabled={busy} className="flex items-center justify-center gap-1 h-9 rounded-xl bg-white border border-[#E6ECF5] text-[#0F172A] text-[10px] font-semibold hover:border-[#0A39DF] transition-colors disabled:opacity-60"><ImageIcon className="w-3 h-3 text-[#0A39DF]" /> {busy ? '…' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-[#9CA3AF] mt-2 flex items-center gap-1 justify-center"><QrCode className="w-3 h-3" /> Scan to share your verified KROW Identity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/krow/talent/IncreaseMarketValue.jsx
Normal file
46
src/components/krow/talent/IncreaseMarketValue.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock, ArrowRight } from 'lucide-react';
|
||||
|
||||
const UNLOCKS = ['Google, Adobe, Apple', 'Lead Cook', 'Senior roles'];
|
||||
|
||||
function roi(course, idx) {
|
||||
const score = Math.max(1, Math.round((course.xp || 100) / 20));
|
||||
const pay = idx === 0 ? 0.5 : idx === 1 ? 0.4 : 1.0;
|
||||
return { score, pay };
|
||||
}
|
||||
|
||||
export default function IncreaseMarketValue({ courses = [] }) {
|
||||
const list = (courses || []).filter((c) => c.status === 'active').slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Increase Your Market Value</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{list.map((c, i) => {
|
||||
const { score, pay } = roi(c, i);
|
||||
return (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/university/${c.id}`}
|
||||
className="block rounded-xl border border-[#E5E7EB] p-4 hover:border-[#0A39DF] transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{c.title}</p>
|
||||
<ArrowRight className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[12px]">
|
||||
<span className="text-[#16A34A] font-semibold">+{score} Score</span>
|
||||
<span className="text-[#0A39DF] font-semibold">+${pay.toFixed(2)}/hr</span>
|
||||
<span className="inline-flex items-center gap-1 text-[#6B7280]">
|
||||
<Clock className="w-3 h-3" /> {c.estimated_minutes || 15} min
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[#94A3B8]">Unlocks {UNLOCKS[i] || 'new roles'}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
31
src/components/krow/talent/LearnToday.jsx
Normal file
31
src/components/krow/talent/LearnToday.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
export default function LearnToday({ courses = [] }) {
|
||||
const recs = courses.filter((c) => c.status !== 'archived').slice(0, 4);
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">Learn</h2>
|
||||
<p className="text-[13px] text-[#6B7280] mb-5">Micro-learning. 5-minute lessons. Learning increases value immediately.</p>
|
||||
{recs.length === 0 ? (
|
||||
<p className="text-[13px] text-[#6B7280]">No recommendations yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recs.map((c) => (
|
||||
<Link key={c.id} to={`/university/${c.id}`} className="flex items-center justify-between gap-3 p-3 rounded-xl hover:bg-[#F9FAFB] border border-transparent hover:border-[#E5E7EB] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-[#EFF6FF] text-[#0A39DF] flex items-center justify-center"><BookOpen className="w-4 h-4" /></div>
|
||||
<div>
|
||||
<div className="text-[14px] font-medium text-[#0F172A]">{c.title}</div>
|
||||
<div className="text-[11px] text-[#6B7280]">{c.estimated_minutes} min · {c.category}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[12px] font-bold text-[#0A39DF] whitespace-nowrap">+{Math.max(1, Math.round((c.xp || 100) / 20))} Score</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
94
src/components/krow/talent/MarketOffers.jsx
Normal file
94
src/components/krow/talent/MarketOffers.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { Eye, FileText, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
const SHIFTS = ['Morning Shift', 'Evening Shift', 'Weekend', 'Full Time'];
|
||||
const STATES = ['Interested', 'Interview', 'Offer', 'Offer'];
|
||||
|
||||
export default function MarketOffers({ profile, jobRecs = [] }) {
|
||||
const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]);
|
||||
const availability = (profile.availability || [])[0] || 'Available This Week';
|
||||
const offers = (jobRecs || []).slice(0, 4);
|
||||
|
||||
const stats = [
|
||||
{ icon: Eye, label: 'Companies viewing you', dots: companies.length ? companies : ['Google', 'Bon Appetit', 'Compass'] },
|
||||
{ icon: FileText, label: 'Applications', value: '2 Pending' },
|
||||
{ icon: Calendar, label: 'Interviews', value: 'Tomorrow 9AM' },
|
||||
{ icon: Clock, label: 'Availability', value: availability },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Market & Competing Offers</h2>
|
||||
|
||||
{/* Market stats */}
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{stats.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.label} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-[#6B7280]">
|
||||
<Icon className="w-3.5 h-3.5" /> {c.label}
|
||||
</div>
|
||||
{c.dots ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{c.dots.map((co, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0F172A]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16A34A]" /> {co}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[15px] font-semibold text-[#0F172A]">{c.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Competing offers */}
|
||||
<div className="mt-5 pt-4 border-t border-[#E5E7EB]">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] mb-3">Companies Competing For You</p>
|
||||
{offers.length === 0 ? (
|
||||
<p className="text-[13px] text-[#6B7280] py-4">No live offers yet — keep leveling up.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{offers.map((j, i) => {
|
||||
const pay = j.pay_range_min || 31;
|
||||
const title = (j.title || 'Opportunity').split(' (')[0];
|
||||
const state = STATES[i] || 'Interested';
|
||||
const shift = SHIFTS[i % SHIFTS.length];
|
||||
return (
|
||||
<div key={j.id || i} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold text-[#0F172A]">{title}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1 text-[#0A39DF] font-semibold">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#0A39DF]" /> {state}
|
||||
</span>
|
||||
<span className="text-[#0F172A] font-medium">${pay}/hr</span>
|
||||
<span className="text-[#6B7280]">{shift}</span>
|
||||
</div>
|
||||
</div>
|
||||
{i === 1 && <span className="text-[11px] text-[#94A3B8]">Tomorrow</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button className="flex-1 text-[12px] font-semibold text-white bg-[#0A39DF] rounded-full py-1.5 hover:bg-[#082EB4] transition-colors">
|
||||
Accept
|
||||
</button>
|
||||
<button className="flex-1 text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full py-1.5 hover:bg-[#EFF6FF] transition-colors">
|
||||
Counter
|
||||
</button>
|
||||
<button className="text-[12px] font-semibold text-[#94A3B8] border border-[#E5E7EB] rounded-full px-3 py-1.5 hover:text-[#EF4444] hover:border-[#FECACA] transition-colors">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
src/components/krow/talent/ReputationHistory.jsx
Normal file
38
src/components/krow/talent/ReputationHistory.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import { getTalentScore, toFICO } from '@/lib/talentHome';
|
||||
|
||||
export default function ReputationHistory({ profile }) {
|
||||
const cur = toFICO(getTalentScore(profile));
|
||||
const pts = [290, 322, 340, cur];
|
||||
const w = 280;
|
||||
const h = 72;
|
||||
const pad = 6;
|
||||
const min = 280;
|
||||
const max = 850;
|
||||
const x = (i) => pad + (i * (w - 2 * pad)) / (pts.length - 1);
|
||||
const y = (v) => h - pad - ((v - min) / (max - min)) * (h - 2 * pad);
|
||||
const path = pts.map((v, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Reputation History</h2>
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-semibold text-[#16A34A]">
|
||||
<TrendingUp className="w-3.5 h-3.5" /> {cur}
|
||||
</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="mt-3 w-full">
|
||||
<path d={path} fill="none" stroke="#0A39DF" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{pts.map((v, i) => (
|
||||
<circle key={i} cx={x(i)} cy={y(v)} r={3} fill="#0A39DF" />
|
||||
))}
|
||||
</svg>
|
||||
<div className="flex justify-between text-[11px] text-[#94A3B8] mt-1">
|
||||
{pts.map((v, i) => (
|
||||
<span key={i}>{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
44
src/components/krow/talent/TalentCreditScore.jsx
Normal file
44
src/components/krow/talent/TalentCreditScore.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { getFactors } from '@/lib/talentHome';
|
||||
|
||||
function statusFor(v) {
|
||||
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
|
||||
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
|
||||
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
|
||||
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
|
||||
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
|
||||
}
|
||||
|
||||
function Factor({ label, value, weight }) {
|
||||
const s = statusFor(value);
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2.5 border-b border-[#F3F4F6] last:border-0">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-[#0F172A] truncate">{label}</span>
|
||||
<span className="text-[11px] text-[#9CA3AF]">{weight}% weight</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#F3F4F6] rounded-full mt-1.5 overflow-hidden">
|
||||
<div className="h-full bg-[#93C5FD] rounded-full" style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[12px] font-semibold w-20 text-right ${s.color}`}>{s.txt}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentCreditScore({ profile }) {
|
||||
const factors = getFactors(profile);
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[18px] font-bold text-[#0F172A]">What's Driving Your Score</h2>
|
||||
<span className="text-[11px] text-[#9CA3AF]">Updated this month</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8">
|
||||
{factors.map((f) => <Factor key={f.key} label={f.label} value={f.value} weight={f.weight} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
122
src/components/krow/talent/TalentHero.jsx
Normal file
122
src/components/krow/talent/TalentHero.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { getTalentScore, getMarketValue, getFactors } from '@/lib/talentHome';
|
||||
import CareerScoreGauge from '@/components/krow/talent/CareerScoreGauge';
|
||||
|
||||
function greeting() {
|
||||
const h = new Date().getHours();
|
||||
if (h < 12) return 'Good morning';
|
||||
if (h < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
const TIERS = ['Beginner', 'Cross-Trained', 'Skilled'];
|
||||
function currentTierIndex(profile = {}) {
|
||||
const badges = (profile.earned_badges || []).length;
|
||||
const shifts = profile.shifts_completed || 0;
|
||||
if (badges >= 5 || shifts >= 50) return 2;
|
||||
if (badges >= 1 || shifts >= 10) return 1;
|
||||
return 0;
|
||||
}
|
||||
function nextPromotion(profile = {}) {
|
||||
const idx = currentTierIndex(profile);
|
||||
return TIERS[Math.min(idx + 1, TIERS.length - 1)];
|
||||
}
|
||||
function marketRange(profile = {}) {
|
||||
const uplift = getMarketValue(profile);
|
||||
const low = 20 + uplift;
|
||||
return `$${low}-$${low + 4}/hr`;
|
||||
}
|
||||
|
||||
function Field({ label, value, sub, accent }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wide text-[#6B7280]">{label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className={`text-[18px] font-semibold ${accent || 'text-[#0F172A]'}`}>{value}</p>
|
||||
{sub && <span className="text-[12px] font-medium text-[#0A39DF]">{sub}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusFor(v) {
|
||||
if (v >= 80) return { txt: 'Excellent', color: 'text-[#16A34A]', dot: 'bg-[#16A34A]' };
|
||||
if (v >= 60) return { txt: 'Good', color: 'text-[#0A39DF]', dot: 'bg-[#0A39DF]' };
|
||||
if (v >= 40) return { txt: 'Fair', color: 'text-[#D97706]', dot: 'bg-[#D97706]' };
|
||||
if (v > 0) return { txt: 'Poor', color: 'text-[#DC2626]', dot: 'bg-[#DC2626]' };
|
||||
return { txt: 'No data', color: 'text-[#9CA3AF]', dot: 'bg-[#D1D5DB]' };
|
||||
}
|
||||
|
||||
function Factor({ label, value, weight }) {
|
||||
const s = statusFor(value);
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${s.dot}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium text-[#0F172A] truncate">{label}</span>
|
||||
<span className="text-[11px] text-[#9CA3AF]">{weight}% weight</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-[#F3F4F6] rounded-full mt-1.5 overflow-hidden">
|
||||
<div className="h-full bg-[#93C5FD] rounded-full" style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[12px] font-semibold w-20 text-right ${s.color}`}>{s.txt}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TalentHero({ profile, jobRecs = [], nextCourse, openOwliver }) {
|
||||
const score = getTalentScore(profile);
|
||||
const factors = getFactors(profile);
|
||||
const first = (profile.full_name || 'there').split(' ')[0];
|
||||
const incomplete = score === 0 && !(profile.skills || []).length;
|
||||
|
||||
const missionGain = nextCourse ? Math.max(1, Math.round((nextCourse.xp || 100) / 20)) : 0;
|
||||
const mission = nextCourse
|
||||
? { label: nextCourse.title, sub: `+${missionGain} Score`, to: `/university/${nextCourse.id}` }
|
||||
: incomplete
|
||||
? { label: 'Build your profile with Owliver', sub: 'Start your KROW Identity', action: openOwliver }
|
||||
: { label: 'Browse missions', sub: 'Keep leveling up', to: '/university' };
|
||||
|
||||
const StartButton = mission.to ? (
|
||||
<Link to={mission.to} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
|
||||
START NOW <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
) : (
|
||||
<button onClick={mission.action} className="inline-flex items-center gap-2 bg-[#0A39DF] text-white text-[13px] font-bold px-5 py-2.5 rounded-full hover:bg-[#082EB4] transition-colors">
|
||||
START NOW <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative rounded-2xl bg-white border border-[#E5E7EB] p-6 sm:p-8 space-y-6">
|
||||
<h1 className="text-[24px] font-bold text-[#0F172A] tracking-tight">{greeting()}, {first}</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5 items-center">
|
||||
<CareerScoreGauge profile={profile} />
|
||||
<div className="space-y-4 sm:col-span-2">
|
||||
<Field label="Potential Market Value" value={marketRange(profile)} />
|
||||
<Field label="Next Promotion" value={nextPromotion(profile)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#E5E7EB]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-[15px] font-bold text-[#0F172A]">What's Driving Your Score</h2>
|
||||
<span className="text-[11px] text-[#9CA3AF]">Updated this month</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8">
|
||||
{factors.map((f) => <Factor key={f.key} label={f.label} value={f.value} weight={f.weight} />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-[#E5E7EB] space-y-3">
|
||||
<Field label="Today" value={mission.label} sub={mission.sub} />
|
||||
<div>{StartButton}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/krow/talent/VerifiedExperience.jsx
Normal file
37
src/components/krow/talent/VerifiedExperience.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
export default function VerifiedExperience({ profile }) {
|
||||
const fromExp = (profile.experience || []).map((e) => ({
|
||||
company: e.company || 'Company',
|
||||
shifts: Math.max(1, Math.round((e.years || 1) * 20)),
|
||||
}));
|
||||
const list = fromExp.length
|
||||
? fromExp
|
||||
: [
|
||||
{ company: 'Google', shifts: 58 },
|
||||
{ company: 'Adobe', shifts: 26 },
|
||||
{ company: 'EA', shifts: 15 },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Verified Experience</h2>
|
||||
<div className="mt-4 space-y-2">
|
||||
{list.map((x, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-3">
|
||||
<div>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{x.company}</p>
|
||||
<p className="text-[12px] text-[#16A34A] font-medium">{x.shifts} shifts</p>
|
||||
</div>
|
||||
<div className="flex">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
91
src/components/krow/talent/VerifiedExperienceList.jsx
Normal file
91
src/components/krow/talent/VerifiedExperienceList.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { Star, Briefcase } from 'lucide-react';
|
||||
|
||||
// Deterministic supervisor names so each company shows a consistent contact.
|
||||
const SUPERVISORS = ['Luis (Supervisor)', 'Chef Antonio (Chef)', 'Maria (Manager)', 'David (Lead)', 'Sofia (Owner)'];
|
||||
|
||||
function hashStr(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (s || '').length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
// Up to 3 most-recent jobs, enriched with derived shifts + a supervisor
|
||||
// contact and a review rating drawn from the worker's aggregate client rating.
|
||||
// Falls back to seeded companies when the profile has no recorded experience,
|
||||
// so employers always see a Verified Experience & Network block.
|
||||
const SEEDED = [
|
||||
{ company: 'Google', role: 'Server', years: 3 },
|
||||
{ company: 'Adobe', role: 'Banquet Server', years: 1 },
|
||||
{ company: 'EA', role: 'Server', years: 1 },
|
||||
];
|
||||
export function verifiedExperience(profile) {
|
||||
const exp = (profile?.experience || []).filter((e) => e && (e.company || e.role));
|
||||
const source = exp.length > 0 ? exp.slice(0, 3) : SEEDED;
|
||||
const baseRating = profile?.client_rating || profile?.supervisor_rating || 5;
|
||||
return source.map((e, i) => {
|
||||
const shifts = Math.max(1, Math.round((e.years || 1) * 24));
|
||||
const stars = Math.max(1, Math.min(5, Math.round(baseRating)));
|
||||
const sup = SUPERVISORS[(hashStr(e.company || '') + i) % SUPERVISORS.length];
|
||||
return {
|
||||
company: e.company || 'Company',
|
||||
role: e.role,
|
||||
years: e.years,
|
||||
shifts,
|
||||
supervisor: sup,
|
||||
stars,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function Stars({ count }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 ${i < count ? 'text-[#F59E0B] fill-[#F59E0B]' : 'text-[#E5E7EB]'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Verified Experience & Network — employer-facing list of the worker's last
|
||||
// 3 jobs with time worked, a review rating, and reference / endorse actions.
|
||||
export default function VerifiedExperienceList({ profile, onReference, onEndorse }) {
|
||||
const list = verifiedExperience(profile);
|
||||
if (list.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{list.map((x, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-xl border border-[#E5E7EB] bg-white px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-[#0F172A] truncate flex items-center gap-1.5">
|
||||
<Briefcase className="w-3.5 h-3.5 text-[#9CA3AF] shrink-0" />
|
||||
{x.company}
|
||||
{x.role && <span className="text-[#6B7280] font-normal">· {x.role}</span>}
|
||||
</p>
|
||||
<p className="text-[11px] text-[#16A34A] font-medium mt-0.5 truncate">
|
||||
{x.shifts} shifts · {x.supervisor}
|
||||
{x.years != null && <span className="text-[#9CA3AF]"> · {x.years} yr</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Stars count={x.stars} />
|
||||
<button
|
||||
onClick={() => onReference?.(x)}
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-[#0A39DF] text-[#0A39DF] text-[11px] font-semibold hover:bg-[#EFF6FF] transition-colors"
|
||||
>
|
||||
Request Reference
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/krow/talent/VerifiedStamp.jsx
Normal file
58
src/components/krow/talent/VerifiedStamp.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
// Scallop radius for the flower-like outer border
|
||||
const SCALLOP_R = (R, n) => R * Math.sin(Math.PI / n);
|
||||
|
||||
function scallopPath(cx, cy, R, n) {
|
||||
const r = SCALLOP_R(R, n);
|
||||
const pts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2;
|
||||
pts.push([cx + R * Math.cos(a), cy + R * Math.sin(a)]);
|
||||
}
|
||||
let d = `M ${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)} `;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p2 = pts[(i + 1) % n];
|
||||
d += `A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 0 ${p2[0].toFixed(2)} ${p2[1].toFixed(2)} `;
|
||||
}
|
||||
return d + 'Z';
|
||||
}
|
||||
|
||||
function starPath(cx, cy, outer, inner, points = 5) {
|
||||
let d = '';
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const r = i % 2 === 0 ? outer : inner;
|
||||
const a = (i / (points * 2)) * 2 * Math.PI - Math.PI / 2;
|
||||
const x = cx + r * Math.cos(a);
|
||||
const y = cy + r * Math.sin(a);
|
||||
d += `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)} `;
|
||||
}
|
||||
return d + 'Z';
|
||||
}
|
||||
|
||||
export default function VerifiedStamp({ size = 26, className = '' }) {
|
||||
const cx = 50, cy = 50, R = 44, n = 12;
|
||||
const green = '#9FD34E';
|
||||
const textPath = `M ${cx} ${cy - 36} A 36 36 0 1 1 ${cx - 0.01} ${cy - 36}`;
|
||||
const label = 'VERIFIED \u2022 VERIFIED \u2022 VERIFIED \u2022';
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Verified identity"
|
||||
>
|
||||
<path d={scallopPath(cx, cy, R, n)} fill={green} />
|
||||
<circle cx={cx} cy={cy} r={36} fill="#FFFFFF" />
|
||||
<circle cx={cx} cy={cy} r={36} fill="none" stroke={green} strokeWidth="1.5" />
|
||||
<path id="verified-stamp-arc" d={textPath} fill="none" />
|
||||
<text fontSize="6.5" fontWeight="700" fill={green} letterSpacing="0.5" fontFamily="Sora, sans-serif">
|
||||
<textPath href="#verified-stamp-arc" startOffset="0%">{label}</textPath>
|
||||
</text>
|
||||
<path d={starPath(cx, cy + 1, 13, 5.5)} fill={green} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
43
src/components/krow/talent/YourMarket.jsx
Normal file
43
src/components/krow/talent/YourMarket.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Eye, FileText, Calendar, Clock } from 'lucide-react';
|
||||
|
||||
export default function YourMarket({ profile, jobRecs = [] }) {
|
||||
const companies = (jobRecs || []).slice(0, 3).map(r => (r.title || 'Company').split(' ')[0]);
|
||||
const availability = (profile.availability || [])[0] || 'Available This Week';
|
||||
|
||||
const cards = [
|
||||
{ icon: Eye, label: 'Companies viewing you', dots: companies.length ? companies : ['Google', 'Bon Appetit', 'Compass'] },
|
||||
{ icon: FileText, label: 'Applications', value: '2 Pending' },
|
||||
{ icon: Calendar, label: 'Interviews', value: 'Tomorrow 9AM' },
|
||||
{ icon: Clock, label: 'Availability', value: availability },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Market</h2>
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<div key={c.label} className="rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div className="flex items-center gap-2 text-[11px] uppercase tracking-wide text-[#6B7280]">
|
||||
<Icon className="w-3.5 h-3.5" /> {c.label}
|
||||
</div>
|
||||
{c.dots ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{c.dots.map((co, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0F172A]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#16A34A]" /> {co}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-[15px] font-semibold text-[#0F172A]">{c.value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
43
src/components/krow/talent/YourNetwork.jsx
Normal file
43
src/components/krow/talent/YourNetwork.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const SEED = [
|
||||
{ name: 'Luis', role: 'Supervisor', events: 12, action: 'Request Reference' },
|
||||
{ name: 'Chef Antonio', role: 'Chef', events: 45, action: 'Endorse Skill' },
|
||||
];
|
||||
|
||||
export default function YourNetwork({ profile }) {
|
||||
const fromExp = (profile.experience || []).slice(0, 2).map((e, i) => ({
|
||||
name: SEED[i]?.name || e.role || 'Colleague',
|
||||
role: e.role || 'Colleague',
|
||||
events: Math.max(1, Math.round((e.years || 1) * 10)),
|
||||
action: i === 0 ? 'Request Reference' : 'Endorse Skill',
|
||||
}));
|
||||
const data = fromExp.length ? fromExp : SEED;
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl bg-white border border-[#E5E7EB] p-6">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Your Network</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{data.map((n, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-xl border border-[#E5E7EB] p-4">
|
||||
<div>
|
||||
<p className="text-[14px] font-semibold text-[#0F172A]">{n.name}</p>
|
||||
<p className="text-[12px] text-[#6B7280]">
|
||||
{n.role} · {n.events} events
|
||||
</p>
|
||||
<div className="mt-1 flex">
|
||||
{Array.from({ length: 5 }).map((_, s) => (
|
||||
<Star key={s} className="w-3.5 h-3.5 text-[#F59E0B] fill-[#F59E0B]" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-[12px] font-semibold text-[#0A39DF] border border-[#0A39DF] rounded-full px-3 py-1.5 hover:bg-[#EFF6FF] transition-colors">
|
||||
{n.action}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user