Phase 9, first batch: 18 pages. Seventeen emit byte-identical JavaScript, the
eighteenth differs only by a JSDoc cast becoming a real annotation, and the
production bundle is byte-identical to be49184.
SCOPE, decided by imports. Twenty-two of the forty pages import `lib/skills`,
`lib/agents`, `ai-assistant`, `components/skills`, `ui-tree` or `ui-editor`, and
are deferred to Phase 11 with the rest of that region. `Owliver.jsx` is in THIS
batch despite the name: it imports only `krowHooks`, `krowScore`, `krowAi` and
`ui/button`, and is a profile-building conversation page rather than any part of
the agent runtime.
`DesignSystem` is the headline result. It was the single worst file in Phase 6 -
111 errors when the design system was first renamed - and it arrived here with
none, because those were never its errors: they were the primitives' inferred
props, and fixing them at the source fixed every consumer.
Three pages needed real work, and each was the author's own intent made
explicit:
- `admin/Login` carried `useState(/** @type {{email?: string, password?: string}} */ ({}))`.
JSDoc casts stop applying in a `.tsx` file, so that became a real type
argument, and `validate`'s accumulator - built empty and filled per failed
rule - needed the same shape. The login flow itself is untouched: the
generic 401 message, the 429 branch and the `remember` field all stand.
- `Owliver` gets `new Promise<void>`, because its `resolve()` takes no
argument.
- `Candidates` names the element type of a `Set` built from `any[]`, which
otherwise infers `Set<unknown>` and makes every option a `ReactNode` error.
LINT CAUGHT A REGRESSION THE OTHER CHECKS DID NOT. The generator adds an entity
import when it sees the type NAME anywhere in the file, which for four pages
with no props at all left an import nothing used - four `unused-imports` ERRORS,
taking `npm run lint` from exit 0 to exit 1 while typecheck, tests, the bundle
and seventeen of eighteen emit comparisons all stayed green. Removed. The
generator's import rule is too eager and wants narrowing before the next batch.
Verified: tsc 21 -> 21, set-difference showing zero introduced and zero removed;
zero errors in any of the 18 pages; lint back to 0 errors and 289 warnings;
npm test 1684/1691 with the same seven failures; Owliver baseline 59/59;
production bundle byte-identical; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
567 lines
28 KiB
TypeScript
567 lines
28 KiB
TypeScript
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useWorkerProfile, useUpdateWorkerProfile } from '@/lib/krowHooks';
|
|
import { recalcProfilePatch } from '@/lib/krowScore';
|
|
import { owliverQuestion, buildProfileFromConversation } from '@/lib/krowAi';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Mic, MicOff, Volume2, VolumeX, Loader2, ArrowRight, Send, Sparkles, RefreshCw } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|
|
|
const TOTAL_SECONDS = 300; // 5 minutes
|
|
const QUESTION_COUNT = 6;
|
|
const AVAIL_TOKENS = ['Weekdays', 'Weekends', 'Evenings', 'Mornings', 'Overnight', 'On-Call'];
|
|
|
|
// Owliver's wisdom — Career DNA: a living professional identity, built from signals not claims
|
|
|
|
const UI = {
|
|
en: {
|
|
badge: 'Step 2 · Meet Owliver',
|
|
title: 'Talk to Owliver',
|
|
subtitle: 'Not forms. Just a 5-minute conversation. Owliver learns who you are and builds your profile automatically.',
|
|
hi: (n) => `Hi ${n}, I'm Owliver`,
|
|
introP: "I'll ask a few friendly questions. Just speak naturally — there are no wrong answers.",
|
|
banner: "Just a casual chat — no right or wrong answers. I'll turn it into your profile for you.",
|
|
speaks: 'Owliver speaks',
|
|
muted: 'Muted',
|
|
start: 'Start talking',
|
|
question: 'Question',
|
|
of: 'of',
|
|
skipped: 'skipped',
|
|
listening: 'Listening…',
|
|
placeholder: 'Type your answer or tap the mic to speak',
|
|
speak: 'Speak',
|
|
stop: 'Stop',
|
|
skip: 'Skip',
|
|
send: 'Send',
|
|
building1: 'Owliver is building your profile…',
|
|
building2: 'Turning our conversation into your KROW Identity.',
|
|
done: 'Owliver built your profile',
|
|
seeId: 'See my KROW ID',
|
|
yrs: 'yrs',
|
|
dash: '—',
|
|
notEnoughTitle: "Owliver didn't catch enough",
|
|
notEnoughBody: "I didn't get much from our chat — try again and answer a couple of questions so I can build your profile.",
|
|
chatAgain: "Let's chat again",
|
|
goToProfile: 'Go to my profile',
|
|
labels: { desired: 'Desired role', experience: 'Experience', transport: 'Transportation', salary: 'Salary', leadership: 'Leadership', skills: 'Skills', languages: 'Languages', industries: 'Industries', availability: 'Availability', strengths: 'Strengths', workingOn: 'Working on', personality: 'Personality', identity: 'Identity', learningSpeed: 'Learning speed', reliability: 'Reliability', communication: 'Communication', leadershipIndex: 'Leadership index', adaptability: 'Adaptability', workStyle: 'Work style', cultureMatch: 'Culture match', reputation: 'Reputation baseline' },
|
|
dnaHeader: 'Career DNA',
|
|
dnaSub: 'Built from this conversation — signals, not claims. It grows with every verified shift, certification, and review.',
|
|
},
|
|
es: {
|
|
badge: 'Paso 2 · Conoce a Owliver',
|
|
title: 'Habla con Owliver',
|
|
subtitle: 'No formularios. Solo una conversación de 5 minutos. Owliver aprende quién eres y crea tu perfil automáticamente.',
|
|
hi: (n) => `Hola ${n}, soy Owliver`,
|
|
introP: 'Te haré algunas preguntas amigables. Solo habla naturalmente — no hay respuestas incorrectas.',
|
|
banner: 'Solo una charla casual — no hay respuestas correctas ni incorrectas. Lo convierto en tu perfil por ti.',
|
|
speaks: 'Owliver habla',
|
|
muted: 'Silenciado',
|
|
start: 'Empezar a hablar',
|
|
question: 'Pregunta',
|
|
of: 'de',
|
|
skipped: 'omitido',
|
|
listening: 'Escuchando…',
|
|
placeholder: 'Escribe tu respuesta o toca el micrófono para hablar',
|
|
speak: 'Hablar',
|
|
stop: 'Detener',
|
|
skip: 'Saltar',
|
|
send: 'Enviar',
|
|
building1: 'Owliver está creando tu perfil…',
|
|
building2: 'Convirtiendo nuestra conversación en tu KROW Identity.',
|
|
done: 'Owliver creó tu perfil',
|
|
seeId: 'Ver mi KROW ID',
|
|
yrs: 'años',
|
|
dash: '—',
|
|
notEnoughTitle: 'Owliver no captó suficiente',
|
|
notEnoughBody: 'No obtuve mucho de nuestra charla — intenta de nuevo y responde un par de preguntas para que pueda crear tu perfil.',
|
|
chatAgain: 'Charlemos de nuevo',
|
|
goToProfile: 'Ir a mi perfil',
|
|
labels: { desired: 'Rol deseado', experience: 'Experiencia', transport: 'Transporte', salary: 'Salario', leadership: 'Liderazgo', skills: 'Habilidades', languages: 'Idiomas', industries: 'Industrias', availability: 'Disponibilidad', strengths: 'Fortalezas', workingOn: 'Por mejorar', personality: 'Personalidad', identity: 'Identidad', learningSpeed: 'Velocidad de aprendizaje', reliability: 'Confiabilidad', communication: 'Comunicación', leadershipIndex: 'Índice de liderazgo', adaptability: 'Adaptabilidad', workStyle: 'Estilo de trabajo', cultureMatch: 'Encaje cultural', reputation: 'Reputación base' },
|
|
dnaHeader: 'Career DNA',
|
|
dnaSub: 'Creado a partir de esta conversación — señales, no afirmaciones. Crece con cada turno, certificación y reseña verificados.',
|
|
},
|
|
};
|
|
|
|
interface OwliverProps {
|
|
compact?: any;
|
|
}
|
|
|
|
export default function Owliver({ compact = false }: OwliverProps) {
|
|
const navigate = useNavigate();
|
|
const { data: profile, isLoading } = useWorkerProfile();
|
|
const updateProfile = useUpdateWorkerProfile();
|
|
|
|
const [phase, setPhase] = useState('intro'); // intro | active | building | done
|
|
const [messages, setMessages] = useState([]);
|
|
const [transcript, setTranscript] = useState('');
|
|
const [isListening, setIsListening] = useState(false);
|
|
const [isSpeaking, setIsSpeaking] = useState(false);
|
|
const [, setTimeLeft] = useState(TOTAL_SECONDS);
|
|
const [extracted, setExtracted] = useState(null);
|
|
const [error, setError] = useState('');
|
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
|
const [language, setLanguage] = useState('en');
|
|
|
|
const recognitionRef = useRef(null);
|
|
const lastUserTime = useRef(null);
|
|
const timerRef = useRef(null);
|
|
const scrollRef = useRef(null);
|
|
|
|
const workerName = profile?.full_name || 'there';
|
|
const t = UI[language];
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}, [messages, isSpeaking]);
|
|
|
|
// Warm owl voice — slightly higher pitch, slower, gentle
|
|
const speak = useCallback((text) => {
|
|
return new Promise<void>((resolve) => {
|
|
if (!ttsEnabled || !window.speechSynthesis) { resolve(); return; }
|
|
window.speechSynthesis.cancel();
|
|
const u = new SpeechSynthesisUtterance(text);
|
|
const target = language === 'es' ? 'es' : 'en';
|
|
u.lang = language === 'es' ? 'es-ES' : 'en-US';
|
|
const voices = window.speechSynthesis.getVoices();
|
|
const langVoices = voices.filter(v => v.lang?.toLowerCase().startsWith(target));
|
|
if (langVoices.length > 0) {
|
|
const preferred =
|
|
langVoices.find(v => v.localService && v.lang.toLowerCase() === (language === 'es' ? 'es-es' : 'en-us')) ||
|
|
langVoices.find(v => v.localService) ||
|
|
langVoices.find(v => v.name.toLowerCase().includes('google')) ||
|
|
langVoices[0];
|
|
u.voice = preferred;
|
|
}
|
|
u.rate = 0.96;
|
|
u.pitch = 1.12;
|
|
u.onstart = () => setIsSpeaking(true);
|
|
u.onend = () => { setIsSpeaking(false); resolve(); };
|
|
u.onerror = () => { setIsSpeaking(false); resolve(); };
|
|
window.speechSynthesis.speak(u);
|
|
});
|
|
}, [ttsEnabled, language]);
|
|
|
|
const stopListening = useCallback(() => {
|
|
if (recognitionRef.current) { recognitionRef.current.stop(); recognitionRef.current = null; }
|
|
setIsListening(false);
|
|
}, []);
|
|
|
|
const askNext = useCallback(async (currentMessages) => {
|
|
const qNumber = currentMessages.filter(m => m.role === 'assistant').length + 1;
|
|
if (qNumber > QUESTION_COUNT) {
|
|
await finishChat(currentMessages);
|
|
return;
|
|
}
|
|
try {
|
|
const question = await owliverQuestion(currentMessages, workerName, qNumber, language);
|
|
const aiMsg = { role: 'assistant', content: question, timestamp: new Date().toISOString() };
|
|
const newMessages = [...currentMessages, aiMsg];
|
|
setMessages(newMessages);
|
|
lastUserTime.current = Date.now();
|
|
await speak(question);
|
|
} catch {
|
|
setError('Owliver lost his train of thought. Try again.');
|
|
}
|
|
}, [workerName, speak, language]);
|
|
|
|
const startListening = useCallback(() => {
|
|
const win = /** @type {any} */ (window);
|
|
const SR = win.SpeechRecognition || win.webkitSpeechRecognition;
|
|
if (!SR) { setError('Voice input not supported here — type your answer below.'); return; }
|
|
// Yield Owliver's voice so the mic picks up the user, not Owliver.
|
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
setIsSpeaking(false);
|
|
const base = transcript;
|
|
if (!base) setTranscript('');
|
|
setError('');
|
|
const rec = new SR();
|
|
rec.continuous = true;
|
|
rec.interimResults = true;
|
|
rec.lang = language === 'es' ? 'es-ES' : 'en-US';
|
|
rec.onresult = (event) => {
|
|
let text = '';
|
|
for (let i = 0; i < event.results.length; i++) text += event.results[i][0].transcript;
|
|
setTranscript((base ? base + ' ' : '') + text.trim());
|
|
};
|
|
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(`Mic error: ${event.error}`); setIsListening(false); };
|
|
rec.onend = () => setIsListening(false);
|
|
recognitionRef.current = rec;
|
|
rec.start();
|
|
setIsListening(true);
|
|
}, [language, transcript]);
|
|
|
|
const submitAnswer = useCallback(() => {
|
|
if (!transcript.trim()) return;
|
|
stopListening();
|
|
const userMsg = { role: 'user', content: transcript.trim(), timestamp: new Date().toISOString() };
|
|
const newMessages = [...messages, userMsg];
|
|
setMessages(newMessages);
|
|
setTranscript('');
|
|
setTimeout(() => askNext(newMessages), 300);
|
|
}, [transcript, messages, stopListening, askNext]);
|
|
|
|
const skipAnswer = useCallback(() => {
|
|
stopListening();
|
|
const userMsg = { role: 'user', content: language === 'es' ? '(Prefiero saltar esta)' : "(I'd rather skip this one)", timestamp: new Date().toISOString(), skipped: true };
|
|
const newMessages = [...messages, userMsg];
|
|
setMessages(newMessages);
|
|
setTranscript('');
|
|
setTimeout(() => askNext(newMessages), 250);
|
|
}, [messages, stopListening, askNext, language]);
|
|
|
|
const normalizeAvail = (arr) => {
|
|
if (!Array.isArray(arr)) return [];
|
|
return arr.map(a => {
|
|
const hit = AVAIL_TOKENS.find(t => t.toLowerCase() === String(a).toLowerCase());
|
|
return hit || a;
|
|
});
|
|
};
|
|
|
|
const finishChat = useCallback(async (finalMessages) => {
|
|
setPhase('building');
|
|
stopListening();
|
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
// Only send real (non-skipped) answers to the profile builder.
|
|
const realMessages = (finalMessages || []).filter(m => !(m.role === 'user' && m.skipped));
|
|
const realAnswers = realMessages.filter(m => m.role === 'user');
|
|
if (realAnswers.length === 0) {
|
|
setPhase('notenough');
|
|
return;
|
|
}
|
|
try {
|
|
const result = await buildProfileFromConversation(realMessages, workerName, language);
|
|
const patch = {
|
|
career_goals: result.career_goals || '',
|
|
desired_position: result.desired_position || '',
|
|
current_position: result.current_position || '',
|
|
experience_years: result.experience_years || 0,
|
|
experience: result.experience || [],
|
|
skills: result.skills || [],
|
|
languages: result.languages || [],
|
|
availability: normalizeAvail(result.availability),
|
|
transportation: result.transportation || '',
|
|
certifications: result.certifications || [],
|
|
personality: result.personality || '',
|
|
strengths: result.strengths || [],
|
|
weaknesses: result.weaknesses || [],
|
|
industries: result.industries || [],
|
|
salary_expectations: result.salary_expectations || '',
|
|
leadership_potential: result.leadership_potential || 0,
|
|
communication_style: result.communication_style || '',
|
|
};
|
|
const merged = { ...(profile || {}), ...patch };
|
|
const fullPatch = { ...patch, ...recalcProfilePatch(merged) };
|
|
if (profile?.id) {
|
|
await updateProfile.mutateAsync({ id: profile.id, data: fullPatch });
|
|
}
|
|
setExtracted({ ...result, availability: normalizeAvail(result.availability) });
|
|
setPhase('done');
|
|
} catch {
|
|
setError('Owliver could not finish building your profile. Try again.');
|
|
setPhase('active');
|
|
}
|
|
}, [workerName, profile, updateProfile, language, stopListening]);
|
|
|
|
const startChat = useCallback(() => {
|
|
setPhase('active');
|
|
setTimeLeft(TOTAL_SECONDS);
|
|
setMessages([]);
|
|
askNext([]);
|
|
}, [askNext]);
|
|
|
|
// Timer
|
|
useEffect(() => {
|
|
if (phase !== 'active') return;
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(timerRef.current);
|
|
stopListening();
|
|
setMessages((cur) => { if (cur.length > 0) finishChat(cur); return cur; });
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
return () => clearInterval(timerRef.current);
|
|
}, [phase, finishChat, stopListening]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
stopListening();
|
|
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
}, [stopListening]);
|
|
|
|
if (isLoading) {
|
|
return <div className="flex justify-center py-24"><Loader2 className="w-6 h-6 animate-spin text-[#0838E0]" /></div>;
|
|
}
|
|
|
|
return (
|
|
<div className={cn('max-w-2xl mx-auto space-y-6', compact && 'h-full flex flex-col')}>
|
|
{/* Hero — page only */}
|
|
{!compact && (
|
|
<div className="rounded-2xl bg-[#0838E0] p-6 sm:p-8 text-white">
|
|
<span className="inline-flex items-center gap-1.5 text-[11px] font-medium tracking-wide opacity-90 bg-white/15 px-2.5 py-1 rounded-full">
|
|
🦉 {t.badge}
|
|
</span>
|
|
<h1 className="text-[26px] sm:text-[30px] font-bold mt-3">{t.title}</h1>
|
|
<p className="text-[13px] opacity-90 mt-1.5 max-w-md">
|
|
{t.subtitle}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Intro */}
|
|
{phase === 'intro' && (
|
|
compact ? (
|
|
<div className="flex flex-col h-full justify-between">
|
|
<p className="text-[13px] text-[#6B7280]">{t.introP}</p>
|
|
<div className="space-y-3 mt-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0838E0]">
|
|
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
|
|
</button>
|
|
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
|
|
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
|
|
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
|
|
</div>
|
|
</div>
|
|
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
|
|
<Mic className="w-4 h-4 mr-2" /> {t.start}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
|
|
<div className="flex items-center gap-4">
|
|
<OwliverAvatar className="w-16 h-16" />
|
|
<div>
|
|
<h2 className="text-[16px] font-semibold text-[#111827]">{t.hi(workerName.split(' ')[0])}</h2>
|
|
<p className="text-[13px] text-[#6B7280]">{t.introP}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-[12px] text-[#6B7280] bg-[#F9FAFB] rounded-lg px-3 py-2.5">
|
|
<Sparkles className="w-4 h-4 text-[#F59E0B] shrink-0" />
|
|
<span>{t.banner}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<button onClick={() => setTtsEnabled(!ttsEnabled)} className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[#0838E0]">
|
|
{ttsEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />} {ttsEnabled ? t.speaks : t.muted}
|
|
</button>
|
|
<div className="inline-flex rounded-full border border-[#E5E7EB] overflow-hidden">
|
|
<button onClick={() => setLanguage('en')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'en' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>EN</button>
|
|
<button onClick={() => setLanguage('es')} className={cn('px-3 py-1 text-[12px] font-semibold', language === 'es' ? 'bg-[#0838E0] text-white' : 'bg-white text-[#6B7280]')}>ES</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Button onClick={startChat} className="w-full h-12 text-[14px] bg-[#0838E0] hover:bg-[#062BAF]">
|
|
<Mic className="w-4 h-4 mr-2" /> {t.start}
|
|
</Button>
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Active conversation */}
|
|
{phase === 'active' && (
|
|
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-5 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<OwliverAvatar className="w-9 h-9" rounded="rounded-xl" />
|
|
<span className="text-[14px] font-semibold text-[#111827]">Owliver</span>
|
|
</div>
|
|
<span className="text-[12px] font-medium text-[#6B7280] px-2.5 py-1 rounded-lg bg-[#F3F4F6]">
|
|
{t.question} {Math.min(messages.filter(m => m.role === 'assistant').length, QUESTION_COUNT)} {t.of} {QUESTION_COUNT}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="h-1.5 w-full rounded-full bg-[#F3F4F6] overflow-hidden">
|
|
<div className="h-full bg-[#0838E0] transition-all duration-500" style={{ width: `${Math.min(100, (messages.filter(m => m.role === 'user').length / QUESTION_COUNT) * 100)}%` }} />
|
|
</div>
|
|
|
|
<div ref={scrollRef} className="h-[320px] overflow-y-auto space-y-3 p-1">
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={cn('flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
|
|
{msg.skipped ? (
|
|
<span className="text-[11px] text-[#9CA3AF] italic px-1">{t.skipped}</span>
|
|
) : (
|
|
<div className={cn(
|
|
'max-w-[82%] rounded-2xl px-4 py-2.5 text-[14px] leading-snug',
|
|
msg.role === 'user' ? 'bg-[#0838E0] text-white rounded-br-sm' : 'bg-[#F3F4F6] text-[#111827] rounded-bl-sm'
|
|
)}>
|
|
<p>{msg.content}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{isSpeaking && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-[#F3F4F6] rounded-2xl rounded-bl-sm px-4 py-3 flex gap-1">
|
|
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<span className="w-2 h-2 bg-[#9CA3AF] rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-[12px] text-[#EF4444]">{error}</p>}
|
|
|
|
<div className="border-t border-[#E5E7EB] pt-3">
|
|
<textarea
|
|
value={transcript}
|
|
onChange={(e) => setTranscript(e.target.value)}
|
|
placeholder={isListening ? t.listening : t.placeholder}
|
|
className="w-full min-h-[60px] p-3 text-[14px] rounded-lg border border-[#E5E7EB] focus:border-[#0838E0] focus:outline-none resize-none"
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitAnswer(); } }}
|
|
/>
|
|
<div className="flex items-center justify-between gap-2 mt-2">
|
|
<button
|
|
onClick={isListening ? stopListening : startListening}
|
|
className={cn(
|
|
'inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold transition-colors',
|
|
isListening ? 'bg-[#FEE2E2] text-[#991B1B]' : 'bg-[#0838E0] text-white hover:bg-[#062BAF]'
|
|
)}
|
|
>
|
|
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
|
|
{isListening ? t.stop : t.speak}
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={skipAnswer}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2.5 rounded-xl text-[13px] font-medium text-[#6B7280] hover:bg-[#F3F4F6] transition-colors"
|
|
>
|
|
{t.skip} <ArrowRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
<Button onClick={submitAnswer} disabled={!transcript.trim()} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
|
<Send className="w-4 h-4 mr-1.5" /> {t.send}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Not enough info */}
|
|
{phase === 'notenough' && (
|
|
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-8 text-center space-y-4">
|
|
<OwliverAvatar className="w-16 h-16 mx-auto" />
|
|
<h2 className="text-[18px] font-semibold text-[#111827]">{t.notEnoughTitle}</h2>
|
|
<p className="text-[13px] text-[#6B7280] max-w-sm mx-auto">{t.notEnoughBody}</p>
|
|
<div className="flex flex-col sm:flex-row gap-2 justify-center pt-2">
|
|
<Button onClick={startChat} className="bg-[#0838E0] hover:bg-[#062BAF]">
|
|
<RefreshCw className="w-4 h-4 mr-2" /> {t.chatAgain}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => navigate('/me')}>
|
|
{t.goToProfile} <ArrowRight className="w-4 h-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Building */}
|
|
{phase === 'building' && (
|
|
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-10 text-center">
|
|
<OwliverAvatar className="w-20 h-20 mx-auto mb-4 animate-pulse" />
|
|
<Loader2 className="w-7 h-7 text-[#0838E0] animate-spin mx-auto mb-3" />
|
|
<p className="text-[14px] font-medium text-[#111827]">{t.building1}</p>
|
|
<p className="text-[12px] text-[#6B7280] mt-1">{t.building2}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Done */}
|
|
{phase === 'done' && extracted && (
|
|
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 space-y-5">
|
|
<div className="text-center">
|
|
<OwliverAvatar className="w-16 h-16 mx-auto mb-2" />
|
|
<h2 className="text-[18px] font-semibold text-[#111827]">{t.done}</h2>
|
|
{extracted.summary && <p className="text-[13px] text-[#6B7280] mt-1.5 italic">"{extracted.summary}"</p>}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-[12px]">
|
|
<SummaryField label={t.labels.desired} value={extracted.desired_position} empty={t.dash} />
|
|
<SummaryField label={t.labels.experience} value={extracted.experience_years ? `${extracted.experience_years} ${t.yrs}` : ''} empty={t.dash} />
|
|
<SummaryField label={t.labels.transport} value={extracted.transportation} empty={t.dash} />
|
|
<SummaryField label={t.labels.salary} value={extracted.salary_expectations} empty={t.dash} />
|
|
<SummaryField label={t.labels.leadership} value={extracted.leadership_potential ? `${extracted.leadership_potential}/100` : ''} full empty={t.dash} />
|
|
<SummaryField label={t.labels.skills} value={(extracted.skills || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.languages} value={(extracted.languages || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.industries} value={(extracted.industries || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.availability} value={(extracted.availability || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.strengths} value={(extracted.strengths || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.workingOn} value={(extracted.weaknesses || []).join(', ')} full empty={t.dash} />
|
|
<SummaryField label={t.labels.personality} value={extracted.personality} full empty={t.dash} />
|
|
</div>
|
|
|
|
{extracted.career_dna && (
|
|
<div className="rounded-xl border border-[#0838E0]/15 bg-[#EEF3FE]/60 p-4 space-y-3">
|
|
<div>
|
|
<p className="text-[13px] font-bold text-[#0838E0] flex items-center gap-1.5"><Sparkles className="w-3.5 h-3.5" /> {t.dnaHeader}</p>
|
|
<p className="text-[11px] text-[#6B7280] mt-0.5">{t.dnaSub}</p>
|
|
</div>
|
|
{extracted.career_dna.identity && (
|
|
<p className="text-[13px] font-medium text-[#0F172A] italic">"{extracted.career_dna.identity}"</p>
|
|
)}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
<DnaScore label={t.labels.learningSpeed} value={extracted.career_dna.learning_speed} />
|
|
<DnaScore label={t.labels.reliability} value={extracted.career_dna.reliability} />
|
|
<DnaScore label={t.labels.communication} value={extracted.career_dna.communication_score} />
|
|
<DnaScore label={t.labels.leadershipIndex} value={extracted.career_dna.leadership_index} />
|
|
<DnaScore label={t.labels.adaptability} value={extracted.career_dna.adaptability_score} />
|
|
<DnaScore label={t.labels.reputation} value={extracted.career_dna.reputation_score} />
|
|
</div>
|
|
{extracted.career_dna.work_style && (
|
|
<SummaryField label={t.labels.workStyle} value={extracted.career_dna.work_style} full empty={t.dash} />
|
|
)}
|
|
{(extracted.career_dna.culture_match || []).length > 0 && (
|
|
<SummaryField label={t.labels.cultureMatch} value={extracted.career_dna.culture_match.join(', ')} full empty={t.dash} />
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button onClick={() => navigate('/me')} className="w-full h-11 bg-[#0838E0] hover:bg-[#062BAF]">
|
|
<Sparkles className="w-4 h-4 mr-2" /> {t.seeId} <ArrowRight className="w-4 h-4 ml-1" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface SummaryFieldProps {
|
|
label?: string;
|
|
value?: any;
|
|
full?: any;
|
|
empty?: any;
|
|
}
|
|
|
|
function SummaryField({ label, value, full = false, empty = '—' }: SummaryFieldProps) {
|
|
const has = value && String(value).trim();
|
|
return (
|
|
<div className={cn('rounded-lg bg-[#F9FAFB] p-2.5', full && 'col-span-2')}>
|
|
<div className="text-[10px] text-[#6B7280] uppercase tracking-wide">{label}</div>
|
|
<div className="text-[12px] text-[#111827] font-medium mt-0.5">{has ? value : empty}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface DnaScoreProps {
|
|
label?: string;
|
|
value?: any;
|
|
}
|
|
|
|
function DnaScore({ label, value }: DnaScoreProps) {
|
|
const v = typeof value === 'number' ? value : 0;
|
|
const tone = v >= 75 ? 'text-[#16A34A]' : v >= 50 ? 'text-[#0838E0]' : v > 0 ? 'text-[#D97706]' : 'text-[#9CA3AF]';
|
|
return (
|
|
<div className="rounded-lg bg-white p-2.5 border border-[#E5E7EB]">
|
|
<div className="text-[10px] text-[#6B7280] uppercase tracking-wide">{label}</div>
|
|
<div className={cn('text-[14px] font-bold mt-0.5', tone)}>{v > 0 ? v : '—'}</div>
|
|
</div>
|
|
);
|
|
} |