333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import type { ProfileView, CourseView } from '../types';
|
|
import React, { useState } from 'react';
|
|
import { Lock, Video, Camera, MessageSquare, ScanLine, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button, Spinner, Surface } from '@/components/ds';
|
|
import { isUnlocked } from '@/lib/provingGround';
|
|
import { useSubmitChallenge, useVerifyEvidence } from '@/lib/krowHooks';
|
|
import RoleplayChallenge from './RoleplayChallenge';
|
|
import MediaChallenge from './MediaChallenge';
|
|
import VideoRecorder from './VideoRecorder';
|
|
import HazardIdentifier from './HazardIdentifier';
|
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
/**
|
|
* The challenge itself: learn, demonstrate, verify, proof.
|
|
*
|
|
* Presentation only — every step, transition and mutation below is the original
|
|
* flow. Styling comes from the design system so the runner reads as part of the
|
|
* Forge workspace it is embedded in rather than a widget dropped into it.
|
|
*/
|
|
|
|
const STEPS = ['Learn', 'Demonstrate', 'Verify', 'Proof'];
|
|
const TYPE_ICON = { roleplay: MessageSquare, photo: Camera, video: Video, photo_identify: ScanLine };
|
|
const TYPE_LABEL = { roleplay: 'Chat with Owliver', photo: 'Photo Proof', video: 'Record Yourself', photo_identify: 'Identify Hazards' };
|
|
|
|
/** Props for the local `Stepper` helper in this file. Optional throughout:
|
|
the JavaScript validated none of them and callers omit freely. */
|
|
interface StepperProps {
|
|
active?: any;
|
|
}
|
|
|
|
function Stepper({ active }: StepperProps) {
|
|
return (
|
|
<div className="mb-5 flex items-center gap-1.5">
|
|
{STEPS.map((s, i) => {
|
|
const done = i < active;
|
|
const cur = i === active;
|
|
return (
|
|
<React.Fragment key={s}>
|
|
<div className="flex items-center gap-1.5">
|
|
<span
|
|
className={cn(
|
|
'grid h-5 w-5 place-items-center rounded-full text-[10px] font-bold',
|
|
done || cur ? 'bg-krow-blue text-white' : 'bg-surface-sunken text-ink-4',
|
|
cur && 'ring-4 ring-krow-blue-tint'
|
|
)}
|
|
>
|
|
{done ? '✓' : i + 1}
|
|
</span>
|
|
<span className={cn('text-caption font-medium', done || cur ? 'text-ink-1' : 'text-ink-4')}>
|
|
{s}
|
|
</span>
|
|
</div>
|
|
{i < STEPS.length - 1 && (
|
|
<span className={cn('h-px flex-1', done ? 'bg-krow-blue' : 'bg-border')} aria-hidden="true" />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The runner's shell — one surface, one padding, every step. */
|
|
/** Props for the local `Frame` helper in this file. Optional throughout:
|
|
the JavaScript validated none of them and callers omit freely. */
|
|
interface FrameProps {
|
|
children?: any;
|
|
className?: any;
|
|
}
|
|
|
|
function Frame({ children, className }: FrameProps) {
|
|
return (
|
|
<Surface variant="solid" radius="lg" padding="none" elevation="xs" className={cn('px-5 py-4', className)}>
|
|
{children}
|
|
</Surface>
|
|
);
|
|
}
|
|
|
|
interface ChallengeRunnerProps {
|
|
course: CourseView;
|
|
profile: ProfileView;
|
|
onPassed?: (...args: any[]) => void;
|
|
}
|
|
|
|
export default function ChallengeRunner({ course, profile, onPassed }: ChallengeRunnerProps) {
|
|
const { t } = useTranslation();
|
|
const [step, setStep] = useState(0);
|
|
const [evaluating, setEvaluating] = useState(false);
|
|
const [result, setResult] = useState(null);
|
|
const [evidence, setEvidence] = useState(null);
|
|
const [supervisorVerified, setSupervisorVerified] = useState(false);
|
|
const submit = useSubmitChallenge();
|
|
const verify = useVerifyEvidence();
|
|
const ch = course?.challenge || {};
|
|
const type = ch.type || 'roleplay';
|
|
const Icon = TYPE_ICON[type] || MessageSquare;
|
|
const lock = isUnlocked(course, profile);
|
|
|
|
const handleSubmit = async (payload) => {
|
|
setEvaluating(true);
|
|
let mediaUrl = null, transcript = '', identified = null;
|
|
if (type === 'roleplay') transcript = payload;
|
|
else if (type === 'photo_identify') { mediaUrl = ch.image_url; identified = payload; }
|
|
else mediaUrl = payload;
|
|
try {
|
|
const res = await submit.mutateAsync({ profile, course, type, mediaUrl, transcript, identified });
|
|
setResult(res.result);
|
|
setEvidence(res.evidence);
|
|
setSupervisorVerified(false);
|
|
setStep(2);
|
|
if (res.passed && onPassed) onPassed();
|
|
} catch {
|
|
setStep(1);
|
|
} finally {
|
|
setEvaluating(false);
|
|
}
|
|
};
|
|
|
|
const doVerify = async () => {
|
|
if (!evidence) return;
|
|
try {
|
|
await verify.mutateAsync({ evidenceId: evidence.id });
|
|
setSupervisorVerified(true);
|
|
} catch {}
|
|
};
|
|
|
|
const passed = evidence?.ai_verdict === 'verified';
|
|
|
|
// 0 — Learn
|
|
if (step === 0) {
|
|
return (
|
|
<Frame>
|
|
<Stepper active={0} />
|
|
{!lock.unlocked ? (
|
|
<>
|
|
<p className="flex items-center gap-2 text-body font-semibold text-ink-1">
|
|
<Lock className="h-4 w-4 text-warning" aria-hidden="true" /> {t('challengeRunner.lockedEarnThisOne')}
|
|
</p>
|
|
<p className="mt-1 text-body-sm text-ink-3">{t('challengeRunner.realWorkUnlocksLearningCompleteThese')}</p>
|
|
<ul className="mt-2.5 space-y-1.5">
|
|
{lock.reasons.map((r, i) => (
|
|
<li key={i} className="flex items-start gap-2 text-body-sm text-ink-2">
|
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true" />
|
|
{r}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center gap-3">
|
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-blue-tint text-krow-blue">
|
|
<Icon className="h-5 w-5" aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-semibold uppercase tracking-wide text-ink-4">{t('challengeRunner.demonstration')}</p>
|
|
<p className="text-body font-semibold text-ink-1">{TYPE_LABEL[type]}</p>
|
|
</div>
|
|
</div>
|
|
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">
|
|
{ch.prompt || course?.description}
|
|
</p>
|
|
<Button shape="rounded" className="mt-4 w-full" onClick={() => setStep(1)}>
|
|
{t('challengeRunner.showMe')} <ArrowRight aria-hidden="true" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// Evaluating
|
|
if (evaluating) {
|
|
return (
|
|
<Frame className="text-center">
|
|
<Stepper active={1} />
|
|
<div className="flex flex-col items-center gap-2 py-6">
|
|
<Spinner size="lg" />
|
|
<p className="text-body-sm font-medium text-ink-1">{t('challengeRunner.owliverIsEvaluatingYourProof')}</p>
|
|
<p className="text-caption text-ink-3">{t('challengeRunner.measuringWhetherYouCanActuallyPerform')}</p>
|
|
</div>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 1 — Demonstrate
|
|
if (step === 1) {
|
|
return (
|
|
<Frame>
|
|
<Stepper active={1} />
|
|
{/* The roleplay widget opens with this same prompt as its first message,
|
|
so repeating it above the transcript would say it twice. */}
|
|
{type !== 'roleplay' && (
|
|
<div className="mb-4 flex items-start gap-2.5 rounded-xl border border-border bg-surface-subtle p-3">
|
|
<OwliverAvatar className="h-7 w-7 shrink-0" rounded="rounded-full" />
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-semibold text-krow-blue">{t('challengeRunner.owliver')}</p>
|
|
<p className="text-body-sm leading-relaxed text-ink-2">{ch.prompt || course?.description}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{type === 'roleplay' && <RoleplayChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
|
{type === 'photo' && <MediaChallenge course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
|
{type === 'video' && <VideoRecorder onSubmit={handleSubmit} submitting={submit.isPending} />}
|
|
{type === 'photo_identify' && <HazardIdentifier course={course} onSubmit={handleSubmit} submitting={submit.isPending} />}
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 2 — Verify
|
|
if (step === 2) {
|
|
const rubric = result?.rubric || {};
|
|
return (
|
|
<Frame>
|
|
<Stepper active={2} />
|
|
<div className="flex items-center gap-3">
|
|
{passed
|
|
? <CheckCircle2 className="h-8 w-8 shrink-0 text-success" aria-hidden="true" />
|
|
: <XCircle className="h-8 w-8 shrink-0 text-destructive" aria-hidden="true" />}
|
|
<div className="min-w-0">
|
|
<p className="font-heading text-title text-ink-1">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
|
<p className="text-caption tabular-nums text-ink-3">
|
|
{t('challengeRunner.aiConfidence')} {Math.round(evidence?.ai_score || 0)}/100
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{result?.feedback && (
|
|
<p className="mt-3 max-w-prose text-body-sm leading-relaxed text-ink-2">{result.feedback}</p>
|
|
)}
|
|
|
|
{Object.keys(rubric).length > 0 && (
|
|
<div className="mt-4 space-y-2">
|
|
{Object.entries(rubric).map(([k, v]) => (
|
|
<div key={k}>
|
|
<div className="mb-1 flex justify-between text-caption">
|
|
<span className="capitalize text-ink-3">{k.replace(/_/g, ' ')}</span>
|
|
<span className="font-semibold tabular-nums text-ink-1">{Math.round(v as number)}/100</span>
|
|
</div>
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface-sunken">
|
|
<div className="h-full rounded-full bg-krow-blue" style={{ width: `${Math.min(100, Math.round(v as number))}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'mt-4 flex items-center gap-3 rounded-xl border px-3.5 py-3',
|
|
supervisorVerified ? 'border-success/30 bg-success-muted' : 'border-warning/30 bg-warning-muted'
|
|
)}
|
|
>
|
|
<ShieldCheck
|
|
className={cn('h-4 w-4 shrink-0', supervisorVerified ? 'text-success' : 'text-warning')}
|
|
aria-hidden="true"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-body-sm font-medium text-ink-1">
|
|
{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}
|
|
</p>
|
|
<p className="text-caption text-ink-3">
|
|
{supervisorVerified
|
|
? 'A supervisor confirmed this demonstration in person.'
|
|
: 'A supervisor can confirm this demonstration on the floor.'}
|
|
</p>
|
|
</div>
|
|
{!supervisorVerified ? (
|
|
<Button variant="navy" size="sm" shape="rounded" onClick={doVerify} disabled={verify.isPending}>
|
|
{verify.isPending ? '…' : 'Verify'}
|
|
</Button>
|
|
) : (
|
|
<CheckCircle2 className="h-4 w-4 text-success" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
|
|
{passed ? (
|
|
<Button shape="rounded" className="mt-3 w-full" onClick={() => setStep(3)}>
|
|
{t('challengeRunner.seeYourProof')} <ArrowRight aria-hidden="true" />
|
|
</Button>
|
|
) : (
|
|
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(1)}>
|
|
{t('challengeRunner.tryTheDemonstrationAgain')}
|
|
</Button>
|
|
)}
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 3 — Proof
|
|
return (
|
|
<Frame>
|
|
<Stepper active={3} />
|
|
<div className="flex items-center gap-3">
|
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-krow-yellow text-krow-blue">
|
|
<Award className="h-5 w-5" aria-hidden="true" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="text-body font-semibold text-ink-1">{t('challengeRunner.proofStored')}</p>
|
|
<p className="text-caption text-ink-3">{t('challengeRunner.capability')} {course.proof_skill || course.title}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<dl className="mt-4 divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface-subtle">
|
|
{[
|
|
{ label: 'AI verdict', value: evidence?.ai_verdict, tone: 'text-success', capitalize: true },
|
|
{ label: 'Score', value: `${Math.round(evidence?.ai_score || 0)}/100` },
|
|
{ label: 'Supervisor', value: supervisorVerified ? 'Verified' : 'Pending' },
|
|
{ label: 'Proof type', value: TYPE_LABEL[type] },
|
|
].map((row) => (
|
|
<div key={row.label} className="flex items-baseline justify-between gap-3 px-3.5 py-2">
|
|
<dt className="text-caption text-ink-3">{row.label}</dt>
|
|
<dd className={cn('text-caption font-semibold', row.capitalize && 'capitalize', row.tone || 'text-ink-1')}>
|
|
{row.value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
|
|
<p className="mt-3 flex items-start gap-2 rounded-xl border border-border bg-krow-blue-tint px-3.5 py-3 text-caption leading-relaxed text-krow-blue">
|
|
<Eye className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
{t('challengeRunner.employersCanNowSeeThisProof')}
|
|
</p>
|
|
|
|
<Button variant="outline" shape="rounded" className="mt-3 w-full" onClick={() => setStep(0)}>
|
|
{t('challengeRunner.backToStart')}
|
|
</Button>
|
|
</Frame>
|
|
);
|
|
}
|