update hired page

This commit is contained in:
2026-08-11 19:17:40 +05:30
parent 8a256a4de7
commit 25c4ee4a6a
23 changed files with 2921 additions and 380 deletions

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Lock, Search, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { isUnlocked } from '@/lib/provingGround';
import { DIFF_TONE, TYPE_ICON, TYPE_LABEL, typeOf } from './challengeMeta';
/**
* Every other challenge, as a directory.
*
* Rows on hairlines rather than a wall of cards: these are secondary to the
* featured skill above, and a grid of bordered tiles would compete with it. The
* controls sit on the heading line so the section needs no toolbar surface of
* its own, and the row still carries everything a card did — skill, title,
* description, difficulty, proof type, duration, XP, reward, state and reason.
*/
export function ChallengeDirectory({
courses,
results,
search,
onSearch,
difficulty,
onDifficulty,
difficulties,
loading,
filtered,
onClear,
completedIds,
profile,
}) {
return (
<section aria-labelledby="forge-directory" className="min-w-0">
<div className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h2 id="forge-directory" className="text-overline uppercase text-ink-4">
Other challenges
</h2>
<p className="mt-1 text-body-sm text-ink-3">
Practical challenges that turn a claim into verified evidence.
</p>
</div>
<div className="flex flex-col gap-x-4 gap-y-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="relative min-w-0 sm:w-52">
<Search
className="pointer-events-none absolute left-0 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4"
aria-hidden="true"
/>
<input
type="search"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder="Search challenges"
aria-label="Search challenges"
className="h-8 w-full border-0 border-b border-border bg-transparent pl-6 pr-2 text-body-sm text-ink-1
placeholder:text-ink-4 focus:border-krow-blue focus:outline-none"
/>
</div>
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label="Challenge difficulty">
{difficulties.map((d) => {
const active = d.v === difficulty;
return (
<button
key={d.v}
type="button"
role="radio"
aria-checked={active}
onClick={() => onDifficulty(d.v)}
className={cn(
'rounded text-body-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
active
? 'font-semibold text-krow-blue underline decoration-krow-blue decoration-2 underline-offset-[6px]'
: 'text-ink-3 hover:text-ink-1'
)}
>
{d.label}
</button>
);
})}
</div>
</div>
</div>
<div className="flex items-center justify-between gap-3 py-2.5">
<p className="text-caption tabular-nums text-ink-4">
{results.length} of {courses.length} challenges{filtered ? ' · filtered' : ''}
</p>
{filtered && (
<button
type="button"
onClick={onClear}
className="rounded text-caption font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
)}
</div>
{loading ? (
<div className="divide-y divide-border border-t border-border">
{[...Array(3)].map((_, i) => (
<div key={i} className="py-5">
<div className="h-10 w-full animate-pulse rounded bg-surface-sunken" />
</div>
))}
</div>
) : results.length === 0 ? (
<div className="border-t border-border py-10 text-center">
<p className="text-body-sm text-ink-3">No challenges match your filters.</p>
<button
type="button"
onClick={onClear}
className="mt-2 rounded text-body-sm font-semibold text-krow-blue hover:underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Clear filters
</button>
</div>
) : (
<ul className="divide-y divide-border border-t border-border">
{results.map((course) => {
const lock = isUnlocked(course, profile);
const completed = completedIds.has(course.id);
const locked = !lock.unlocked && !completed;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
return (
<li key={course.id}>
<Link
to={`/university/${course.id}`}
className="group grid gap-x-6 gap-y-2 py-4 transition-colors hover:bg-surface-subtle
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-krow-blue/50
lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)_auto] lg:items-baseline lg:px-2"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
{completed ? (
<ShieldCheck className="h-3.5 w-3.5 shrink-0 text-success" aria-hidden="true" />
) : locked ? (
<Lock className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden="true" />
) : (
<Icon className="h-3.5 w-3.5 shrink-0 text-ink-4" aria-hidden="true" />
)}
<span className="truncate text-overline uppercase text-ink-4">
{course.proof_skill || course.category}
</span>
</span>
<span className="mt-1 block text-body-lg font-semibold leading-snug text-ink-1 group-hover:text-krow-blue">
{course.title}
</span>
</span>
<span className="min-w-0">
<span className="block line-clamp-2 text-body-sm leading-relaxed text-ink-3 lg:line-clamp-1">
{course.description}
</span>
<span className="mt-1 block text-caption tabular-nums text-ink-4">
<span className={cn('font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
{course.difficulty}
</span>
{' · '}{TYPE_LABEL[type]}
{' · '}{course.estimated_minutes} min
{' · '}{course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</span>
{locked && lock.reasons?.length > 0 && (
<span className="mt-1 block truncate text-caption text-warning">
Requires {lock.reasons[0]}
</span>
)}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-body-sm font-semibold text-krow-blue">
{completed ? 'Review' : locked ? 'Details' : 'Start'}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react';
/**
* The Forge masthead.
*
* An editorial header rather than the marketing banner it replaces: an eyebrow
* that names the product category, the title, one line of standfirst, and the
* single action worth taking. The rule underneath is the only decoration, and
* the page reaches the actual skill work within one screen.
*/
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
return (
<header className="flex flex-col gap-4 border-b border-border pb-5 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.14em] text-krow-blue">
Workforce skill verification
</p>
<h1 className="mt-2 font-heading text-display-lg leading-none tracking-tight text-ink-1 sm:text-[2.375rem]">
KROW Forge
</h1>
<p className="mt-2.5 max-w-xl text-body-lg leading-relaxed text-ink-3">
Prove real skills through real-world challenges and build a verified workforce reputation.
</p>
</div>
{course && (
<Link
to={`/university/${course.id}`}
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
)}
</header>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { ArrowRight } from 'lucide-react';
/**
* The page's closing remark.
*
* A rule and an accent rather than a filled panel: this is commentary on
* everything above it, and commentary that arrives in its own coloured card
* claims the same weight as the work.
*/
export function ForgeInsight({ headline, detail, highlight, actionLabel, onAction }) {
return (
<aside className="border-l-2 border-krow-blue pl-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
KROW Forge insight
</p>
<p className="mt-1.5 font-heading text-body-lg font-semibold text-ink-1">{headline}</p>
<p className="mt-0.5 max-w-2xl text-body-sm leading-relaxed text-ink-3">
Next best skill: <span className="font-semibold text-ink-2">{highlight}</span>. {detail}
</p>
</div>
<button
type="button"
onClick={onAction}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 sm:self-auto"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { cn } from '@/lib/utils';
/**
* Forge progress — the four figures, and the journey they describe.
*
* The figures on their own were four disconnected numbers. Underneath them the
* same state is drawn as a single line: where the worker started, what they are
* proving now, the skill in front of them, and what is still gated. One rule,
* four nodes, no surfaces — competency progression rather than a scoreboard.
*
* Every node's state is derived from live counts by the page; this component
* only draws them.
*/
const NODE = {
done: 'border-krow-blue bg-krow-blue',
current: 'border-krow-blue bg-surface ring-4 ring-krow-blue/15',
upcoming: 'border-border bg-surface',
locked: 'border-warning/60 bg-surface',
};
const CAPTION_TONE = {
done: 'text-ink-2',
current: 'text-krow-blue',
upcoming: 'text-ink-4',
locked: 'text-warning',
};
export function ForgeProgress({ figures = [], stages = [], loading = false }) {
return (
<section aria-labelledby="forge-progress" className="border-b border-border pb-6">
<h2 id="forge-progress" className="text-overline uppercase text-ink-4">
Forge progress
</h2>
{/* The figures. */}
<dl className="mt-3.5 flex flex-wrap items-baseline gap-x-10 gap-y-4">
{figures.map((f) => (
<div key={f.label} className="min-w-0">
<dd
className={cn(
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
f.tone || 'text-ink-1'
)}
>
{loading ? <span className="inline-block h-6 w-10 animate-pulse rounded bg-surface-sunken" /> : f.value}
</dd>
<dt className="mt-1.5 text-body-sm text-ink-3">{f.label}</dt>
</div>
))}
</dl>
{/* The journey. */}
{stages.length > 0 && (
<ol className="mt-6 grid gap-y-4 sm:flex sm:items-start">
{stages.map((stage, i) => (
<li key={stage.label} className="relative flex min-w-0 flex-1 gap-3 sm:block">
{/* Connector — horizontal on desktop, vertical while stacked. */}
{i < stages.length - 1 && (
<span
className={cn(
'pointer-events-none absolute bg-border',
'left-[0.3125rem] top-4 h-[calc(100%-0.25rem)] w-px',
'sm:left-3 sm:top-[0.3125rem] sm:h-px sm:w-[calc(100%-0.75rem)]',
stage.state === 'done' && 'bg-krow-blue/40'
)}
aria-hidden="true"
/>
)}
<span
className={cn(
'relative z-10 mt-0.5 block h-2.5 w-2.5 shrink-0 rounded-full border-2 sm:mt-0',
NODE[stage.state] || NODE.upcoming
)}
aria-hidden="true"
/>
<span className="min-w-0 pb-1 sm:block sm:pr-6 sm:pt-2.5">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{stage.label}
</span>
{stage.caption && (
<span className={cn('block truncate text-caption', CAPTION_TONE[stage.state])}>
{stage.caption}
</span>
)}
</span>
</li>
))}
</ol>
)}
</section>
);
}

View File

@@ -0,0 +1,267 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, Check, Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf, proofSteps } from './challengeMeta';
/**
* The next skill to prove — the focal point of the page and its only surface.
*
* It answers the four questions a worker actually has, in order: what the skill
* is, what they will be judged on, how the proof is submitted and who grades it,
* and what is standing in the way. Divided by rules rather than nested cards, so
* it reads as one workspace instead of a stack of tiles.
*
* "What you'll prove" is the challenge's own rubric — the criteria Owliver
* scores against — so the marking scheme is visible before the attempt.
*/
/** Progress toward a threshold: pips for a small count, a bar for a score. */
function Progress({ have, need }) {
const done = Math.min(have, need);
if (need <= 8) {
return (
<span className="flex items-center gap-1" aria-hidden="true">
{Array.from({ length: need }, (_, i) => (
<span
key={i}
className={cn(
'h-2 w-2 rounded-full border',
i < done ? 'border-krow-blue bg-krow-blue' : 'border-ink-4/40 bg-transparent'
)}
/>
))}
</span>
);
}
return (
<span className="block h-1.5 w-20 overflow-hidden rounded-full bg-border" aria-hidden="true">
<span
className="block h-full rounded-full bg-krow-blue transition-[width] duration-500"
style={{ width: `${Math.round((done / need) * 100)}%` }}
/>
</span>
);
}
export function NextSkill({ course, locked, requirements = [], actionLabel, index = 1 }) {
if (!course) return null;
const type = typeOf(course);
const Icon = TYPE_ICON[type] || TYPE_ICON.roleplay;
const steps = proofSteps(course);
const outstanding = requirements.filter((r) => !r.met).length;
return (
<section
aria-labelledby="next-skill"
className="overflow-hidden rounded-xl border border-border bg-surface shadow-sm
motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300"
>
{/* Identity. */}
<div className="flex gap-5 border-b border-border p-5 sm:gap-7 sm:p-7">
<span
className="hidden font-heading text-[2.5rem] font-bold leading-none tabular-nums text-border sm:block"
aria-hidden="true"
>
{String(index).padStart(2, '0')}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<p className="text-overline uppercase tracking-[0.12em] text-krow-blue">
Next skill to prove
</p>
<span
className={cn(
'text-overline uppercase',
locked ? 'text-warning' : 'text-success'
)}
>
· {locked ? 'Locked' : 'Ready to start'}
</span>
</div>
<h2
id="next-skill"
className="mt-2 font-heading text-title-lg leading-tight tracking-tight text-ink-1 sm:text-[1.75rem]"
>
{course.title}
</h2>
<p className="mt-1.5 text-body-sm text-ink-3">
<span className="font-medium text-ink-2">{course.proof_skill || course.category}</span>
<span className="capitalize"> · {course.difficulty}</span>
</p>
<p className="mt-3 max-w-prose text-body leading-relaxed text-ink-2">
{course.description}
</p>
</div>
</div>
<div className="grid gap-px bg-border lg:grid-cols-2">
{/* What the proof is judged on. */}
{steps.length > 0 && (
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">What you’ll prove</p>
<ol className="mt-3.5">
{steps.map((step, i) => (
<li key={step} className="flex items-baseline gap-3 border-b border-border py-2.5 last:border-0 last:pb-0">
<span
className={cn(
'font-heading text-caption font-bold tabular-nums',
i === steps.length - 1 ? 'text-ink-4' : 'text-krow-blue'
)}
>
{String(i + 1).padStart(2, '0')}
</span>
<span
className={cn(
'text-body-sm leading-relaxed',
i === steps.length - 1 ? 'text-ink-3' : 'text-ink-1'
)}
>
{step}
</span>
</li>
))}
</ol>
</div>
)}
{/* How it is submitted, and who grades it. */}
<div className="bg-surface p-5 sm:p-7">
<p className="text-overline uppercase text-ink-4">How you’ll prove it</p>
<p className="mt-3.5 flex items-center gap-2.5 font-heading text-body-lg font-semibold text-ink-1">
<Icon className="h-4 w-4 shrink-0 text-krow-blue" aria-hidden="true" />
{TYPE_LABEL[type]}
</p>
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
{/* Your action → Owliver → result. The existing evaluation relationship,
drawn as a sequence rather than three tiles. */}
<ol className="mt-5 space-y-0">
{[
{
key: 'action',
label: 'Your action',
value: TYPE_LABEL[type],
mark: <span className="h-2 w-2 rounded-full bg-krow-blue" aria-hidden="true" />,
},
{
key: 'owliver',
label: 'Owliver',
value: 'Evaluates your proof against the criteria',
mark: <OwliverAvatar className="h-6 w-6" rounded="rounded-full" />,
},
{
key: 'result',
label: 'Result',
value: course.badge_reward
? `Skill verified · earns ${course.badge_reward}`
: 'Skill verified on your workforce profile',
mark: (
<span className="grid h-4 w-4 place-items-center rounded-full bg-success text-white">
<Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />
</span>
),
},
].map((row, i, all) => (
<li key={row.key} className="relative flex gap-3 pb-4 last:pb-0">
{i < all.length - 1 && (
<span
className="pointer-events-none absolute left-[0.6875rem] top-5 bottom-0 w-px bg-border"
aria-hidden="true"
/>
)}
<span className="relative z-10 grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface">
{row.mark}
</span>
<span className="min-w-0 pt-0.5">
<span className="block text-overline uppercase text-ink-4">{row.label}</span>
<span className="block text-body-sm text-ink-2">{row.value}</span>
</span>
</li>
))}
</ol>
</div>
</div>
{/* What is in the way. */}
{locked && requirements.length > 0 && (
<div className="border-t border-border bg-warning-muted/40 px-5 py-5 sm:px-7">
<p className="flex items-center gap-2 text-overline uppercase text-warning">
<Lock className="h-3 w-3" aria-hidden="true" />
Locked for now
</p>
<p className="mt-1.5 text-body-sm text-ink-2">
{outstanding === 1
? 'One requirement left to unlock this challenge.'
: `${outstanding} requirements left to unlock this challenge.`}
</p>
{/* Capped: at full shell width the label and its meter would sit a
thousand pixels apart and stop reading as one row. */}
<ul className="mt-3.5 max-w-3xl space-y-2.5">
{requirements.map((r) => (
<li key={r.key} className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span
className={cn(
'grid h-4 w-4 shrink-0 place-items-center rounded-full',
r.met ? 'bg-success text-white' : 'border border-warning/60'
)}
>
{r.met && <Check className="h-2.5 w-2.5" strokeWidth={3} aria-hidden="true" />}
</span>
<span
className={cn(
'min-w-0 flex-1 basis-[calc(100%-1.75rem)] text-body-sm sm:basis-0',
r.met ? 'text-ink-3' : 'text-ink-2'
)}
>
{r.label}
</span>
{r.need != null && (
<span className="flex items-center gap-3 pl-7 sm:pl-0">
<span className="text-caption font-semibold tabular-nums text-ink-3">
{Math.min(r.have, r.need)} / {r.need} {r.unit}
</span>
<Progress have={r.have} need={r.need} />
</span>
)}
</li>
))}
</ul>
</div>
)}
{/* Quiet metadata, and the action. */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4 sm:px-7">
<p className="text-caption tabular-nums text-ink-3">
{course.estimated_minutes} min · {course.xp} XP
{course.badge_reward && ` · Earns ${course.badge_reward}`}
</p>
<Link
to={`/university/${course.id}`}
className="group inline-flex items-center gap-1.5 rounded-lg bg-krow-blue px-4 py-2.5 text-body-sm font-semibold text-white
transition-colors hover:bg-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
{actionLabel}
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { ArrowRight, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Your Forge — the record, or the process that creates it.
*
* With nothing verified yet this used to be the largest and emptiest object on
* the page. Here the empty state is the thing worth explaining instead: the four
* steps that turn a challenge into a verified skill, laid along one line. It is
* a process, not a placeholder, and it takes a fifth of the height.
*
* Once skills exist the same band lists them, so the section grows into a record
* rather than being swapped for a different component.
*/
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
export function YourForge({ skills = [], evidenceCount = 0, onBrowse, formatDate }) {
const empty = skills.length === 0;
return (
<section aria-labelledby="your-forge" className="border-b border-border pb-6">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 id="your-forge" className="text-overline uppercase text-ink-4">
Your Forge
</h2>
<p className="text-caption text-ink-4">
{empty
? 'Your first verified skill starts with a real-world challenge.'
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
</p>
</div>
<p className="mt-2 font-heading text-title text-ink-1">
<span className="tabular-nums">{skills.length}</span>{' '}
{skills.length === 1 ? 'verified skill' : 'verified skills'}
</p>
{empty ? (
/* How a skill becomes verified — one line, four steps. */
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:gap-0">
<ol className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{STEPS.map((step, i) => (
<li key={step} className="flex min-w-0 items-center gap-3 sm:flex-1">
<span className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
'grid h-6 w-6 shrink-0 place-items-center rounded-full border text-caption font-semibold tabular-nums',
i === 0
? 'border-krow-blue bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-4'
)}
>
{i + 1}
</span>
<span
className={cn(
'truncate text-body-sm',
i === 0 ? 'font-semibold text-ink-1' : 'text-ink-3'
)}
>
{step}
</span>
</span>
{i < STEPS.length - 1 && (
<span
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
aria-hidden="true"
/>
)}
</li>
))}
</ol>
<button
type="button"
onClick={onBrowse}
className="group inline-flex shrink-0 items-center gap-1.5 self-start rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50 lg:self-auto lg:pl-6"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</div>
) : (
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
{skills.map((badge, i) => (
<li key={badge.name || i} className="flex min-w-0 items-center gap-2.5">
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-success text-white">
<Check className="h-3 w-3" strokeWidth={3} aria-hidden="true" />
</span>
<span className="min-w-0">
<span className="block truncate text-body-sm font-semibold text-ink-1">
{badge.name}
</span>
<span className="block truncate text-caption capitalize text-ink-4">
{badge.level || 'Standard'} · verified {formatDate?.(badge.earned_date)}
</span>
</span>
</li>
))}
<li>
<button
type="button"
onClick={onBrowse}
className="group inline-flex items-center gap-1.5 rounded text-body-sm font-semibold text-krow-blue
transition-colors hover:text-krow-blue-dark
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
Browse challenges
<ArrowRight
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</button>
</li>
</ul>
)}
</section>
);
}

View File

@@ -0,0 +1,92 @@
import { Camera, MessageSquare, ScanLine, Video } from 'lucide-react';
/**
* Presentation lookups for a challenge, and the one piece of derived logic the
* Forge page needs that the data layer does not already provide.
*
* Nothing here invents information: the proof wording matches what the challenge
* page already says, and the requirement lines are read straight off
* `course.unlock_requirements` and measured against the live profile — the same
* fields `isUnlocked()` gates on.
*/
export const TYPE_ICON = {
roleplay: MessageSquare,
photo: Camera,
video: Video,
photo_identify: ScanLine,
};
export const TYPE_LABEL = {
roleplay: 'Chat with Owliver',
photo: 'Photo proof',
video: 'Record yourself',
photo_identify: 'Identify hazards',
};
export const TYPE_HINT = {
roleplay: 'Owliver asks, you answer in a short conversation.',
photo: 'Upload a photo of your work.',
video: 'Record a short clip — Owliver grades your technique.',
photo_identify: 'Tap hazards on a photo — Owliver judges what you found.',
};
export const typeOf = (course) => course?.challenge?.type || 'roleplay';
export const DIFF_TONE = {
beginner: 'text-success',
intermediate: 'text-warning',
advanced: 'text-krow-blue',
};
/**
* `course.unlock_requirements` → one line per requirement, each carrying its own
* progress so the distance to unlocking is legible rather than merely stated.
* Returns [] when a challenge has no gate.
*/
export function buildRequirements(course, profile = {}) {
const req = course?.unlock_requirements;
if (!req) return [];
const lines = [];
if (req.min_shifts) {
const have = Number(profile.shifts_completed) || 0;
lines.push({
key: 'shifts',
label: `Complete ${req.min_shifts} shifts`,
unit: 'shifts',
have,
need: req.min_shifts,
met: have >= req.min_shifts,
});
}
if (req.min_reliability) {
const have = Number(profile.reliability_score) || 0;
lines.push({
key: 'reliability',
label: `Reach a reliability score of ${req.min_reliability}`,
unit: 'score',
have,
need: req.min_reliability,
met: have >= req.min_reliability,
});
}
(req.required_badges || []).forEach((badge) => {
lines.push({
key: `badge-${badge}`,
label: `Earn the ${badge} badge`,
met: (profile.earned_badges || []).some((b) => b.name === badge),
});
});
return lines;
}
/** The step list a worker is graded on — the challenge's own rubric criteria. */
export function proofSteps(course) {
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
return criteria.length ? [...criteria, 'Submit your proof'] : [];
}

View File

@@ -0,0 +1,24 @@
/**
* Forge components — the pieces the KROW Forge workspace is composed from.
*
* The page tells one story and each part has a different shape, so the reader is
* never looking at the same rectangle twice:
*
* ForgeHeader editorial masthead
* ForgeProgress four figures, and the journey they describe
* YourForge the record, or the process that creates it
* NextSkill the one surface on the page: the skill to prove now
* ChallengeDirectory everything else, as rows on hairlines
* ForgeInsight a rule and a remark
*
* All state, data and the unlock gate stay with the page; these only arrange it.
*/
export { ForgeHeader } from './ForgeHeader';
export { ForgeProgress } from './ForgeProgress';
export { YourForge } from './YourForge';
export { NextSkill } from './NextSkill';
export { ChallengeDirectory } from './ChallengeDirectory';
export { ForgeInsight } from './ForgeInsight';
export {
buildRequirements, DIFF_TONE, proofSteps, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
} from './challengeMeta';