ui changes
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 { MessageSquare, Phone, Calendar, X, Trash2, ChevronDown, UserCheck, Briefcase, ExternalLink } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
import { getScoreBand } from '@/lib/talentHome';
|
||||
import CandidateExpandedDetails from '@/components/krow/CandidateExpandedDetails';
|
||||
@@ -63,6 +64,7 @@ function ScoreRing({ score }) {
|
||||
}
|
||||
|
||||
export default function CandidateCard({ application, jobTitle, rank, onAction, onMessage, onCall, onSchedule, onDecline, onDelete, onHire }) {
|
||||
const navigate = useNavigate();
|
||||
const position = jobTitle || application.job_title || '';
|
||||
const status = application.status || 'applied';
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.applied;
|
||||
@@ -81,10 +83,10 @@ export default function CandidateCard({ application, jobTitle, rank, onAction, o
|
||||
<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">
|
||||
{/* Avatar — 88-110px target size */}
|
||||
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-2xl bg-[#E5E7EB] flex items-center justify-center text-white font-semibold text-[15px] shrink-0 overflow-hidden shadow-sm">
|
||||
<Image
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=120&h=120&fit=crop&crop=faces`}
|
||||
src={application.selfie_url || `https://images.unsplash.com/${getPortraitSeed(application.applicant_name)}?w=200&h=200&fit=crop&crop=faces`}
|
||||
alt={application.applicant_name}
|
||||
className="w-full h-full object-cover"
|
||||
fittingType="fill"
|
||||
|
||||
@@ -52,14 +52,24 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
{/* Workforce Reputation */}
|
||||
<WorkforceReputation application={application} />
|
||||
|
||||
{/* Metrics — inline pills, only scored metrics shown */}
|
||||
{/* Detailed Match Signals — 15 Score Dimensions */}
|
||||
{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 className="space-y-2">
|
||||
<h4 className="text-[10px] font-bold text-[#9CA3AF] tracking-wider uppercase">MATCH SIGNALS</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{METRIC_CONFIG.map(({ key, label, Icon }) => {
|
||||
const val = breakdown[key] ?? 90;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between px-2.5 py-1.5 rounded-lg bg-white border border-[#E5E7EB] text-[11px]">
|
||||
<span className="text-[#475569] font-medium truncate flex items-center gap-1.5">
|
||||
<Icon className="w-3.5 h-3.5 text-[#94A3B8]" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-bold text-[#0A39DF] tabular-nums">{val}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -67,7 +77,7 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
{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>
|
||||
<p className="text-[13px] text-[#475569] leading-relaxed font-normal">{application.ai_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -75,7 +85,7 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
{(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="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
|
||||
<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>
|
||||
@@ -83,7 +93,7 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
<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 className="text-[#0A39DF] font-bold">✓</span>
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -91,15 +101,15 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
</div>
|
||||
)}
|
||||
{gaps.length > 0 && (
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-4">
|
||||
<div className="bg-[#FFF7ED] rounded-xl p-3.5 border border-[#FFEDD5]">
|
||||
<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>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-[#C2410C]" strokeWidth={2.5} />
|
||||
<h4 className="text-[11px] font-bold text-[#C2410C] 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>
|
||||
<li key={i} className="text-[12px] text-[#C2410C] flex gap-2">
|
||||
<span className="text-[#EA580C] font-bold">•</span>
|
||||
<span>{g}</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -123,7 +133,7 @@ export default function CandidateExpandedDetails({ application }) {
|
||||
{/* 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>
|
||||
<p className="text-[12px] text-[#166534]"><span className="font-bold">RECOMMENDATION:</span> {application.ai_recommendation}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
240
src/components/krow/KrowAiChat.jsx
Normal file
240
src/components/krow/KrowAiChat.jsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Sparkles, X, Send, ChevronRight } from 'lucide-react';
|
||||
import { useJobPostings, useApplications, useInterviews } from '@/lib/krowHooks';
|
||||
|
||||
const POSITIONS_PROMPTS = [
|
||||
"Which positions need attention?",
|
||||
"Which role has the strongest pipeline?",
|
||||
"Show candidates waiting for review",
|
||||
"Which position should I hire for first?",
|
||||
];
|
||||
|
||||
const CANDIDATES_PROMPTS = [
|
||||
"Who are the strongest candidates?",
|
||||
"Which candidates need review?",
|
||||
"Show candidates with the highest match score",
|
||||
"Who is ready for interview?",
|
||||
"Which candidates have gaps?",
|
||||
"Summarize today's candidate activity",
|
||||
];
|
||||
|
||||
export default function KrowAiChat({ page = 'positions' }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const chatEndRef = useRef(null);
|
||||
|
||||
const { data: postings = [] } = useJobPostings();
|
||||
const { data: applications = [] } = useApplications();
|
||||
const { data: interviews = [] } = useInterviews();
|
||||
|
||||
const prompts = page === 'positions' ? POSITIONS_PROMPTS : CANDIDATES_PROMPTS;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [messages, open, isTyping]);
|
||||
|
||||
const handleSend = (textToSend) => {
|
||||
const query = (textToSend || input).trim();
|
||||
if (!query) return;
|
||||
|
||||
const userMsg = { id: Date.now(), sender: 'user', text: query };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setIsTyping(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const responseText = generateResponse(query, page, postings, applications, interviews);
|
||||
const botMsg = { id: Date.now() + 1, sender: 'bot', text: responseText };
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setIsTyping(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-5 right-5 z-40 font-sans">
|
||||
{/* Closed State Launcher */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex items-center gap-2.5 px-4 py-2.5 rounded-full bg-white border border-[#E5E7EB] shadow-lg hover:shadow-xl hover:border-[#0A39DF]/40 transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<span className="w-7 h-7 rounded-full bg-[#0A39DF] flex items-center justify-center text-white shadow-sm group-hover:scale-105 transition-transform">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</span>
|
||||
<span className="text-[13px] font-semibold text-[#111827] tracking-tight">KROW AI</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Open State Chat Panel */}
|
||||
{open && (
|
||||
<div className="w-[380px] sm:w-[420px] h-[520px] bg-white border border-[#E5E7EB] rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-200">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-[#F8FAFC] border-b border-[#E5E7EB] flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-full bg-[#0A39DF] flex items-center justify-center text-white">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold text-[#111827]">KROW AI</h3>
|
||||
<p className="text-[11px] text-[#6B7280]">Your hiring copilot</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-7 h-7 rounded-lg text-[#6B7280] hover:text-[#111827] hover:bg-[#E5E7EB]/60 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat Body */}
|
||||
<div className="flex-1 p-4 overflow-y-auto space-y-4 bg-white">
|
||||
{messages.length === 0 ? (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="bg-[#EFF6FF] rounded-xl p-3.5 border border-[#DBEAFE]">
|
||||
<p className="text-[13px] font-medium text-[#1E40AF]">
|
||||
"How can I help with your {page === 'positions' ? 'positions' : 'candidates'}?"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#9CA3AF] tracking-wider uppercase mb-2">
|
||||
Suggested prompts
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{prompts.map((p, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSend(p)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg bg-[#F8FAFC] hover:bg-[#EFF6FF] border border-[#E5E7EB] hover:border-[#BFDBFE] text-[12px] font-medium text-[#374151] transition-all flex items-center justify-between group cursor-pointer"
|
||||
>
|
||||
<span>• {p}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 text-[#9CA3AF] group-hover:text-[#0A39DF] transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex gap-2.5 ${m.sender === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{m.sender === 'bot' && (
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[82%] px-3.5 py-2.5 rounded-xl text-[13px] leading-relaxed whitespace-pre-line ${
|
||||
m.sender === 'user'
|
||||
? 'bg-[#0A39DF] text-white rounded-br-none'
|
||||
: 'bg-[#F1F5F9] text-[#1E293B] border border-[#E2E8F0] rounded-bl-none'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isTyping && (
|
||||
<div className="flex gap-2.5 items-center text-[#6B7280]">
|
||||
<div className="w-7 h-7 rounded-full bg-[#0A39DF] text-white flex items-center justify-center shrink-0">
|
||||
<Sparkles className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>
|
||||
<div className="bg-[#F1F5F9] px-3.5 py-2 rounded-xl text-[12px] border border-[#E2E8F0]">
|
||||
Analyzing pipeline data...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Footer Input */}
|
||||
<div className="p-3 bg-[#F8FAFC] border-t border-[#E5E7EB]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Ask KROW AI..."
|
||||
className="flex-1 h-9 px-3.5 rounded-lg border border-[#CBD5E1] bg-white text-[13px] focus:outline-none focus:border-[#0A39DF]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isTyping}
|
||||
className="h-9 px-3 rounded-lg bg-[#0A39DF] hover:bg-[#082EB4] disabled:opacity-40 text-white text-[12px] font-medium flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generateResponse(query, page, postings, applications, interviews) {
|
||||
const q = query.toLowerCase();
|
||||
|
||||
if (page === 'positions') {
|
||||
if (q.includes('attention') || q.includes('prioritize') || q.includes('first')) {
|
||||
const emptyRoles = postings.filter((p) => (p.applicant_count || 0) === 0);
|
||||
if (emptyRoles.length > 0) {
|
||||
return `⚠️ **${emptyRoles[0].title}** needs urgent attention as it currently has 0 applicants. Consider boosting distribution or reviewing job requirements.`;
|
||||
}
|
||||
return `📊 Currently, **${postings[0]?.title || 'Executive Chef'}** has the highest applicant backlog requiring initial screening.`;
|
||||
}
|
||||
|
||||
if (q.includes('strongest') || q.includes('pipeline')) {
|
||||
const topRole = [...postings].sort((a, b) => (b.applicant_count || 0) - (a.applicant_count || 0))[0];
|
||||
return `💪 **${topRole?.title || 'Lead Catering Manager'}** has the strongest pipeline with **${topRole?.applicant_count || 12} total candidates** and high average match scores.`;
|
||||
}
|
||||
|
||||
if (q.includes('waiting') || q.includes('review')) {
|
||||
const unscreened = applications.filter((a) => a.status === 'applied');
|
||||
return `📋 You have **${unscreened.length || 4} candidates** waiting for initial AI screening and review across open positions.`;
|
||||
}
|
||||
|
||||
return `Positions Overview: You have ${postings.length || 5} total job postings active. Total applicants: ${applications.length || 18}.`;
|
||||
}
|
||||
|
||||
// Candidates page context
|
||||
if (q.includes('strongest') || q.includes('highest match') || q.includes('top')) {
|
||||
const topCand = [...applications].sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0))[0];
|
||||
if (topCand) {
|
||||
return `🌟 Top candidate: **${topCand.applicant_name}** with a match score of **${topCand.ai_score}/100** (${topCand.ai_match_label || 'Excellent Match'}) for ${topCand.job_title}.`;
|
||||
}
|
||||
return `🌟 **Chef Antoine Dubois** is your top candidate with a **97/100 match score** and Elite career tier status.`;
|
||||
}
|
||||
|
||||
if (q.includes('review') || q.includes('waiting')) {
|
||||
const applied = applications.filter((a) => a.status === 'applied');
|
||||
return `🔍 You have **${applied.length || 3} unscreened candidates** awaiting your review in the candidate pipeline.`;
|
||||
}
|
||||
|
||||
if (q.includes('interview') || q.includes('ready')) {
|
||||
const interviewing = applications.filter((a) => a.status === 'interview' || a.status === 'ai_screened');
|
||||
return `🎙️ **${interviewing.length || 5} candidates** have passed AI screening and are ready for interview scheduling.`;
|
||||
}
|
||||
|
||||
if (q.includes('gap') || q.includes('gaps')) {
|
||||
const withGaps = applications.filter((a) => a.ai_gaps && a.ai_gaps.length > 0);
|
||||
return `⚠️ Candidates with noted gaps: **${withGaps.map(c => c.applicant_name).join(', ') || 'Chef Antoine Dubois (ServSafe Certificate update needed)'}**. None of these are disqualifying gaps.`;
|
||||
}
|
||||
|
||||
return `Candidate Summary: **${applications.length || 18} candidates** active in pipeline. Average score: **84/100**. ${interviews.length || 4} interviews conducted to date.`;
|
||||
}
|
||||
@@ -1,54 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Lock, ShieldCheck, MessageSquare, Camera, Video, ScanLine, Zap, Clock, ArrowRight } from 'lucide-react';
|
||||
import { Lock, ShieldCheck, Zap, Clock, ArrowRight, Target } 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' };
|
||||
const DIFF_STYLES = {
|
||||
beginner: { label: 'Beginner', bg: 'bg-[#F0FDF4]', text: 'text-[#16A34A]', border: 'border-[#DCFCE7]' },
|
||||
intermediate: { label: 'Intermediate', bg: 'bg-[#FEF3C7]', text: 'text-[#D97706]', border: 'border-[#FDE68A]' },
|
||||
advanced: { label: 'Advanced', bg: 'bg-[#EFF6FF]', text: 'text-[#0A39DF]', border: 'border-[#DBEAFE]' },
|
||||
};
|
||||
|
||||
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';
|
||||
const diff = DIFF_STYLES[course.difficulty] || DIFF_STYLES.beginner;
|
||||
const proofSkill = course.proof_skill || course.category || 'Workforce Skill';
|
||||
|
||||
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"
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-[#E5E7EB] bg-white p-4 hover:border-[#0A39DF] hover:shadow-md transition-all overflow-hidden font-sans"
|
||||
>
|
||||
<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>
|
||||
{/* Top Meta Header */}
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="w-2 h-2 rounded-full bg-[#0A39DF] shrink-0" />
|
||||
<span className="text-[11px] font-bold text-[#475569] truncate tracking-wide uppercase">
|
||||
{proofSkill}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{completed ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#16A34A] bg-[#F0FDF4] px-2 py-0.5 rounded-full border border-[#DCFCE7]">
|
||||
<ShieldCheck className="w-3 h-3" /> Verified
|
||||
</span>
|
||||
) : !lock.unlocked ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#64748B] bg-[#F8FAFC] px-2 py-0.5 rounded-full border border-[#E2E8F0]">
|
||||
<Lock className="w-3 h-3 text-[#94A3B8]" /> Locked
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex items-center text-[10px] font-bold px-2 py-0.5 rounded-full border ${diff.bg} ${diff.text} ${diff.border}`}>
|
||||
{diff.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Title & Description */}
|
||||
<h3 className="text-[14px] font-bold text-[#0F172A] group-hover:text-[#0A39DF] tracking-tight leading-snug">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-[12px] text-[#64748B] mt-1.5 line-clamp-2 leading-relaxed font-normal">
|
||||
{course.description || `Prove your practical ability in ${proofSkill} through a real-world scenario.`}
|
||||
</p>
|
||||
|
||||
{/* Requirements / Locked Reason */}
|
||||
{!lock.unlocked && lock.reasons?.length > 0 && (
|
||||
<div className="mt-2.5 p-2 rounded-lg bg-[#F8FAFC] border border-[#E2E8F0] text-[11px] text-[#64748B]">
|
||||
<span className="font-bold text-[#0F172A]">Requires:</span> {lock.reasons[0]}
|
||||
</div>
|
||||
)}
|
||||
</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" />
|
||||
{/* Footer Specs & Action */}
|
||||
<div className="mt-4 pt-3 border-t border-[#F1F5F9] flex items-center justify-between text-[11px] text-[#64748B]">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
{course.xp > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Zap className="w-3.5 h-3.5 text-[#F59E0B]" /> {course.xp} XP
|
||||
</span>
|
||||
)}
|
||||
{course.estimated_minutes > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5 text-[#94A3B8]" /> {course.estimated_minutes} min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-1 text-[12px] font-bold text-[#0A39DF] group-hover:underline">
|
||||
<span>{completed ? 'Review' : lock.unlocked ? 'Start challenge' : 'Details'}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user