Initial commit
This commit is contained in:
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
38
src/components/krow/proving/CapabilityTimeline.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, Plus } from 'lucide-react';
|
||||
|
||||
// The transcript replacement — verified capabilities as visual chips,
|
||||
// each backed by evidence. Empty until the worker proves something.
|
||||
export default function CapabilityTimeline({ profile, compact }) {
|
||||
const caps = (profile?.capabilities || []).filter((c) => c.status === 'verified' || c.status === 'pending');
|
||||
return (
|
||||
<section className={compact ? 'py-2' : 'py-8'}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[15px] font-semibold text-[#0F172A]">Proven skills</h2>
|
||||
<span className="text-[11px] text-[#94A3B8]">{caps.length} verified</span>
|
||||
</div>
|
||||
{!caps.length ? (
|
||||
<div className="rounded-2xl border border-dashed border-[#E2E8F0] bg-[#F8FAFC] p-6 text-center">
|
||||
<div className="w-10 h-10 rounded-full bg-white border border-[#E2E8F0] flex items-center justify-center mx-auto mb-2">
|
||||
<Plus className="w-4 h-4 text-[#94A3B8]" />
|
||||
</div>
|
||||
<p className="text-[12px] text-[#64748B]">No verified skills yet — prove a challenge to start your chain.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caps.map((c, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1.5 rounded-full bg-[#F0FDF4] border border-[#BBF7D0] px-3 py-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-[#16A34A]" />
|
||||
<span className="text-[12px] font-medium text-[#166534]">{c.skill}</span>
|
||||
{c.verified_date && (
|
||||
<span className="text-[10px] text-[#16A34A]/70">
|
||||
{new Date(c.verified_date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
209
src/components/krow/proving/ChallengeRunner.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Lock, Video, Camera, MessageSquare, ScanLine, Loader2, XCircle, CheckCircle2, ShieldCheck, Award, Eye, ArrowRight } from 'lucide-react';
|
||||
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';
|
||||
|
||||
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' };
|
||||
|
||||
function Stepper({ active }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 mb-6">
|
||||
{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 ${cur || done ? '' : 'opacity-50'}`}>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[11px] font-bold ${done ? 'bg-[#0A39DF] text-white' : cur ? 'bg-[#0A39DF] text-white ring-4 ring-[#EFF6FF]' : 'bg-[#F1F5F9] text-[#94A3B8]'}`}>{done ? '✓' : i + 1}</div>
|
||||
<span className={`text-[12px] font-medium ${cur || done ? 'text-[#0F172A]' : 'text-[#94A3B8]'}`}>{s}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && <div className={`flex-1 h-px ${done ? 'bg-[#0A39DF]' : 'bg-[#E5E7EB]'}`} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChallengeRunner({ course, profile, onPassed }) {
|
||||
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 (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={0} />
|
||||
{!lock.unlocked ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-[#0F172A] mb-2"><Lock className="w-4 h-4" /><span className="font-semibold text-[15px]">Locked — earn this one</span></div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Real work unlocks learning. Complete these first:</p>
|
||||
<ul className="space-y-1.5">
|
||||
{lock.reasons.map((r, i) => (<li key={i} className="text-[13px] text-[#475569] flex items-center gap-2"><span className="w-1.5 h-1.5 rounded-full bg-[#94A3B8]" />{r}</li>))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#EFF6FF] flex items-center justify-center"><Icon className="w-5 h-5 text-[#0A39DF]" /></div>
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-[#94A3B8]">Demonstration</p>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[14px] text-[#374151] leading-relaxed mb-4">{ch.prompt || course?.description}</p>
|
||||
<button onClick={() => setStep(1)} className="w-full h-11 rounded-xl bg-[#0A39DF] hover:bg-[#082EB4] text-white text-[14px] font-semibold flex items-center justify-center gap-2">Show me <ArrowRight className="w-4 h-4" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Evaluating
|
||||
if (evaluating) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-10 flex flex-col items-center">
|
||||
<Stepper active={1} />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[#0A39DF] mb-3" />
|
||||
<p className="text-[14px] font-medium text-[#0F172A]">Owliver is evaluating your proof…</p>
|
||||
<p className="text-[12px] text-[#64748B] mt-1">Measuring whether you can actually perform.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 1 — Demonstrate
|
||||
if (step === 1) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={1} />
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-3 mb-4 flex items-start gap-2.5">
|
||||
<OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-[#0A39DF] mb-0.5">Owliver</p>
|
||||
<p className="text-[13px] text-[#374151]">{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} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 2 — Verify
|
||||
if (step === 2) {
|
||||
const rubric = result?.rubric || {};
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={2} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
{passed ? <CheckCircle2 className="w-9 h-9 text-[#16A34A]" /> : <XCircle className="w-9 h-9 text-[#EF4444]" />}
|
||||
<div>
|
||||
<p className="text-[17px] font-semibold text-[#0F172A]">{passed ? 'Verified by Owliver' : 'Needs work'}</p>
|
||||
<p className="text-[12px] text-[#64748B]">AI confidence: {Math.round(evidence?.ai_score || 0)}/100</p>
|
||||
</div>
|
||||
</div>
|
||||
{result?.feedback && <p className="text-[13px] text-[#374151] leading-relaxed mb-4">{result.feedback}</p>}
|
||||
{Object.keys(rubric).length > 0 && (
|
||||
<div className="space-y-2 mb-4">
|
||||
{Object.entries(rubric).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<div className="flex justify-between text-[12px] mb-1"><span className="text-[#475569] capitalize">{k.replace(/_/g, ' ')}</span><span className="font-semibold text-[#0F172A]">{Math.round(v)}/100</span></div>
|
||||
<div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"><div className="h-full bg-[#0A39DF]" style={{ width: `${Math.min(100, Math.round(v))}%` }} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-xl border p-4 flex items-center gap-3 mb-3 ${supervisorVerified ? 'bg-[#F0FDF4] border-[#BBF7D0]' : 'bg-[#FFFBEB] border-[#FDE68A]'}`}>
|
||||
<ShieldCheck className={`w-5 h-5 ${supervisorVerified ? 'text-[#16A34A]' : 'text-[#D97706]'}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-[13px] font-medium text-[#0F172A]">{supervisorVerified ? 'Supervisor verified' : 'Supervisor verification'}</p>
|
||||
<p className="text-[11px] text-[#64748B]">{supervisorVerified ? 'A supervisor confirmed this demonstration in person.' : 'A supervisor can confirm this demonstration on the floor.'}</p>
|
||||
</div>
|
||||
{!supervisorVerified ? (
|
||||
<button onClick={doVerify} disabled={verify.isPending} className="h-8 px-3 rounded-lg bg-[#0F172A] text-white text-[12px] font-semibold disabled:opacity-50">{verify.isPending ? '…' : 'Verify'}</button>
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-[#16A34A]" />
|
||||
)}
|
||||
</div>
|
||||
{passed ? (
|
||||
<button onClick={() => setStep(3)} className="w-full h-11 rounded-xl bg-[#0A39DF] text-white text-[14px] font-semibold flex items-center justify-center gap-2">See your proof <ArrowRight className="w-4 h-4" /></button>
|
||||
) : (
|
||||
<button onClick={() => setStep(1)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Try the demonstration again</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 3 — Proof
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6">
|
||||
<Stepper active={3} />
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-11 h-11 rounded-2xl bg-[#F9E547] flex items-center justify-center"><Award className="w-5 h-5 text-[#92400E]" /></div>
|
||||
<div>
|
||||
<p className="text-[16px] font-semibold text-[#0F172A]">Proof stored</p>
|
||||
<p className="text-[12px] text-[#64748B]">Capability: {course.proof_skill || course.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#F8FAFC] border border-[#F1F5F9] p-4 space-y-2 mb-4">
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">AI verdict</span><span className="font-semibold text-[#16A34A] capitalize">{evidence?.ai_verdict}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Score</span><span className="font-semibold text-[#0F172A]">{Math.round(evidence?.ai_score || 0)}/100</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Supervisor</span><span className="font-semibold text-[#0F172A]">{supervisorVerified ? 'Verified' : 'Pending'}</span></div>
|
||||
<div className="flex justify-between text-[12px]"><span className="text-[#64748B]">Proof type</span><span className="font-semibold text-[#0F172A]">{TYPE_LABEL[type]}</span></div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-[#EFF6FF] border border-[#BFDBFE] p-4 flex items-start gap-2.5 mb-4">
|
||||
<Eye className="w-4 h-4 text-[#0A39DF] mt-0.5 shrink-0" />
|
||||
<p className="text-[12px] text-[#1E40AF]">Employers can now see this proof in your Career Wallet. Every skill is backed by evidence.</p>
|
||||
</div>
|
||||
<button onClick={() => setStep(0)} className="w-full h-10 rounded-xl border border-[#E5E7EB] text-[13px] font-semibold text-[#0F172A] hover:bg-[#F8FAFC]">Back to start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
82
src/components/krow/proving/HazardIdentifier.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, Plus } from 'lucide-react';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
const PRESET_TAGS = [
|
||||
'Raw meat next to ready-to-eat food',
|
||||
'Same board for raw + cooked',
|
||||
'Soiled towel on food surface',
|
||||
'Food in the temperature danger zone',
|
||||
'Unwashed hands',
|
||||
'Improper storage',
|
||||
'Cross-contact allergen',
|
||||
];
|
||||
|
||||
// Interactive hazard identification — tap a kitchen photo to mark hazards.
|
||||
export default function HazardIdentifier({ course, onSubmit, submitting }) {
|
||||
const imageUrl = course?.challenge?.image_url;
|
||||
const [markers, setMarkers] = useState([]);
|
||||
const [pending, setPending] = useState(null);
|
||||
const [customLabel, setCustomLabel] = useState('');
|
||||
|
||||
const addMarker = (e) => {
|
||||
if (submitting || pending) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
setPending({ x, y });
|
||||
};
|
||||
|
||||
const confirm = (label) => {
|
||||
if (!pending) return;
|
||||
setMarkers([...markers, { ...pending, label }]);
|
||||
setPending(null);
|
||||
setCustomLabel('');
|
||||
};
|
||||
|
||||
const remove = (i) => setMarkers(markers.filter((_, j) => j !== i));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">Tap anywhere on the kitchen photo to mark a hazard. Label each one, then submit for AI evaluation.</p>
|
||||
<div className="relative rounded-2xl overflow-hidden border border-[#E5E7EB] mb-3 select-none cursor-crosshair" onClick={addMarker}>
|
||||
{imageUrl ? (
|
||||
<Image src={imageUrl} alt="Kitchen scene" className="w-full max-h-80 object-cover" fittingType="fill" />
|
||||
) : (
|
||||
<div className="w-full h-64 bg-[#F1F5F9] flex items-center justify-center text-[13px] text-[#94A3B8]">No scene image configured</div>
|
||||
)}
|
||||
{markers.map((m, i) => (
|
||||
<div key={i} className="absolute -translate-x-1/2 -translate-y-1/2 group" style={{ left: `${m.x * 100}%`, top: `${m.y * 100}%` }} onClick={(e) => e.stopPropagation()}>
|
||||
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-red-500 text-white text-[11px] font-bold shadow ring-2 ring-white">{i + 1}</span>
|
||||
<span className="absolute left-1/2 -translate-x-1/2 top-7 whitespace-nowrap text-[10px] bg-white/95 rounded px-1.5 py-0.5 shadow text-[#0F172A]">{m.label}</span>
|
||||
<button onClick={() => remove(i)} className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-white text-red-500 shadow flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Trash2 className="w-2.5 h-2.5" /></button>
|
||||
</div>
|
||||
))}
|
||||
{pending && (
|
||||
<div className="absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none" style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}>
|
||||
<span className="block w-6 h-6 rounded-full bg-[#0A39DF] ring-4 ring-white animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<div className="rounded-xl border border-[#0A39DF] bg-[#EFF6FF] p-3 mb-3">
|
||||
<p className="text-[12px] font-medium text-[#1E40AF] mb-2">Label this hazard:</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{PRESET_TAGS.map((t) => (
|
||||
<button key={t} onClick={() => confirm(t)} className="text-[11px] px-2.5 py-1 rounded-full bg-white border border-[#E5E7EB] text-[#374151] hover:border-[#0A39DF]">{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input value={customLabel} onChange={(e) => setCustomLabel(e.target.value)} placeholder="Or type your own…" className="flex-1 h-8 px-2.5 text-[12px] rounded-lg border border-[#E5E7EB] focus:border-[#0A39DF] focus:outline-none" />
|
||||
<button onClick={() => customLabel.trim() && confirm(customLabel.trim())} className="h-8 px-3 rounded-lg bg-[#0A39DF] text-white text-[12px] font-medium flex items-center gap-1"><Plus className="w-3.5 h-3.5" /> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => onSubmit(markers)} disabled={markers.length === 0 || submitting || !!pending} className="w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40">
|
||||
Submit {markers.length} hazard{markers.length !== 1 ? 's' : ''} for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
69
src/components/krow/proving/MediaChallenge.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Loader2, Camera } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { Image } from '@/components/ui/image';
|
||||
|
||||
// Photo or video proof — capture/upload, then submit for AI evaluation.
|
||||
export default function MediaChallenge({ course, onSubmit, submitting }) {
|
||||
const type = course?.challenge?.type || 'photo';
|
||||
const [mediaUrl, setMediaUrl] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const accept = type === 'video' ? 'video/*' : 'image/*';
|
||||
const capture = type === 'photo' ? 'environment' : undefined;
|
||||
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
setMediaUrl(file_url);
|
||||
} catch {
|
||||
setMediaUrl('');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[13px] text-[#64748B] mb-3">
|
||||
{type === 'video' ? 'Record or upload a short video demonstrating the challenge.' : 'Take or upload a photo showing your work.'}
|
||||
</p>
|
||||
{mediaUrl ? (
|
||||
<div className="mb-3 rounded-xl overflow-hidden border border-[#E5E7EB]">
|
||||
{type === 'video' ? (
|
||||
<video src={mediaUrl} controls className="w-full max-h-64 object-cover" />
|
||||
) : (
|
||||
<Image src={mediaUrl} alt="Your proof" className="w-full max-h-64 object-cover" fittingType="fill" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-[#CBD5E1] cursor-pointer hover:border-[#0A39DF] transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-[#0A39DF]" />
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-6 h-6 text-[#94A3B8] mb-2" />
|
||||
<span className="text-[13px] text-[#64748B]">Tap to {type === 'video' ? 'record' : 'capture'}</span>
|
||||
</>
|
||||
)}
|
||||
<input type="file" accept={accept} capture={capture} onChange={handleFile} className="hidden" disabled={uploading || submitting} />
|
||||
</label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{mediaUrl && (
|
||||
<button onClick={() => setMediaUrl('')} className="h-10 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B]">Retake</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSubmit(mediaUrl)}
|
||||
disabled={!mediaUrl || uploading || submitting}
|
||||
className="flex-1 h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
82
src/components/krow/proving/RoleplayChallenge.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Loader2 } from 'lucide-react';
|
||||
import { challengeFollowUp } from '@/lib/provingGround';
|
||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||
|
||||
// AI roleplay challenge — scenario prompt, worker responds, KROW follows up,
|
||||
// then the worker submits the transcript for AI evaluation.
|
||||
export default function RoleplayChallenge({ course, onSubmit, submitting }) {
|
||||
const initial = course?.challenge?.prompt || course?.description || 'Demonstrate the skill.';
|
||||
const [messages, setMessages] = useState([{ role: 'ai', content: initial }]);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
const next = [...messages, { role: 'user', content: text }];
|
||||
setMessages(next);
|
||||
setInput('');
|
||||
setBusy(true);
|
||||
try {
|
||||
const follow = await challengeFollowUp(course, next);
|
||||
setMessages([...next, { role: 'ai', content: follow }]);
|
||||
} catch {
|
||||
setMessages([...next, { role: 'ai', content: 'Thanks. Submit your proof when ready.' }]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const transcript = messages.map((m) => `${m.role === 'user' ? 'Worker' : 'Owliver'}: ${m.content}`).join('\n');
|
||||
const hasResponded = messages.some((m) => m.role === 'user');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<OwliverAvatar className="w-8 h-8" rounded="rounded-full" />
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-[#0F172A]">Owliver</p>
|
||||
<p className="text-[10px] text-[#94A3B8]">Your AI skills coach</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 mb-4 max-h-72 overflow-y-auto pr-1">
|
||||
{messages.map((m, i) => {
|
||||
const isAI = m.role !== 'user';
|
||||
return (
|
||||
<div key={i} className={`flex items-end gap-2 ${isAI ? 'justify-start' : 'justify-end'}`}>
|
||||
{isAI && <OwliverAvatar className="w-7 h-7 shrink-0" rounded="rounded-full" />}
|
||||
<div className={`max-w-[80%] px-3.5 py-2.5 rounded-2xl text-[13px] leading-relaxed ${
|
||||
m.role === 'user' ? 'bg-[#0A39DF] text-white rounded-br-sm' : 'bg-[#F1F5F9] text-[#0F172A] rounded-bl-sm'
|
||||
}`}>{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={scrollRef} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full bg-white pl-4 pr-1.5 py-1.5 shadow-[0_10px_30px_rgba(10,57,223,0.14),0_2px_6px_rgba(51,63,72,0.06)] ring-1 ring-black/5">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && send()}
|
||||
disabled={submitting || busy}
|
||||
placeholder="Type your response…"
|
||||
className="flex-1 h-9 bg-transparent px-1 text-[13px] focus:outline-none placeholder:text-[#9CA3AF]"
|
||||
/>
|
||||
<button onClick={send} disabled={busy || submitting} className="w-9 h-9 rounded-full bg-[#0A39DF] text-white flex items-center justify-center disabled:opacity-50 shrink-0">
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSubmit(transcript)}
|
||||
disabled={!hasResponded || submitting || busy}
|
||||
className="mt-3 w-full h-10 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold disabled:opacity-40"
|
||||
>
|
||||
Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
103
src/components/krow/proving/VideoRecorder.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Loader2, Video, Square, RotateCcw } from 'lucide-react';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
// Real in-browser video recording — "record yourself washing your hands."
|
||||
export default function VideoRecorder({ onSubmit, submitting }) {
|
||||
const videoRef = useRef(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const timerRef = useRef(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [recordedUrl, setRecordedUrl] = useState(null);
|
||||
const [blob, setBlob] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let s;
|
||||
navigator.mediaDevices?.getUserMedia({ video: { facingMode: 'user' }, audio: true })
|
||||
.then((stream) => {
|
||||
s = stream;
|
||||
if (active && videoRef.current) videoRef.current.srcObject = stream;
|
||||
})
|
||||
.catch(() => setError('Camera access denied. Grant camera permission or use a mobile device to record.'));
|
||||
return () => {
|
||||
active = false;
|
||||
if (s) s.getTracks().forEach((t) => t.stop());
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = () => {
|
||||
if (!videoRef.current?.srcObject) return;
|
||||
chunksRef.current = [];
|
||||
const mr = new MediaRecorder(videoRef.current.srcObject);
|
||||
mr.ondataavailable = (e) => { if (e.data.size) chunksRef.current.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
const b = new Blob(chunksRef.current, { type: 'video/webm' });
|
||||
setBlob(b);
|
||||
setRecordedUrl(URL.createObjectURL(b));
|
||||
};
|
||||
mr.start();
|
||||
mediaRecorderRef.current = mr;
|
||||
setRecording(true);
|
||||
setSeconds(0);
|
||||
timerRef.current = setInterval(() => setSeconds((v) => v + 1), 1000);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
|
||||
const reset = () => { setRecordedUrl(null); setBlob(null); setSeconds(0); };
|
||||
|
||||
const submit = async () => {
|
||||
if (!blob) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const file = new File([blob], `proof-${Date.now()}.webm`, { type: 'video/webm' });
|
||||
const { file_url } = await base44.integrations.Core.UploadFile({ file });
|
||||
onSubmit(file_url);
|
||||
} catch {
|
||||
setError('Upload failed. Try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <p className="text-[12px] text-[#EF4444] mb-3">{error}</p>}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-black aspect-video mb-3">
|
||||
{recordedUrl ? (
|
||||
<video src={recordedUrl} controls className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<video ref={videoRef} autoPlay muted playsInline className="w-full h-full object-cover" />
|
||||
)}
|
||||
{recording && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 bg-black/60 rounded-full px-2.5 py-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="text-[12px] text-white font-mono">{seconds}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!recordedUrl ? (
|
||||
<button onClick={recording ? stop : start} disabled={uploading || submitting} className={`w-full h-11 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 text-white ${recording ? 'bg-red-500' : 'bg-[#0A39DF]'}`}>
|
||||
{recording ? <><Square className="w-4 h-4" /> Stop recording</> : <><Video className="w-4 h-4" /> Record yourself</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={reset} className="h-11 px-4 rounded-xl border border-[#E5E7EB] text-[13px] font-medium text-[#64748B] flex items-center gap-1.5"><RotateCcw className="w-4 h-4" /> Retake</button>
|
||||
<button onClick={submit} disabled={uploading || submitting} className="flex-1 h-11 rounded-xl bg-[#0F172A] text-white text-[13px] font-semibold flex items-center justify-center gap-2 disabled:opacity-50">
|
||||
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Submit proof for AI evaluation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user