update hired page
This commit is contained in:
191
src/components/forge/ChallengeDirectory.jsx
Normal file
191
src/components/forge/ChallengeDirectory.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/forge/ForgeHeader.jsx
Normal file
44
src/components/forge/ForgeHeader.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/forge/ForgeInsight.jsx
Normal file
41
src/components/forge/ForgeInsight.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/forge/ForgeProgress.jsx
Normal file
96
src/components/forge/ForgeProgress.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
src/components/forge/NextSkill.jsx
Normal file
267
src/components/forge/NextSkill.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/forge/YourForge.jsx
Normal file
128
src/components/forge/YourForge.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/forge/challengeMeta.js
Normal file
92
src/components/forge/challengeMeta.js
Normal 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'] : [];
|
||||
}
|
||||
24
src/components/forge/index.js
Normal file
24
src/components/forge/index.js
Normal 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';
|
||||
Reference in New Issue
Block a user