Initial commit
This commit is contained in:
131
src/pages/University.jsx
Normal file
131
src/pages/University.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useCourses, useWorkerProfile, useEvidenceList } from '@/lib/krowHooks';
|
||||
import UniversityCourseCard from '@/components/krow/UniversityCourseCard';
|
||||
import CapabilityTimeline from '@/components/krow/proving/CapabilityTimeline';
|
||||
import BadgeChip from '@/components/krow/BadgeChip';
|
||||
import { Loader2, Search, Lock } from 'lucide-react';
|
||||
|
||||
const DIFFS = [
|
||||
{ v: 'all', label: 'All' },
|
||||
{ v: 'beginner', label: 'Beginner' },
|
||||
{ v: 'intermediate', label: 'Intermediate' },
|
||||
{ v: 'advanced', label: 'Advanced' },
|
||||
];
|
||||
|
||||
function LockCard({ course, profile }) {
|
||||
const req = course.unlock_requirements || {};
|
||||
const shifts = Number(profile?.shifts_completed) || 0;
|
||||
const rel = Number(profile?.reliability_score) || 0;
|
||||
const have = (profile?.earned_badges || []).map((b) => b.name);
|
||||
const bars = [];
|
||||
if (req.min_shifts) bars.push({ label: 'Shifts', val: Math.min(1, shifts / req.min_shifts), text: `${shifts}/${req.min_shifts}` });
|
||||
if (req.min_reliability) bars.push({ label: 'Reliability', val: Math.min(1, rel / req.min_reliability), text: `${rel}/${req.min_reliability}` });
|
||||
(req.required_badges || []).forEach((b) => bars.push({ label: b, val: have.includes(b) ? 1 : 0, text: have.includes(b) ? 'Earned' : 'Missing' }));
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lock className="w-4 h-4 text-[#94A3B8]" />
|
||||
<h3 className="text-[14px] font-semibold text-[#0F172A]">{course.title}</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{bars.map((b) => (
|
||||
<div key={b.label}>
|
||||
<div className="flex justify-between text-[11px] mb-1">
|
||||
<span className="text-[#64748B]">{b.label}</span>
|
||||
<span className={`font-medium ${b.val >= 1 ? 'text-[#16A34A]' : 'text-[#64748B]'}`}>{b.text}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-[#E2E8F0] overflow-hidden">
|
||||
<div className={`h-full rounded-full ${b.val >= 1 ? 'bg-[#16A34A]' : 'bg-[#0A39DF]'}`} style={{ width: `${Math.round(b.val * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function University() {
|
||||
const { data: profile } = useWorkerProfile();
|
||||
const { data: courses = [], isLoading: loadingCourses } = useCourses();
|
||||
const { data: evidence = [] } = useEvidenceList(profile?.email);
|
||||
const [search, setSearch] = useState('');
|
||||
const [diffFilter, setDiffFilter] = useState('all');
|
||||
|
||||
const completedIds = useMemo(() => new Set((profile?.completed_courses || []).map((c) => c.course_id)), [profile]);
|
||||
const activeCourses = courses.filter((c) => c.status === 'active');
|
||||
const filtered = activeCourses.filter((c) => {
|
||||
const ms = !search || c.title?.toLowerCase().includes(search.toLowerCase()) || c.category?.toLowerCase().includes(search.toLowerCase());
|
||||
const md = diffFilter === 'all' || c.difficulty === diffFilter;
|
||||
return ms && md;
|
||||
});
|
||||
const lockedGoals = activeCourses.filter((c) => c.unlock_requirements && !completedIds.has(c.id));
|
||||
const evidenceBySkill = (evidence || []).reduce((m, e) => { (m[e.skill] = m[e.skill] || []).push(e); return m; }, {});
|
||||
|
||||
if (!profile) return <div className="flex justify-center py-20"><Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" /></div>;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<CapabilityTimeline profile={profile} compact />
|
||||
|
||||
{/* Badges */}
|
||||
{profile.earned_badges?.length > 0 && (
|
||||
<section className="pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Badges with proof</h2>
|
||||
<span className="text-[11px] text-[#94A3B8]">{profile.earned_badges.length} earned</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{profile.earned_badges.map((b, i) => {
|
||||
const proofCount = (evidenceBySkill[b.name] || (evidenceBySkill[Object.keys(evidenceBySkill).find((k) => k.toLowerCase().includes((b.name || '').toLowerCase().split(' ')[0]))] || [])).length;
|
||||
return <BadgeChip key={i} name={`${b.name}${proofCount ? ` · ${proofCount} proof${proofCount > 1 ? 's' : ''}` : ''}`} level={b.level} earned_date={b.earned_date} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Locked goals */}
|
||||
{lockedGoals.length > 0 && (
|
||||
<section className="pt-8">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A] mb-3">Earn these next</h2>
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
{lockedGoals.map((c) => <LockCard key={c.id} course={c} profile={profile} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Challenges */}
|
||||
<section className="pt-8">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap mb-4">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proving Challenges</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#94A3B8]" />
|
||||
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search" className="pl-8 pr-3 h-9 text-[13px] rounded-full border border-[#E2E8F0] focus:border-[#0A39DF] focus:outline-none w-32" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[#F1F5F9] rounded-full p-1">
|
||||
{DIFFS.map((d) => (
|
||||
<button
|
||||
key={d.v}
|
||||
onClick={() => setDiffFilter(d.v)}
|
||||
className={`px-2.5 h-7 rounded-full text-[11px] font-medium transition-all ${diffFilter === d.v ? 'bg-white text-[#0A39DF] shadow-sm' : 'text-[#64748B] hover:text-[#0F172A]'}`}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loadingCourses ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">{[...Array(6)].map((_, i) => <div key={i} className="h-36 rounded-2xl bg-[#F1F5F9] animate-pulse" />)}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-[13px] text-[#94A3B8] py-10 text-center">No challenges match your filter.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filtered.map((c) => <UniversityCourseCard key={c.id} course={c} completed={completedIds.has(c.id)} profile={profile} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user