Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}`}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &amp; 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}