docs(milestone): complete v0.1-nextcraft-ui-prototype
---ci--- phase: 7 milestone: v0.1 status: complete requirements: covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028] partial: [] ---/ci--- Milestone v0.1 (nextcraft-ui-prototype) complete. Summary: - 7 phases (P0 pre-execution + P1-P6 execution + P7 final review) - 28 requirements covered (all complete) - 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4) - 19 routes, 104 TypeScript files - Shared component library (5 primitives + design tokens) - Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers - Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font - Storybook with 6 stories - Dark mode, breadcrumbs, role switcher, responsive design - Build passes, typecheck passes, Storybook build passes Phases: P0 pre-execution → v0.0.1 P1 project-scaffolding → v0.0.2 P2 learner-surface → v0.0.3 P3 marketplace-surface → v0.0.4 P4 employer-dashboard → v0.0.5 P5 admin-surface → v0.0.6 P6 polish-integration → v0.0.7 P7 final-review-ship → v0.1.0 (milestone release)
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search, Filter, FileCode, ShieldCheck, Target } from 'lucide-react';
|
||||
import { Avatar, Badge } from '@nextcraft/ui';
|
||||
import type { Candidate } from '@nextcraft/types';
|
||||
import { matchBadgeClasses } from '../../lib/format';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Filter config */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const STACK_OPTIONS = [
|
||||
{ value: 'all', label: 'All stacks' },
|
||||
{ value: 'stack-orchestration', label: 'AI Orchestration' },
|
||||
{ value: 'stack-safety', label: 'AI Safety' },
|
||||
{ value: 'stack-designer', label: 'Product Design' },
|
||||
{ value: 'stack-operator', label: 'Field Operator' },
|
||||
{ value: 'stack-science', label: 'Comp Sciences' },
|
||||
];
|
||||
|
||||
const DEFENSE_OPTIONS = [
|
||||
{ value: 0, label: 'Any defense score' },
|
||||
{ value: 70, label: 'Defense 70+' },
|
||||
{ value: 80, label: 'Defense 80+' },
|
||||
{ value: 90, label: 'Defense 90+' },
|
||||
];
|
||||
|
||||
const ARTIFACT_OPTIONS = [
|
||||
{ value: 0, label: 'Any artifacts' },
|
||||
{ value: 1, label: '1+ artifacts' },
|
||||
{ value: 3, label: '3+ artifacts' },
|
||||
{ value: 5, label: '5+ artifacts' },
|
||||
{ value: 10, label: '10+ artifacts' },
|
||||
];
|
||||
|
||||
/** Derive 3 short microcredential badges from a candidate's stack + count. */
|
||||
function microcredentialBadges(cand: Candidate): string[] {
|
||||
const stackMap: Record<string, string[]> = {
|
||||
'stack-orchestration': ['Agent Arch', 'Tool Use', 'RAG', 'Eval', 'Routing'],
|
||||
'stack-safety': ['Red Team', 'Model Cards', 'Bias Audit', 'Policy', 'Interp'],
|
||||
'stack-designer': ['Agentic UX', 'Conv UX', 'Transparency', 'Trust', 'Persona'],
|
||||
'stack-operator': ['Robotics', 'Vision', 'Calibration', 'AR', 'Field Data'],
|
||||
'stack-science': ['Sci Python', 'HPC', 'PINNs', 'Active Learn', 'Bioinformatics'],
|
||||
};
|
||||
const pool = stackMap[cand.competencyStackId] ?? ['MC-1', 'MC-2', 'MC-3'];
|
||||
// Spread selection deterministically based on id suffix.
|
||||
const offset = Number(cand.id.replace(/\D/g, '')) % pool.length;
|
||||
return [pool[offset % pool.length], pool[(offset + 1) % pool.length], pool[(offset + 2) % pool.length]];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface TalentSearchProps {
|
||||
candidates: Candidate[];
|
||||
}
|
||||
|
||||
export function TalentSearch({ candidates }: TalentSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [stack, setStack] = useState('all');
|
||||
const [minDefense, setMinDefense] = useState(0);
|
||||
const [minArtifacts, setMinArtifacts] = useState(0);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return candidates.filter((c) => {
|
||||
if (q) {
|
||||
const hay = `${c.name} ${c.headline}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
if (stack !== 'all' && c.competencyStackId !== stack) return false;
|
||||
if (c.defenseScore < minDefense) return false;
|
||||
if (c.artifactCount < minArtifacts) return false;
|
||||
return true;
|
||||
});
|
||||
}, [candidates, query, stack, minDefense, minArtifacts]);
|
||||
|
||||
const selectClass =
|
||||
'h-9 rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Filter bar */}
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="relative flex items-center">
|
||||
<Search className="pointer-events-none absolute left-3 h-4 w-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search candidates by name or headline..."
|
||||
aria-label="Search candidates"
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
<Filter className="h-3.5 w-3.5" /> Filters:
|
||||
</span>
|
||||
<select
|
||||
aria-label="Competency stack"
|
||||
value={stack}
|
||||
onChange={(e) => setStack(e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{STACK_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Minimum defense score"
|
||||
value={minDefense}
|
||||
onChange={(e) => setMinDefense(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{DEFENSE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Minimum artifact count"
|
||||
value={minArtifacts}
|
||||
onChange={(e) => setMinArtifacts(Number(e.target.value))}
|
||||
className={selectClass}
|
||||
>
|
||||
{ARTIFACT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="ml-auto text-sm text-slate-500 dark:text-slate-400">
|
||||
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of{' '}
|
||||
{candidates.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Candidate grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No candidates match your filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((cand) => (
|
||||
<CandidateCard key={cand.id} cand={cand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateCard({ cand }: { cand: Candidate }) {
|
||||
const badges = microcredentialBadges(cand);
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar name={cand.name} src={cand.avatar} size="md" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{cand.name}
|
||||
</h3>
|
||||
<p className="truncate text-sm text-slate-600 dark:text-slate-400">{cand.headline}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex h-9 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
|
||||
cand.matchScore,
|
||||
)}`}
|
||||
title="AI match score"
|
||||
>
|
||||
{cand.matchScore}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{badges.map((b) => (
|
||||
<Badge key={b} variant="info">
|
||||
{b}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<FileCode className="h-3.5 w-3.5" /> {cand.artifactCount} artifacts
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ShieldCheck className="h-3.5 w-3.5" /> Defense: {cand.defenseScore}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Target className="h-3.5 w-3.5" /> Match: {cand.matchScore}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/employer/talent/${cand.id}`}
|
||||
className="mt-auto inline-flex h-9 w-full items-center justify-center rounded-md border border-slate-300 bg-transparent text-sm font-medium text-slate-800 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export icon used in card stats so the import is not tree-shaken away.
|
||||
Reference in New Issue
Block a user