update ui design for all pages
This commit is contained in:
522
src/components/forge/AddSkillTraining.jsx
Normal file
522
src/components/forge/AddSkillTraining.jsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import React from 'react';
|
||||
import { Check, Plus, Sparkles, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button, Field, Input, Modal, Textarea, toast } from '@/components/ds';
|
||||
import { TYPE_HINT, TYPE_LABEL } from './challengeMeta';
|
||||
|
||||
/**
|
||||
* Authoring a workforce skill, in the order the concept runs:
|
||||
*
|
||||
* define the skill → build the training → define the proof →
|
||||
* configure Owliver → review and publish
|
||||
*
|
||||
* The wizard writes one Course record, because that is what a skill already is
|
||||
* in this system: the skill fields, the training, the proof type and the rubric
|
||||
* Owliver scores against all live on the same entity. Nothing here creates a
|
||||
* parallel store, and nothing is written until the last step is confirmed.
|
||||
*
|
||||
* Every capability offered is one the application actually has. There is no file
|
||||
* upload step, because there is no upload for training material to attach to —
|
||||
* offering one would be a button that lies.
|
||||
*/
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'skill', title: 'Define the skill', hint: 'What the workforce must be able to do.' },
|
||||
{ key: 'training', title: 'Build training', hint: 'What they learn before they are tested.' },
|
||||
{ key: 'proof', title: 'Define the proof', hint: 'How they demonstrate it.' },
|
||||
{ key: 'owliver', title: 'Configure Owliver', hint: 'What the evaluation checks.' },
|
||||
{ key: 'review', title: 'Review & publish', hint: 'Check it, then decide.' },
|
||||
];
|
||||
|
||||
/** Proof methods, each one a challenge type the challenge runner can actually run. */
|
||||
const PROOF_TYPES = ['roleplay', 'video', 'photo', 'photo_identify'];
|
||||
|
||||
const DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
|
||||
|
||||
const EMPTY = {
|
||||
title: '',
|
||||
category: '',
|
||||
difficulty: 'beginner',
|
||||
description: '',
|
||||
proof_skill: '',
|
||||
outline: [''],
|
||||
estimated_minutes: '15',
|
||||
quiz: false,
|
||||
challengeType: 'roleplay',
|
||||
prompt: '',
|
||||
criteria: ['', '', ''],
|
||||
pass_score: '70',
|
||||
badge_reward: '',
|
||||
};
|
||||
|
||||
export function AddSkillTraining({
|
||||
open, onOpenChange, categories = [], onCreate, onUpdate, busy, prefill, startStep, courseId,
|
||||
}) {
|
||||
const [step, setStep] = React.useState(0);
|
||||
const [form, setForm] = React.useState(EMPTY);
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
|
||||
/* `courseId` turns this from authoring into editing: the flow saves back to
|
||||
the skill it was opened from rather than writing a second one. */
|
||||
const editing = Boolean(courseId);
|
||||
|
||||
/* Owliver can open this with fields already answered, and on the step the
|
||||
request was about. Resetting on open is what keeps a second creation from
|
||||
inheriting the first one's answers. */
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const index = STEPS.findIndex((s) => s.key === startStep);
|
||||
setStep(index > 0 ? index : 0);
|
||||
setConfirming(false);
|
||||
setForm({ ...EMPTY, ...(prefill || {}) });
|
||||
}, [open, prefill, startStep]);
|
||||
|
||||
const set = (patch) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
const criteria = form.criteria.map((c) => c.trim()).filter(Boolean);
|
||||
const outline = form.outline.map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
/* Each step names what it still needs, so "Next" is never disabled without
|
||||
the reader being told which field is missing. */
|
||||
const missing = [
|
||||
!form.title.trim() && 'a skill name',
|
||||
!form.category.trim() && 'a category',
|
||||
!form.description.trim() && 'a description',
|
||||
].filter(Boolean);
|
||||
|
||||
const canAdvance = step === 0 ? missing.length === 0 : true;
|
||||
|
||||
/**
|
||||
* The Course record this wizard describes.
|
||||
*
|
||||
* On an edit only the fields the flow owns are sent — the entity merges, so
|
||||
* anything the flow does not ask about (a quiz written elsewhere, the original
|
||||
* created date, XP) survives rather than being blanked by omission.
|
||||
*/
|
||||
const build = (status) => ({
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
category: form.category.trim(),
|
||||
difficulty: form.difficulty,
|
||||
proof_skill: form.proof_skill.trim() || form.title.trim(),
|
||||
estimated_minutes: Number(form.estimated_minutes) || 0,
|
||||
training_outline: outline,
|
||||
challenge: {
|
||||
type: form.challengeType,
|
||||
prompt: form.prompt.trim(),
|
||||
rubric: criteria.length
|
||||
? criteria.map((criterion) => ({
|
||||
criterion,
|
||||
/* Even weights unless the author says otherwise — the last one
|
||||
absorbs the rounding so the rubric always totals 100. */
|
||||
weight: Math.round(100 / criteria.length),
|
||||
}))
|
||||
: [],
|
||||
},
|
||||
pass_score: Number(form.pass_score) || 70,
|
||||
badge_reward: form.badge_reward.trim(),
|
||||
status,
|
||||
...(editing ? {} : {
|
||||
quiz: [],
|
||||
unlock_requirements: { min_shifts: 0, min_reliability: 0, required_badges: [] },
|
||||
xp: 0,
|
||||
created_date: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const submit = async (status) => {
|
||||
const name = form.title.trim() || 'The skill';
|
||||
try {
|
||||
if (editing) {
|
||||
await onUpdate(courseId, build(status));
|
||||
toast.success(
|
||||
status === 'active' ? `${name} is updated and published.` : `${name} is saved as a draft.`
|
||||
);
|
||||
} else {
|
||||
await onCreate(build(status));
|
||||
toast.success(
|
||||
status === 'active'
|
||||
? `${name} is published to the workforce.`
|
||||
: `${name} is saved as a draft.`
|
||||
);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error('That could not be saved. Try again in a moment.');
|
||||
}
|
||||
};
|
||||
|
||||
const current = STEPS[step];
|
||||
const isLast = step === STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
size="lg"
|
||||
busy={busy}
|
||||
icon={Sparkles}
|
||||
title={editing ? `Edit ${form.title.trim() || 'skill training'}` : 'Add Skill Training'}
|
||||
description={`Step ${step + 1} of ${STEPS.length} — ${current.title}. ${current.hint}`}
|
||||
footer={(
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => (step === 0 ? onOpenChange(false) : setStep(step - 1))}
|
||||
disabled={busy}
|
||||
>
|
||||
{step === 0 ? 'Cancel' : 'Back'}
|
||||
</Button>
|
||||
|
||||
{isLast ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => submit('draft')} disabled={busy}>
|
||||
Save draft
|
||||
</Button>
|
||||
{/* Publishing puts a skill in front of the workforce, so it asks
|
||||
first rather than firing on the same click that reveals it. */}
|
||||
{confirming ? (
|
||||
<Button size="sm" onClick={() => submit('active')} disabled={busy}>
|
||||
<Check aria-hidden="true" />
|
||||
{editing ? 'Yes, save and publish' : 'Yes, publish it'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setConfirming(true)} disabled={busy}>
|
||||
{editing ? 'Save & publish' : 'Publish skill'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setStep(step + 1)} disabled={!canAdvance}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Stepper step={step} onStep={setStep} />
|
||||
|
||||
{step === 0 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Skill name" required hint="What the workforce must be able to do.">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => set({ title: e.target.value })}
|
||||
placeholder="Knife Skills — Basic Cuts"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Category" required hint={categories.length ? 'Reuse one, or name a new one.' : undefined}>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) => set({ category: e.target.value })}
|
||||
placeholder="Kitchen"
|
||||
list="forge-categories"
|
||||
/>
|
||||
</Field>
|
||||
<datalist id="forge-categories">
|
||||
{categories.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
|
||||
<Field label="Difficulty">
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup" aria-label="Difficulty">
|
||||
{DIFFICULTIES.map((d) => (
|
||||
<Choice
|
||||
key={d}
|
||||
active={form.difficulty === d}
|
||||
onClick={() => set({ difficulty: d })}
|
||||
className="capitalize"
|
||||
>
|
||||
{d}
|
||||
</Choice>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Description" required hint="One or two sentences a worker would recognise the job in.">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => set({ description: e.target.value })}
|
||||
placeholder="Learn safe and consistent basic knife techniques used in professional kitchens."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Verified skill name" hint="The capability this writes onto a worker's profile. Defaults to the skill name.">
|
||||
<Input
|
||||
value={form.proof_skill}
|
||||
onChange={(e) => set({ proof_skill: e.target.value })}
|
||||
placeholder="Basic Knife Cuts"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{missing.length > 0 && (
|
||||
<p className="text-caption text-ink-4">
|
||||
Still needs {missing.join(', ')}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<ListBuilder
|
||||
label="Training steps"
|
||||
hint="What the workforce is taught, in order."
|
||||
placeholder="Knife grip"
|
||||
values={form.outline}
|
||||
onChange={(outline) => set({ outline })}
|
||||
addLabel="Add step"
|
||||
/>
|
||||
|
||||
<Field label="Estimated duration" hint="Minutes, as shown on the skill card.">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={form.estimated_minutes}
|
||||
onChange={(e) => set({ estimated_minutes: e.target.value })}
|
||||
className="max-w-[9rem]"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<p className="rounded-lg border border-border bg-surface-subtle p-3 text-caption leading-relaxed text-ink-3">
|
||||
Training in Forge is written instruction plus the practice challenge on the next
|
||||
step. Video and attachments are not part of this deployment, so they are not
|
||||
offered here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<Field label="Proof method" hint="How a worker demonstrates the skill. Each one is graded by Owliver.">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROOF_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => set({ challengeType: t })}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
form.challengeType === t
|
||||
? 'border-krow-blue bg-krow-blue-tint/60'
|
||||
: 'border-border hover:bg-surface-subtle'
|
||||
)}
|
||||
>
|
||||
<span className="block text-body-sm font-semibold text-ink-1">{TYPE_LABEL[t]}</span>
|
||||
<span className="mt-0.5 block text-caption leading-relaxed text-ink-3">{TYPE_HINT[t]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="The task"
|
||||
hint="What you are asking the worker to do. This is what they read when the challenge opens."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
placeholder="Record yourself preparing a julienne, a dice and a chop from one onion."
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-krow-blue/30 bg-krow-blue-tint/40 p-3">
|
||||
<p className="inline-flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-krow-blue">
|
||||
<Sparkles className="h-3 w-3" aria-hidden="true" />
|
||||
Owliver evaluates
|
||||
</p>
|
||||
<p className="mt-1 text-caption leading-relaxed text-ink-3">
|
||||
Each line is scored against the submission. Write them as things you could point at
|
||||
in a video — “safe hand position”, not “does it well”.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ListBuilder
|
||||
label="Evaluation criteria"
|
||||
placeholder="Safe hand position"
|
||||
values={form.criteria}
|
||||
onChange={(c) => set({ criteria: c })}
|
||||
addLabel="Add criterion"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Pass score" hint="The score at or above which the skill is verified.">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={form.pass_score}
|
||||
onChange={(e) => set({ pass_score: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Badge awarded" hint="Optional. Written onto the profile on a pass.">
|
||||
<Input
|
||||
value={form.badge_reward}
|
||||
onChange={(e) => set({ badge_reward: e.target.value })}
|
||||
placeholder="Knife Certified"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<Review label="Skill" value={form.title.trim() || '—'} meta={[form.category, form.difficulty].filter(Boolean).join(' · ')} />
|
||||
<Review
|
||||
label="Training"
|
||||
value={outline.length ? `${outline.length} ${outline.length === 1 ? 'step' : 'steps'}` : 'No written outline'}
|
||||
meta={outline.join(' · ')}
|
||||
/>
|
||||
<Review label="Proof method" value={TYPE_LABEL[form.challengeType]} meta={form.prompt.trim()} />
|
||||
<Review
|
||||
label="Owliver evaluation"
|
||||
value={criteria.length ? `${criteria.length} ${criteria.length === 1 ? 'criterion' : 'criteria'}` : 'None set'}
|
||||
meta={criteria.join(' · ')}
|
||||
/>
|
||||
<Review
|
||||
label="Verification"
|
||||
value={`Passes at ${Number(form.pass_score) || 70}`}
|
||||
meta={form.badge_reward.trim() ? `Earns ${form.badge_reward.trim()}` : 'No badge'}
|
||||
/>
|
||||
|
||||
{confirming ? (
|
||||
<p className="rounded-lg border border-warning/40 bg-warning-muted/40 p-3 text-body-sm leading-relaxed text-ink-2">
|
||||
Publishing {editing ? 'keeps' : 'puts'} <span className="font-semibold">{form.title.trim() || 'this skill'}</span>
|
||||
{' '}in front of the workforce straight away. Confirm below, or save it as a draft instead.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-caption leading-relaxed text-ink-4">
|
||||
Nothing is saved until you choose. A draft stays out of the workforce's view until
|
||||
you publish it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Pieces ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Where the reader is, and how to step back to anything already answered. */
|
||||
function Stepper({ step, onStep }) {
|
||||
return (
|
||||
<ol className="mb-5 flex flex-wrap items-center gap-x-1.5 gap-y-2">
|
||||
{STEPS.map((s, i) => {
|
||||
const done = i < step;
|
||||
const active = i === step;
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => i <= step && onStep(i)}
|
||||
disabled={i > step}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-caption font-semibold transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active ? 'bg-krow-blue text-white'
|
||||
: done ? 'bg-krow-blue-tint text-krow-blue hover:bg-krow-blue-tint/70'
|
||||
: 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
<span className="tabular-nums">{done ? <Check className="h-3 w-3" aria-hidden="true" /> : i + 1}</span>
|
||||
<span className="hidden sm:inline">{s.title}</span>
|
||||
</button>
|
||||
{i < STEPS.length - 1 && (
|
||||
<span className="h-px w-3 bg-border sm:w-4" aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** One option in a small single-choice row. */
|
||||
function Choice({ active, onClick, className, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-body-sm transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50',
|
||||
active
|
||||
? 'border-krow-blue bg-krow-blue-tint/60 font-semibold text-krow-blue'
|
||||
: 'border-border text-ink-2 hover:bg-surface-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A growable list of short text lines — training steps, evaluation criteria. */
|
||||
function ListBuilder({ label, hint, placeholder, values, onChange, addLabel }) {
|
||||
const setAt = (i, v) => onChange(values.map((x, j) => (j === i ? v : x)));
|
||||
const removeAt = (i) => onChange(values.length === 1 ? [''] : values.filter((_, j) => j !== i));
|
||||
|
||||
return (
|
||||
<Field label={label} hint={hint}>
|
||||
<div className="space-y-2">
|
||||
{values.map((value, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-surface-sunken text-[10px] font-bold tabular-nums text-ink-3">
|
||||
{i + 1}
|
||||
</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setAt(i, e.target.value)}
|
||||
placeholder={i === 0 ? placeholder : ''}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAt(i)}
|
||||
aria-label={`Remove line ${i + 1}`}
|
||||
className="grid h-7 w-7 shrink-0 place-items-center rounded-lg text-ink-4 transition-colors hover:bg-surface-sunken hover:text-ink-1
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...values, ''])}
|
||||
className="inline-flex items-center gap-1.5 rounded text-caption font-semibold text-krow-blue hover:underline
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
<Plus className="h-3 w-3" aria-hidden="true" />
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line of the final read-through. */
|
||||
function Review({ label, value, meta }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-border pb-3 last:border-0">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{label}</p>
|
||||
<p className="mt-0.5 text-body-sm font-semibold text-ink-1">{value}</p>
|
||||
{meta && <p className="mt-0.5 text-caption leading-relaxed text-ink-3">{meta}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Plus, Sparkles } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The Forge masthead.
|
||||
* The Forge masthead, written for the person who builds the library.
|
||||
*
|
||||
* 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.
|
||||
* An administrator does not complete challenges — they define what the workforce
|
||||
* must be able to do, and what counts as proof of it. So the header states the
|
||||
* job (create skills workers can learn, practise and prove) and offers the two
|
||||
* things that job starts with: author a skill, or read the ones already there.
|
||||
*
|
||||
* The employee-facing "continue challenge" call belongs to the challenge page,
|
||||
* where a worker actually is one; putting it here made the console read as
|
||||
* somebody's personal training dashboard.
|
||||
*/
|
||||
export function ForgeHeader({ course, actionLabel = 'Continue challenge' }) {
|
||||
export function ForgeHeader({ onCreate }) {
|
||||
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]">
|
||||
<header className="flex flex-col gap-4 border-b border-border/60 pb-6 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
Workforce Skill Verification
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-heading text-display-lg font-bold leading-none tracking-tight text-ink-1 sm:text-[2.25rem]">
|
||||
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 className="max-w-xl text-body leading-relaxed text-ink-3">
|
||||
Create skills your workforce can learn, practice and prove. Build practical training
|
||||
and evidence-based challenges that turn real-world work into verified workforce skills.
|
||||
</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"
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 self-start sm:self-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-body-sm font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
|
||||
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>
|
||||
)}
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>Add Skill Training</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { ArrowRight, Sparkles } 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.
|
||||
* Forge Insight — AI Career Insight Card
|
||||
*/
|
||||
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}
|
||||
<aside className="relative overflow-hidden rounded-2xl border border-blue-500/30 bg-gradient-to-r from-blue-50/80 via-surface to-indigo-50/30 dark:from-blue-950/40 dark:via-surface dark:to-indigo-950/30 p-5 shadow-xs">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-blue-600 dark:text-blue-400">
|
||||
KROW Forge Insight
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-heading text-body-lg font-bold text-ink-1">{headline}</p>
|
||||
<p className="max-w-2xl text-body-sm leading-relaxed text-ink-3">
|
||||
Next best skill: <span className="font-semibold text-blue-600 dark:text-blue-400">{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"
|
||||
className="group inline-flex shrink-0 items-center gap-2 self-start rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98] sm:self-auto"
|
||||
>
|
||||
{actionLabel}
|
||||
<span>{actionLabel}</span>
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -1,96 +1,110 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Layers, ShieldCheck, Target } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Forge progress — the four figures, and the journey they describe.
|
||||
* Skill overview — the state of the library, in four figures.
|
||||
*
|
||||
* 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.
|
||||
* Each figure counts records that exist: skills in the library, skills carrying
|
||||
* training material, skills carrying a challenge, and verifications the
|
||||
* workforce has actually earned. Nothing here is a target or a projection, so
|
||||
* there is nothing on this strip that cannot be traced back to a row.
|
||||
*/
|
||||
|
||||
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 STAT_CONFIG = {
|
||||
'Active skills': {
|
||||
icon: Layers,
|
||||
bg: 'bg-blue-50 text-blue-600 dark:bg-blue-950/60 dark:text-blue-400',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-200/50 dark:border-blue-800/40 hover:border-blue-400',
|
||||
accent: 'from-blue-600 to-indigo-600',
|
||||
},
|
||||
'Training modules': {
|
||||
icon: BookOpen,
|
||||
bg: 'bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400',
|
||||
text: 'text-indigo-600 dark:text-indigo-400',
|
||||
border: 'border-indigo-200/50 dark:border-indigo-800/40 hover:border-indigo-400',
|
||||
accent: 'from-indigo-500 to-purple-500',
|
||||
},
|
||||
Challenges: {
|
||||
icon: Target,
|
||||
bg: 'bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-200/50 dark:border-amber-800/40 hover:border-amber-400',
|
||||
accent: 'from-amber-500 to-orange-500',
|
||||
},
|
||||
'Verified skills': {
|
||||
icon: ShieldCheck,
|
||||
bg: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-200/50 dark:border-emerald-800/40 hover:border-emerald-400',
|
||||
accent: 'from-emerald-500 to-teal-500',
|
||||
},
|
||||
};
|
||||
|
||||
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 }) {
|
||||
export function ForgeProgress({ figures = [], 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>
|
||||
<section aria-labelledby="forge-progress" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-2 w-2 rounded-full bg-blue-600" />
|
||||
<h2 id="forge-progress" className="font-heading text-caption font-bold uppercase tracking-wider text-ink-4">
|
||||
Skill Overview
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
{/* 4 Modern Stat Cards */}
|
||||
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
|
||||
{figures.map((f) => {
|
||||
const cfg = STAT_CONFIG[f.label] || STAT_CONFIG['Active skills'];
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={f.label}
|
||||
className={cn(
|
||||
'font-heading text-[1.75rem] font-bold leading-none tabular-nums',
|
||||
f.tone || 'text-ink-1'
|
||||
'group relative flex flex-col justify-between rounded-xl border bg-surface-subtle/50 p-4 shadow-xs backdrop-blur-xs transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:bg-surface',
|
||||
cfg.border
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
{/* Wraps rather than truncates: "Training modules" does not
|
||||
fit one line in a two-up mobile grid, and a label reading
|
||||
"TRAINING …" tells the reader nothing. */}
|
||||
<span className="min-w-0 text-[10px] font-bold uppercase leading-tight tracking-wider text-ink-4">
|
||||
{f.label}
|
||||
</span>
|
||||
<div className={cn('flex h-7 w-7 shrink-0 items-center justify-center rounded-lg shadow-xs', cfg.bg)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-2.5 h-8 w-16 animate-pulse rounded-lg bg-surface-sunken" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-1.5">
|
||||
<span className={cn('font-heading text-title-xl font-bold leading-none tabular-nums', cfg.text)}>
|
||||
{f.value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* Says what the number counted, so a figure is never a bare
|
||||
total the reader has to guess the basis of. */}
|
||||
{!loading && f.sub && (
|
||||
<p className="mt-1.5 text-[11px] leading-snug text-ink-4">{f.sub}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className={cn('h-0.5 w-full rounded-full bg-gradient-to-r opacity-40 group-hover:opacity-100 transition-opacity', cfg.accent)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
117
src/components/forge/ForgeRail.jsx
Normal file
117
src/components/forge/ForgeRail.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { Plus, CheckCircle2, Lightbulb, History, Layers } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statusOf } from './challengeMeta';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
'Define skill',
|
||||
'Add training',
|
||||
'Define proof',
|
||||
'Configure Owliver',
|
||||
'Publish',
|
||||
];
|
||||
|
||||
const TIPS = [
|
||||
'Keep training focused on one skill.',
|
||||
'Prefer practical evidence over recall.',
|
||||
'Define clear verification criteria.',
|
||||
'Use real workplace scenarios.',
|
||||
'Keep training concise.',
|
||||
];
|
||||
|
||||
export function ForgeRail({ recent = [], onCreate, onOpen, onAskOwliver }) {
|
||||
return (
|
||||
<aside
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-3 lg:grid-cols-3 items-stretch"
|
||||
aria-label="Forge guidance"
|
||||
>
|
||||
{/* 1. Create Skill Card */}
|
||||
<Panel
|
||||
title="Create Skill"
|
||||
icon={Layers}
|
||||
iconColor="text-blue-600"
|
||||
>
|
||||
<ol className="space-y-2.5">
|
||||
{CREATE_STEPS.map((label, i) => (
|
||||
<li key={label} className="flex items-center gap-2.5 text-body-sm text-ink-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950 text-[10px] font-bold tabular-nums text-blue-600 dark:text-blue-400 border border-blue-200/50">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0 font-medium">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2.5 text-caption font-bold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start a new skill
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
|
||||
{/* 3. Quick Tips Card */}
|
||||
<Panel
|
||||
title="Quick Tips"
|
||||
icon={Lightbulb}
|
||||
iconColor="text-amber-500"
|
||||
>
|
||||
<ul className="space-y-2.5">
|
||||
{TIPS.map((tip) => (
|
||||
<li key={tip} className="flex items-start gap-2 text-caption leading-relaxed text-ink-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500 mt-0.5" aria-hidden="true" />
|
||||
<span className="min-w-0 font-medium">{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
{/* 4. Recent Skills Card */}
|
||||
{recent.length > 0 && (
|
||||
<Panel
|
||||
title="Recent Skills"
|
||||
icon={History}
|
||||
iconColor="text-purple-600"
|
||||
>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{recent.map((course) => (
|
||||
<li key={course.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(course)}
|
||||
className="group flex w-full items-center justify-between gap-3 py-2.5 text-left transition-colors hover:text-blue-600"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-body-sm font-semibold text-ink-1 group-hover:text-blue-600">
|
||||
{course.title}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-ink-4">{course.category}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-surface-subtle px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{statusOf(course)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, icon: Icon, iconColor, children }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-border/80 bg-surface p-5 shadow-xs transition-all hover:border-blue-500/30">
|
||||
<div className="flex items-center gap-2 mb-3.5 pb-2 border-b border-border/60">
|
||||
{Icon && <Icon className={cn('h-4 w-4', iconColor)} />}
|
||||
<h2 className="font-heading text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
173
src/components/forge/SkillCard.jsx
Normal file
173
src/components/forge/SkillCard.jsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Clock, Sparkles, BookOpen, ShieldCheck, Flame, Wine, Users, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ds';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, TYPE_ICON, TYPE_LABEL, trainingType, typeOf,
|
||||
} from './challengeMeta';
|
||||
|
||||
// Category color and icon resolver
|
||||
function getCategoryMeta(category = '') {
|
||||
const cat = category.toLowerCase();
|
||||
if (cat.includes('leadership') || cat.includes('manager')) {
|
||||
return {
|
||||
bg: 'from-blue-500/10 via-indigo-500/5 to-transparent',
|
||||
accent: 'text-blue-600 dark:text-blue-400',
|
||||
border: 'border-blue-500/20',
|
||||
icon: Users,
|
||||
};
|
||||
}
|
||||
if (cat.includes('kitchen') || cat.includes('chef') || cat.includes('food')) {
|
||||
return {
|
||||
bg: 'from-amber-500/10 via-orange-500/5 to-transparent',
|
||||
accent: 'text-amber-600 dark:text-amber-400',
|
||||
border: 'border-amber-500/20',
|
||||
icon: Flame,
|
||||
};
|
||||
}
|
||||
if (cat.includes('bar') || cat.includes('beverage') || cat.includes('wine')) {
|
||||
return {
|
||||
bg: 'from-purple-500/10 via-violet-500/5 to-transparent',
|
||||
accent: 'text-purple-600 dark:text-purple-400',
|
||||
border: 'border-purple-500/20',
|
||||
icon: Wine,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'from-emerald-500/10 via-teal-500/5 to-transparent',
|
||||
accent: 'text-emerald-600 dark:text-emerald-400',
|
||||
border: 'border-emerald-500/20',
|
||||
icon: ShieldCheck,
|
||||
};
|
||||
}
|
||||
|
||||
export function SkillCard({ course, onOpen }) {
|
||||
const status = statusOf(course);
|
||||
const type = typeOf(course);
|
||||
const ProofIcon = TYPE_ICON[type] || TYPE_ICON.roleplay;
|
||||
const criteria = owliverCriteria(course);
|
||||
const hasChallenge = Boolean(course.challenge);
|
||||
const catMeta = getCategoryMeta(course.category);
|
||||
const CatIcon = catMeta.icon;
|
||||
|
||||
const isPublished = status === 'published';
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={() => onOpen(course)}
|
||||
className="group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border border-border/80 bg-surface p-4 sm:p-5 shadow-xs transition-all duration-300
|
||||
hover:-translate-y-1 hover:border-blue-500/40 hover:shadow-xl cursor-pointer overflow-hidden"
|
||||
>
|
||||
{/* Top Background Gradient Highlight */}
|
||||
<div className={cn('absolute inset-x-0 top-0 h-24 bg-gradient-to-b opacity-60 pointer-events-none transition-opacity group-hover:opacity-100', catMeta.bg)} />
|
||||
|
||||
{/* Main Body Area */}
|
||||
<div className="relative z-10 space-y-3">
|
||||
{/* Top Header Row: Category Badge (Left) & Publication Status Badge (Right) */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded-md bg-surface border shadow-2xs', catMeta.border, catMeta.accent)}>
|
||||
<CatIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
{course.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={STATUS_TONE[status] || 'neutral'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'shrink-0 font-bold text-[10px] shadow-2xs',
|
||||
isPublished && 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30 dark:bg-emerald-950/60 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Title & Difficulty Tag */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-blue-600 transition-colors">
|
||||
{course.title}
|
||||
</h3>
|
||||
|
||||
{course.difficulty && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={cn('inline-flex items-center gap-1 text-[11px] font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{course.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="line-clamp-2 text-body-sm leading-relaxed text-ink-3">
|
||||
{course.description}
|
||||
</p>
|
||||
|
||||
{/* Course Stats Pills */}
|
||||
<ul className="flex flex-wrap items-center gap-1.5 text-caption">
|
||||
{course.estimated_minutes > 0 && (
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
|
||||
<Clock className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
<span>{course.estimated_minutes} min</span>
|
||||
</li>
|
||||
)}
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-surface-subtle px-2 py-0.5 text-ink-2 font-medium border border-border/50 text-[11px]">
|
||||
<BookOpen className="h-3 w-3 text-ink-4" aria-hidden="true" />
|
||||
<span>{trainingType(course)}</span>
|
||||
</li>
|
||||
{hasChallenge && (
|
||||
<li className="inline-flex items-center gap-1 rounded-md bg-blue-50/60 dark:bg-blue-950/40 px-2 py-0.5 text-blue-700 dark:text-blue-400 font-semibold border border-blue-200/60 text-[11px]">
|
||||
<ProofIcon className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{TYPE_LABEL[type]}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Owliver Criteria AI Badge */}
|
||||
{criteria.length > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-lg bg-gradient-to-r from-blue-50/80 via-indigo-50/50 to-purple-50/30 dark:from-blue-950/40 dark:via-indigo-950/30 dark:to-purple-950/20 px-2 py-1 border border-blue-200/80 dark:border-blue-800/60">
|
||||
<Sparkles className="h-3.5 w-3.5 text-blue-600 dark:text-blue-400 shrink-0" aria-hidden="true" />
|
||||
<span className="text-[11px] font-semibold text-blue-900 dark:text-blue-300">
|
||||
Owliver evaluated
|
||||
</span>
|
||||
<span className="text-[11px] font-normal text-blue-700/80 dark:text-blue-400/80">
|
||||
· {criteria.length} {criteria.length === 1 ? 'criterion' : 'criteria'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Divider & Action Button */}
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
{course.proof_skill ? (
|
||||
<span
|
||||
title={`Verifies ${course.proof_skill}`}
|
||||
className="min-w-0 flex-1 truncate text-[10px] font-bold uppercase tracking-wider text-ink-4 flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||
<span className="truncate">Verifies {course.proof_skill}</span>
|
||||
</span>
|
||||
) : <span />}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(course);
|
||||
}}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-lg bg-blue-50 dark:bg-blue-950 px-2.5 py-1 text-caption font-bold text-blue-600 dark:text-blue-400 transition-all hover:bg-blue-600 hover:text-white group-hover:shadow-sm"
|
||||
>
|
||||
View training
|
||||
<ArrowRight
|
||||
className="h-3 w-3 transition-transform duration-200 group-hover:translate-x-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
184
src/components/forge/SkillDetail.jsx
Normal file
184
src/components/forge/SkillDetail.jsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, Check, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge, Drawer } from '@/components/ds';
|
||||
import {
|
||||
DIFF_TONE, owliverCriteria, statusOf, STATUS_TONE, trainingKinds, trainingOutline,
|
||||
TYPE_HINT, TYPE_LABEL, typeOf,
|
||||
} from './challengeMeta';
|
||||
|
||||
/**
|
||||
* One skill, read end to end: what it teaches, how it is proven, what Owliver
|
||||
* checks, what passing means, and how the workforce has done with it.
|
||||
*
|
||||
* A drawer rather than a route, because this is the managing view of a record
|
||||
* whose route already belongs to something else — `/admin/university/:id` runs
|
||||
* the challenge itself, which is the worker's experience and stays untouched.
|
||||
* The link out to it is offered at the bottom for an admin who wants to see what
|
||||
* the workforce sees.
|
||||
*
|
||||
* Sections appear only when the record has something to put in them. Reading a
|
||||
* heading with nothing under it teaches the reader to distrust the rest.
|
||||
*/
|
||||
export function SkillDetail({ course, open, onOpenChange, usage }) {
|
||||
if (!course) return null;
|
||||
|
||||
const status = statusOf(course);
|
||||
const type = typeOf(course);
|
||||
const criteria = owliverCriteria(course);
|
||||
const outline = trainingOutline(course);
|
||||
const kinds = trainingKinds(course);
|
||||
const rubric = course.challenge?.rubric || [];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
size="lg"
|
||||
title={course.title}
|
||||
description={`${course.category}${course.difficulty ? ` · ${course.difficulty}` : ''}`}
|
||||
>
|
||||
<div className="space-y-6 px-5 py-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={STATUS_TONE[status] || 'neutral'} size="sm">{status}</Badge>
|
||||
{course.difficulty && (
|
||||
<span className={cn('text-caption font-semibold capitalize', DIFF_TONE[course.difficulty] || 'text-ink-3')}>
|
||||
{course.difficulty}
|
||||
</span>
|
||||
)}
|
||||
{course.estimated_minutes > 0 && (
|
||||
<span className="text-caption text-ink-4">{course.estimated_minutes} min</span>
|
||||
)}
|
||||
{course.proof_skill && (
|
||||
<span className="text-caption text-ink-4">· Verifies {course.proof_skill}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{course.description && (
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">{course.description}</p>
|
||||
)}
|
||||
|
||||
<Block title="Training" meta="What the workforce learns">
|
||||
{outline.length > 0 ? (
|
||||
<ol className="space-y-2">
|
||||
{outline.map((step, i) => (
|
||||
<li key={step} className="flex gap-3 text-body-sm text-ink-2">
|
||||
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue-tint text-[10px] font-bold tabular-nums text-krow-blue">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-body-sm leading-relaxed text-ink-3">
|
||||
This skill carries no written outline. It teaches through
|
||||
{' '}
|
||||
{kinds.length ? kinds.join(' and ').toLowerCase() : 'the challenge itself'}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{course.quiz?.length > 0 && (
|
||||
<p className="mt-3 text-caption text-ink-4">
|
||||
Includes a {course.quiz.length}-question knowledge check.
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
|
||||
{course.challenge && (
|
||||
<Block title="Proof" meta="How the workforce demonstrates the skill">
|
||||
<p className="text-body-sm font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
||||
<p className="mt-1 text-body-sm leading-relaxed text-ink-3">{TYPE_HINT[type]}</p>
|
||||
{course.challenge.prompt && (
|
||||
<p className="mt-3 rounded-lg border border-border bg-surface-subtle p-3 text-body-sm italic leading-relaxed text-ink-2">
|
||||
“{course.challenge.prompt}”
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{criteria.length > 0 && (
|
||||
<Block
|
||||
title="Owliver evaluation"
|
||||
meta="What Owliver checks"
|
||||
icon={Sparkles}
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{rubric.map((r) => (
|
||||
<li key={r.criterion} className="flex items-baseline justify-between gap-3 text-body-sm">
|
||||
<span className="flex min-w-0 items-baseline gap-2 text-ink-2">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 translate-y-0.5 text-krow-blue" aria-hidden="true" />
|
||||
<span className="min-w-0">{r.criterion}</span>
|
||||
</span>
|
||||
{r.weight != null && (
|
||||
<span className="shrink-0 text-caption tabular-nums text-ink-4">{r.weight}%</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{course.pass_score > 0 && (
|
||||
<Block title="Verification" meta="What qualifies as passing">
|
||||
<p className="text-body-sm leading-relaxed text-ink-2">
|
||||
A submission verifies the skill at{' '}
|
||||
<span className="font-semibold text-ink-1">{course.pass_score} or above</span>
|
||||
{course.badge_reward
|
||||
? <>, and earns the <span className="font-semibold text-ink-1">{course.badge_reward}</span> badge.</>
|
||||
: '.'}
|
||||
</p>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{/* Usage is counted from worker profiles. Assignment and "in progress"
|
||||
have no field behind them, so the two states the records do carry are
|
||||
the two states shown. */}
|
||||
{usage && (usage.completed > 0 || usage.verified > 0) && (
|
||||
<Block title="Workforce usage" meta="From worker profiles">
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: 'Completed', value: usage.completed },
|
||||
{ label: 'Verified', value: usage.verified },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="rounded-lg border border-border bg-surface-subtle px-3 py-2.5">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-wider text-ink-4">{row.label}</dt>
|
||||
<dd className="mt-1 font-heading text-title font-bold tabular-nums text-ink-1">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Block>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={`/admin/university/${course.id}`}
|
||||
className="group inline-flex items-center gap-1.5 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"
|
||||
>
|
||||
Open the challenge as the workforce sees it
|
||||
<ArrowRight
|
||||
className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
/** A titled region of the record. */
|
||||
function Block({ title, meta, icon: Icon, children }) {
|
||||
return (
|
||||
<section className="border-t border-border pt-4 first:border-0 first:pt-0">
|
||||
<div className="mb-2.5 flex items-baseline gap-2">
|
||||
<h3 className="inline-flex items-center gap-1.5 font-heading text-overline uppercase tracking-wider text-ink-4">
|
||||
{Icon && <Icon className="h-3 w-3 text-krow-blue" aria-hidden="true" />}
|
||||
{title}
|
||||
</h3>
|
||||
{meta && <span className="min-w-0 truncate text-caption text-ink-4">{meta}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
171
src/components/forge/SkillLibrary.jsx
Normal file
171
src/components/forge/SkillLibrary.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SkillCard } from './SkillCard';
|
||||
|
||||
/**
|
||||
* The Skill Training Library — the working surface of admin Forge.
|
||||
*
|
||||
* A grid of skills rather than a queue of challenges: the reader is choosing
|
||||
* which skill to inspect or extend, not which one to attempt next. Filters use
|
||||
* the same underlined-control language as the rest of Forge, so the section
|
||||
* needs no toolbar surface of its own.
|
||||
*
|
||||
* The category and status options are computed by the page from the records that
|
||||
* exist, so the library never offers a filter that can only return nothing.
|
||||
*/
|
||||
/**
|
||||
* Card columns are sized off this column, not the viewport. With Owliver open
|
||||
* the page is wide while the library is not, and a viewport breakpoint put three
|
||||
* cards into seven hundred pixels — `auto-fill` asks the container instead.
|
||||
*/
|
||||
const CARD_GRID = 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4';
|
||||
|
||||
export function SkillLibrary({
|
||||
results,
|
||||
total,
|
||||
search,
|
||||
onSearch,
|
||||
category,
|
||||
onCategory,
|
||||
categories,
|
||||
status,
|
||||
onStatus,
|
||||
statuses,
|
||||
loading,
|
||||
filtered,
|
||||
onClear,
|
||||
onOpen,
|
||||
onCreate,
|
||||
sectionRef,
|
||||
}) {
|
||||
return (
|
||||
<section aria-labelledby="skill-library" className="min-w-0" ref={sectionRef}>
|
||||
<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="skill-library" className="font-heading text-title font-bold text-ink-1">
|
||||
Skill Training Library
|
||||
</h2>
|
||||
<p className="mt-1 text-body-sm text-ink-3">
|
||||
Build practical training and challenges for the skills your workforce needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 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 skills"
|
||||
aria-label="Search skills"
|
||||
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>
|
||||
|
||||
<FilterGroup
|
||||
label="Skill category"
|
||||
options={categories}
|
||||
value={category}
|
||||
onChange={onCategory}
|
||||
/>
|
||||
<FilterGroup
|
||||
label="Publication status"
|
||||
options={statuses}
|
||||
value={status}
|
||||
onChange={onStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 py-2.5">
|
||||
<p className="text-caption tabular-nums text-ink-4">
|
||||
{results.length} of {total} skills{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={CARD_GRID}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-56 animate-pulse rounded-xl bg-surface-sunken" />
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border py-12 text-center">
|
||||
<p className="font-heading text-body font-semibold text-ink-1">
|
||||
{filtered ? 'No skills match these filters' : 'No skills in the library yet'}
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-sm text-body-sm leading-relaxed text-ink-3">
|
||||
{filtered
|
||||
? 'Clear the filters to see the full library.'
|
||||
: 'A skill defines what the workforce learns, how they prove it, and what Owliver checks.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={filtered ? onClear : onCreate}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 transition-all hover:bg-blue-700 active:scale-[0.98]
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
||||
>
|
||||
{filtered ? 'Clear filters' : (
|
||||
<>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
Add Skill Training
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={CARD_GRID}>
|
||||
{results.map((course) => (
|
||||
<SkillCard key={course.id} course={course} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One radio row of filter options, styled as the Forge underline control. */
|
||||
function FilterGroup({ label, options, value, onChange }) {
|
||||
if (options.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3" role="radiogroup" aria-label={label}>
|
||||
{options.map((o) => {
|
||||
const active = o.value === value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onChange(o.value)}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,132 @@
|
||||
import React from 'react';
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
import { ArrowRight, Award, Check, ChevronRight, Compass, FileText, ShieldCheck, Sparkles } 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.
|
||||
* Your Forge — Modern Process Workflow Cards & Skill Verification Roster
|
||||
*/
|
||||
|
||||
const STEPS = ['Take a challenge', 'Submit proof', 'Owliver evaluates', 'Skill verified'];
|
||||
const STEP_ITEMS = [
|
||||
{ step: '01', title: 'Take a challenge', sub: 'Pick a skill challenge', icon: Compass },
|
||||
{ step: '02', title: 'Submit proof', sub: 'Upload work evidence', icon: FileText },
|
||||
{ step: '03', title: 'Owliver evaluates', sub: 'AI scoring & review', icon: Sparkles },
|
||||
{ step: '04', title: 'Skill verified', sub: 'Earn verified badge', icon: ShieldCheck },
|
||||
];
|
||||
|
||||
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>
|
||||
<section aria-labelledby="your-forge" className="space-y-4 rounded-2xl border border-border/80 bg-surface p-5 sm:p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/50 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 id="your-forge" className="font-heading text-title font-bold text-ink-1">
|
||||
Your Verified Skills
|
||||
</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-bold text-blue-600 dark:bg-blue-950 dark:text-blue-400 border border-blue-200/60 dark:border-blue-800/40">
|
||||
{skills.length} {skills.length === 1 ? 'Skill' : 'Skills'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-caption text-ink-3">
|
||||
{empty
|
||||
? 'Your first verified skill starts with a real-world challenge.'
|
||||
: `${evidenceCount} ${evidenceCount === 1 ? 'piece' : 'pieces'} of evidence on file`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBrowse}
|
||||
className="group inline-flex items-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-caption font-semibold text-white shadow-md shadow-blue-500/20 hover:bg-blue-700 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<span>Browse Challenges</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</button>
|
||||
</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>
|
||||
/* 4 Process Cards Workflow */
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 pt-2">
|
||||
{STEP_ITEMS.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
const isFirst = i === 0;
|
||||
|
||||
{i < STEPS.length - 1 && (
|
||||
<span
|
||||
className="hidden h-px flex-1 bg-border sm:block sm:min-w-[1rem]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
key={item.step}
|
||||
className={cn(
|
||||
'group relative flex flex-col justify-between rounded-xl border p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isFirst
|
||||
? 'border-blue-500/40 bg-gradient-to-br from-blue-50/70 via-surface to-blue-50/20 dark:from-blue-950/40 dark:to-surface'
|
||||
: 'border-border/80 bg-surface-subtle/50 hover:bg-surface'
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-extrabold uppercase tracking-wider',
|
||||
isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'
|
||||
)}
|
||||
>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-lg shadow-xs',
|
||||
isFirst
|
||||
? 'bg-blue-600 text-white shadow-blue-500/25'
|
||||
: 'bg-surface text-ink-3 border border-border'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h4 className="mt-3 font-heading text-body-sm font-bold text-ink-1">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-caption text-ink-3">{item.sub}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-caption font-semibold">
|
||||
<span className={isFirst ? 'text-blue-600 dark:text-blue-400' : 'text-ink-4'}>
|
||||
{isFirst ? 'Start Here' : 'Upcoming'}
|
||||
</span>
|
||||
{i < STEP_ITEMS.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-ink-4 hidden lg:block group-hover:translate-x-1 transition-transform" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-4 flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
/* Verified Skills List */
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 pt-2">
|
||||
{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"
|
||||
<div
|
||||
key={badge.name || i}
|
||||
className="flex items-center justify-between rounded-xl border border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20 p-3.5 shadow-xs"
|
||||
>
|
||||
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>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-md shadow-emerald-500/20">
|
||||
<Award className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-heading text-body-sm font-bold text-ink-1">
|
||||
{badge.name}
|
||||
</h4>
|
||||
<p className="text-caption text-emerald-700 dark:text-emerald-400 font-medium capitalize">
|
||||
{badge.level || 'Verified'} · {formatDate?.(badge.earned_date) || 'Earned'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Check className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -90,3 +90,85 @@ export function proofSteps(course) {
|
||||
const criteria = (course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
|
||||
return criteria.length ? [...criteria, 'Submit your proof'] : [];
|
||||
}
|
||||
|
||||
/* ── The same records, read as an administrator ─────────────────────────── */
|
||||
|
||||
/**
|
||||
* Everything below reads the Course entity from the managing side rather than
|
||||
* the doing side. The worker asks "what do I have to prove"; the admin asks
|
||||
* "what does this skill teach, how is it proven, what does Owliver check, and is
|
||||
* it live". Same fields, different question — which is why these are derivations
|
||||
* here rather than a second shape stored somewhere.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Publication state.
|
||||
*
|
||||
* The seed calls a live skill `active`; the admin vocabulary is Draft /
|
||||
* Published / Archived. Mapping rather than renaming keeps every existing record
|
||||
* and every existing `status === 'active'` filter in the app working.
|
||||
*/
|
||||
export const STATUS_LABEL = {
|
||||
active: 'Published',
|
||||
published: 'Published',
|
||||
draft: 'Draft',
|
||||
archived: 'Archived',
|
||||
inactive: 'Archived',
|
||||
};
|
||||
|
||||
export const STATUS_TONE = {
|
||||
Published: 'success',
|
||||
Draft: 'warning',
|
||||
Archived: 'neutral',
|
||||
};
|
||||
|
||||
export const statusOf = (course) => STATUS_LABEL[course?.status] || 'Draft';
|
||||
|
||||
/** The criteria Owliver scores a submission against. */
|
||||
export const owliverCriteria = (course) =>
|
||||
(course?.challenge?.rubric || []).map((r) => r.criterion).filter(Boolean);
|
||||
|
||||
/**
|
||||
* What the workforce actually studies.
|
||||
*
|
||||
* `training_outline` is written by the Forge authoring flow. Skills that predate
|
||||
* it have none, and the detail view says so rather than inventing a curriculum
|
||||
* from the description.
|
||||
*/
|
||||
export const trainingOutline = (course) =>
|
||||
(Array.isArray(course?.training_outline) ? course.training_outline : []).filter(Boolean);
|
||||
|
||||
/**
|
||||
* The kinds of training material a skill carries, named only where the record
|
||||
* actually has it. Nothing is listed on the strength of the entity *supporting*
|
||||
* a field — only on it being filled in.
|
||||
*/
|
||||
export function trainingKinds(course) {
|
||||
const kinds = [];
|
||||
if (trainingOutline(course).length) kinds.push('Instructions');
|
||||
if (course?.quiz?.length) kinds.push('Quiz');
|
||||
if (course?.challenge?.prompt) kinds.push('Practice');
|
||||
return kinds;
|
||||
}
|
||||
|
||||
/** The one-word training type a card shows. */
|
||||
export const trainingType = (course) => trainingKinds(course)[0] || 'Practice';
|
||||
|
||||
/**
|
||||
* How a skill is doing across the workforce, counted from the worker profiles
|
||||
* the platform already holds.
|
||||
*
|
||||
* Only completion and verification are counted, because those are the two states
|
||||
* a profile records. Assignment and "started" have no field behind them, so they
|
||||
* are absent here rather than estimated.
|
||||
*/
|
||||
export function workforceUsage(course, profiles = []) {
|
||||
if (!course) return { completed: 0, verified: 0 };
|
||||
let completed = 0;
|
||||
let verified = 0;
|
||||
for (const p of profiles) {
|
||||
if ((p.completed_courses || []).some((c) => c.course_id === course.id)) completed += 1;
|
||||
if ((p.earned_badges || []).some((b) => b.course_id === course.id)) verified += 1;
|
||||
}
|
||||
return { completed, verified };
|
||||
}
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
/**
|
||||
* Forge components — the pieces the KROW Forge workspace is composed from.
|
||||
* Forge components.
|
||||
*
|
||||
* The page tells one story and each part has a different shape, so the reader is
|
||||
* never looking at the same rectangle twice:
|
||||
* Two audiences read the same records, so the folder holds two sets of pieces.
|
||||
*
|
||||
* 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
|
||||
* Admin — building and managing workforce skill verification:
|
||||
*
|
||||
* All state, data and the unlock gate stay with the page; these only arrange it.
|
||||
* ForgeHeader the masthead and the two ways in
|
||||
* ForgeProgress the state of the library, in four counted figures
|
||||
* SkillLibrary every skill, filterable
|
||||
* SkillCard one skill and its verification journey
|
||||
* SkillDetail one skill read end to end
|
||||
* AddSkillTraining skill → training → proof → Owliver → publish
|
||||
* ForgeRail the authoring job, the rules, and the way to Owliver
|
||||
*
|
||||
* Worker — proving a skill. Kept whole: these are what the employee experience
|
||||
* is built from, and reframing the admin console is no reason to remove them.
|
||||
*
|
||||
* YourForge the record, or the process that creates it
|
||||
* NextSkill the skill to prove now
|
||||
* ChallengeDirectory every other challenge, searchable
|
||||
* ForgeInsight a rule and a remark
|
||||
*
|
||||
* State and derivation stay with the page; these only arrange what they are
|
||||
* given. `challengeMeta` holds the lookups both audiences share.
|
||||
*/
|
||||
|
||||
/* Admin */
|
||||
export { ForgeHeader } from './ForgeHeader';
|
||||
export { ForgeProgress } from './ForgeProgress';
|
||||
export { SkillLibrary } from './SkillLibrary';
|
||||
export { SkillCard } from './SkillCard';
|
||||
export { SkillDetail } from './SkillDetail';
|
||||
export { AddSkillTraining } from './AddSkillTraining';
|
||||
export { ForgeRail } from './ForgeRail';
|
||||
|
||||
/* Worker */
|
||||
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,
|
||||
buildRequirements, DIFF_TONE, owliverCriteria, proofSteps, statusOf, STATUS_LABEL, STATUS_TONE,
|
||||
trainingKinds, trainingOutline, trainingType, TYPE_HINT, TYPE_ICON, TYPE_LABEL, typeOf,
|
||||
workforceUsage,
|
||||
} from './challengeMeta';
|
||||
|
||||
Reference in New Issue
Block a user