viraj-421's picture
Initial commit: AI-Powered Mock Interview Platform with Auth, Certificates, and Timer
a0da743
Raw History Blame Contribute Delete
22.8 kB
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useRouter } from 'next/navigation';
import { useInterview } from '@/context/InterviewContext';
import LoadingScreen from '@/components/LoadingScreen';
import ScoreRing from '@/components/ScoreRing';
import { Question, ChatMessage, Evaluation } from '@/types/interview';
import {
Send,
Mic,
MicOff,
Clock,
ChevronRight,
CheckCircle2,
AlertCircle,
Lightbulb,
XCircle,
SkipForward,
Trophy,
MessageSquare,
Volume2,
} from 'lucide-react';
export default function SessionPage() {
const router = useRouter();
const {
session,
setQuestions,
addMessage,
submitAnswer,
addEvaluation,
nextQuestion,
setStatus,
completeSession,
} = useInterview();
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isEvaluating, setIsEvaluating] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [showEvaluation, setShowEvaluation] = useState<Evaluation | null>(null);
const [elapsedTime, setElapsedTime] = useState(0);
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const recognitionRef = useRef<any>(null);
// Redirect if no session
useEffect(() => {
if (!session) {
router.push('/interview');
}
}, [session, router]);
// Timer
useEffect(() => {
if (session?.status === 'in-progress') {
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - session.startedAt) / 1000);
setElapsedTime(elapsed);
// Auto-end if time limit is set and exceeded
if (session.config.timeLimit > 0) {
const limitSeconds = session.config.timeLimit * 60;
if (elapsed >= limitSeconds) {
clearInterval(interval);
handleEndInterview();
}
}
}, 1000);
return () => clearInterval(interval);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.status, session?.startedAt]);
// Auto scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [session?.messages]);
// Generate questions on mount
useEffect(() => {
if (session && session.questions.length === 0 && isLoading) {
generateQuestions();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session]);
const generateQuestions = async () => {
if (!session) return;
setError(null);
try {
const res = await fetch('/api/generate-questions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-gemini-api-key': localStorage.getItem('gemini-api-key') || ''
},
body: JSON.stringify({
role: session.config.role,
difficulty: session.config.difficulty,
questionCount: session.config.questionCount,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const questions: Question[] = data.questions;
setQuestions(questions);
// Add welcome message
addMessage({
id: 'welcome',
role: 'ai',
content: `Welcome to your **${session.config.role.title}** mock interview! 🎯\n\nI'll be asking you **${questions.length} questions** at **${session.config.difficulty}** difficulty.\n\nTake your time to think before answering. Let's begin!`,
timestamp: Date.now(),
});
// Add first question
setTimeout(() => {
addMessage({
id: `q-${questions[0].id}`,
role: 'ai',
content: `**Question 1/${questions.length}** β€’ _${questions[0].category}_\n\n${questions[0].text}`,
timestamp: Date.now(),
questionId: questions[0].id,
});
setIsLoading(false);
}, 800);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate questions');
setIsLoading(false);
}
};
const handleSubmitAnswer = async () => {
if (!session || !input.trim() || isEvaluating) return;
const currentQ = session.questions[session.currentQuestionIndex];
if (!currentQ) return;
const answerText = input.trim();
setInput('');
// Add user message
addMessage({
id: `a-${currentQ.id}`,
role: 'user',
content: answerText,
timestamp: Date.now(),
questionId: currentQ.id,
});
// Submit answer
submitAnswer({
questionId: currentQ.id,
text: answerText,
timestamp: Date.now(),
});
// Evaluate
setIsEvaluating(true);
try {
const res = await fetch('/api/evaluate-answer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-gemini-api-key': localStorage.getItem('gemini-api-key') || ''
},
body: JSON.stringify({
question: currentQ.text,
answer: answerText,
role: session.config.role.title,
difficulty: session.config.difficulty,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const evaluation: Evaluation = {
...data.evaluation,
questionId: currentQ.id,
};
addEvaluation(evaluation);
setShowEvaluation(evaluation);
// Add AI feedback message
addMessage({
id: `eval-${currentQ.id}`,
role: 'ai',
content: buildEvaluationMessage(evaluation),
timestamp: Date.now(),
questionId: currentQ.id,
evaluation,
});
} catch (err) {
addMessage({
id: `err-${currentQ.id}`,
role: 'ai',
content: `⚠️ I couldn't evaluate that response. ${err instanceof Error ? err.message : 'Please try again.'}`,
timestamp: Date.now(),
});
}
setIsEvaluating(false);
};
const handleNextQuestion = useCallback(() => {
if (!session) return;
setShowEvaluation(null);
const nextIndex = session.currentQuestionIndex + 1;
if (nextIndex >= session.questions.length) {
// End of interview
handleEndInterview();
return;
}
nextQuestion();
const nextQ = session.questions[nextIndex];
addMessage({
id: `q-${nextQ.id}`,
role: 'ai',
content: `**Question ${nextIndex + 1}/${session.questions.length}** β€’ _${nextQ.category}_\n\n${nextQ.text}`,
timestamp: Date.now(),
questionId: nextQ.id,
});
inputRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, nextQuestion, addMessage]);
const handleEndInterview = async () => {
if (!session) return;
setIsGeneratingSummary(true);
try {
const res = await fetch('/api/session-summary', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-gemini-api-key': localStorage.getItem('gemini-api-key') || ''
},
body: JSON.stringify({
role: session.config.role.title,
difficulty: session.config.difficulty,
evaluations: session.evaluations,
questions: session.questions,
answers: session.answers,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
completeSession(data.summary);
router.push('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate summary');
setIsGeneratingSummary(false);
}
};
const handleSkipQuestion = () => {
if (!session) return;
const currentQ = session.questions[session.currentQuestionIndex];
addMessage({
id: `skip-${currentQ.id}`,
role: 'user',
content: '_Skipped this question_',
timestamp: Date.now(),
questionId: currentQ.id,
});
submitAnswer({
questionId: currentQ.id,
text: '[SKIPPED]',
timestamp: Date.now(),
});
handleNextQuestion();
};
// Voice input
const toggleVoiceInput = () => {
if (isListening) {
recognitionRef.current?.stop();
setIsListening(false);
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = window as any;
const SpeechRecognitionAPI = win.SpeechRecognition || win.webkitSpeechRecognition;
if (!SpeechRecognitionAPI) {
setError('Voice input is not supported in your browser.');
return;
}
const recognition = new SpeechRecognitionAPI();
recognition.continuous = true;
recognition.interimResults = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
setInput(transcript);
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognition.start();
recognitionRef.current = recognition;
setIsListening(true);
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
if (!session) return null;
if (isLoading) {
return <LoadingScreen message="AI is preparing your interview questions..." />;
}
if (isGeneratingSummary) {
return <LoadingScreen message="Generating your performance summary..." />;
}
const progress = session.questions.length > 0
? ((session.currentQuestionIndex + (session.evaluations.length > session.currentQuestionIndex ? 1 : 0)) / session.questions.length) * 100
: 0;
const timeLimitSeconds = session.config.timeLimit * 60;
const remainingSeconds = session.config.timeLimit > 0 ? Math.max(0, timeLimitSeconds - elapsedTime) : 0;
const isRunningOut = session.config.timeLimit > 0 && remainingSeconds <= 120;
const displayTime = session.config.timeLimit > 0 ? remainingSeconds : elapsedTime;
return (
<div className="min-h-screen flex flex-col">
{/* Top Bar */}
<div className="glass sticky top-16 z-40" style={{ borderBottom: '1px solid var(--border)' }}>
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-xl">{session.config.role.icon}</span>
<div>
<h2 className="font-semibold text-sm">{session.config.role.title}</h2>
<p className="text-xs capitalize" style={{ color: 'var(--muted)' }}>
{session.config.difficulty} β€’ Q{session.currentQuestionIndex + 1}/{session.questions.length}
</p>
</div>
</div>
<div className="flex items-center gap-4">
{/* Progress bar */}
<div className="hidden sm:block w-32">
<div className="progress-bar">
<div className="progress-bar-fill" style={{ width: `${progress}%` }} />
</div>
</div>
{/* Timer */}
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors duration-300" style={{
background: isRunningOut ? 'rgba(255, 107, 107, 0.1)' : 'var(--card)',
border: `1px solid ${isRunningOut ? 'rgba(255, 107, 107, 0.3)' : 'var(--border)'}`
}}>
<Clock className="w-3.5 h-3.5" style={{ color: isRunningOut ? 'var(--danger)' : 'var(--muted)' }} />
<span className="text-sm font-mono" style={{ color: isRunningOut ? 'var(--danger)' : 'var(--foreground)' }}>
{formatTime(displayTime)}
</span>
</div>
{/* End button */}
<button
onClick={handleEndInterview}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-300 cursor-pointer"
style={{
background: 'rgba(255, 107, 107, 0.1)',
border: '1px solid rgba(255, 107, 107, 0.2)',
color: 'var(--danger)',
}}
>
End
</button>
</div>
</div>
</div>
{/* Error bar */}
{error && (
<div className="max-w-5xl mx-auto px-4 mt-4">
<div className="flex items-center gap-3 p-4 rounded-xl" style={{ background: 'rgba(255, 107, 107, 0.1)', border: '1px solid rgba(255, 107, 107, 0.2)' }}>
<AlertCircle className="w-5 h-5 shrink-0" style={{ color: 'var(--danger)' }} />
<p className="text-sm" style={{ color: 'var(--danger)' }}>{error}</p>
<button onClick={() => setError(null)} className="ml-auto cursor-pointer">
<XCircle className="w-4 h-4" style={{ color: 'var(--danger)' }} />
</button>
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto py-6 px-4">
<div className="max-w-3xl mx-auto space-y-4">
<AnimatePresence initial={false}>
{session.messages.map((msg) => (
<ChatBubble key={msg.id} message={msg} roleColor={session.config.role.color} />
))}
</AnimatePresence>
{/* Evaluating indicator */}
{isEvaluating && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-3"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0" style={{ background: 'var(--gradient-primary)' }}>
<span className="text-sm">πŸ€–</span>
</div>
<div className="glass-light px-4 py-3 rounded-2xl rounded-tl-md">
<div className="typing-dots flex gap-1">
<span></span>
<span></span>
<span></span>
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Evaluation Drawer */}
<AnimatePresence>
{showEvaluation && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="glass sticky bottom-0 z-30"
style={{ borderTop: '1px solid var(--border)' }}
>
<div className="max-w-3xl mx-auto p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ScoreRing score={showEvaluation.score} maxScore={10} size={56} strokeWidth={5} showPercentage={false} />
<div>
<p className="font-semibold text-sm">Score: {showEvaluation.score}/10</p>
<div className="flex gap-3 mt-1">
{[
{ label: 'Relevance', value: showEvaluation.relevance },
{ label: 'Clarity', value: showEvaluation.clarity },
{ label: 'Accuracy', value: showEvaluation.technicalAccuracy },
].map((m) => (
<span key={m.label} className="text-xs" style={{ color: 'var(--muted)' }}>
{m.label}: <span style={{ color: m.value >= 7 ? 'var(--success)' : m.value >= 4 ? 'var(--warning)' : 'var(--danger)' }}>{m.value}/10</span>
</span>
))}
</div>
</div>
</div>
<button
onClick={handleNextQuestion}
className="btn-primary flex items-center gap-2 text-sm cursor-pointer"
>
{session.currentQuestionIndex + 1 >= session.questions.length ? (
<>
<Trophy className="w-4 h-4" />
Finish
</>
) : (
<>
Next Question
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Input Area */}
{!showEvaluation && session.status !== 'completed' && (
<div className="glass sticky bottom-0 z-20" style={{ borderTop: '1px solid var(--border)' }}>
<div className="max-w-3xl mx-auto p-4">
<div className="flex items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmitAnswer();
}
}}
placeholder="Type your answer... (Shift+Enter for new line)"
rows={2}
className="w-full resize-none rounded-xl px-4 py-3 text-sm focus:outline-none transition-all duration-300"
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
color: 'var(--foreground)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--primary)';
e.currentTarget.style.boxShadow = '0 0 20px rgba(108, 92, 231, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--border)';
e.currentTarget.style.boxShadow = 'none';
}}
disabled={isEvaluating}
/>
</div>
{/* Voice Button */}
<button
onClick={toggleVoiceInput}
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer"
style={{
background: isListening ? 'var(--danger)' : 'var(--card)',
border: `1px solid ${isListening ? 'var(--danger)' : 'var(--border)'}`,
color: isListening ? 'white' : 'var(--muted)',
}}
title={isListening ? 'Stop listening' : 'Voice input'}
>
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />}
</button>
{/* Skip Button */}
<button
onClick={handleSkipQuestion}
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer"
style={{
background: 'var(--card)',
border: '1px solid var(--border)',
color: 'var(--muted)',
}}
title="Skip question"
>
<SkipForward className="w-4 h-4" />
</button>
{/* Send Button */}
<button
onClick={handleSubmitAnswer}
disabled={!input.trim() || isEvaluating}
className="w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 shrink-0 cursor-pointer disabled:opacity-40"
style={{
background: input.trim() ? 'var(--gradient-primary)' : 'var(--card)',
border: `1px solid ${input.trim() ? 'transparent' : 'var(--border)'}`,
color: input.trim() ? 'white' : 'var(--muted)',
}}
>
<Send className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
);
}
function buildEvaluationMessage(evaluation: Evaluation): string {
const scoreEmoji = evaluation.score >= 8 ? '🌟' : evaluation.score >= 6 ? 'πŸ‘' : evaluation.score >= 4 ? 'πŸ’‘' : 'πŸ“š';
let msg = `${scoreEmoji} **Score: ${evaluation.score}/10**\n\n`;
msg += `πŸ“Š Relevance: ${evaluation.relevance}/10 β€’ Clarity: ${evaluation.clarity}/10 β€’ Accuracy: ${evaluation.technicalAccuracy}/10 β€’ Confidence: ${evaluation.confidence}/10\n\n`;
if (evaluation.strengths.length > 0) {
msg += `βœ… **Strengths:**\n${evaluation.strengths.map(s => `β€’ ${s}`).join('\n')}\n\n`;
}
if (evaluation.weaknesses.length > 0) {
msg += `⚠️ **Areas to Improve:**\n${evaluation.weaknesses.map(w => `β€’ ${w}`).join('\n')}\n\n`;
}
if (evaluation.suggestions.length > 0) {
msg += `πŸ’‘ **Suggestions:**\n${evaluation.suggestions.map(s => `β€’ ${s}`).join('\n')}\n\n`;
}
if (evaluation.sampleAnswer) {
msg += `πŸ“ **Sample Answer:**\n_${evaluation.sampleAnswer}_`;
}
return msg;
}
function ChatBubble({ message, roleColor }: { message: ChatMessage; roleColor: string }) {
const isAI = message.role === 'ai';
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3 }}
className={`flex items-start gap-3 ${isAI ? '' : 'flex-row-reverse'}`}
>
{/* Avatar */}
<div
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 text-sm"
style={{
background: isAI ? 'var(--gradient-primary)' : roleColor,
}}
>
{isAI ? 'πŸ€–' : 'πŸ‘€'}
</div>
{/* Bubble */}
<div
className={`max-w-[80%] px-4 py-3 rounded-2xl text-sm leading-relaxed ${isAI ? 'rounded-tl-md' : 'rounded-tr-md'}`}
style={{
background: isAI ? 'var(--card)' : `${roleColor}15`,
border: `1px solid ${isAI ? 'var(--border)' : `${roleColor}30`}`,
}}
>
<div
dangerouslySetInnerHTML={{
__html: formatMessage(message.content),
}}
/>
</div>
</motion.div>
);
}
function formatMessage(text: string): string {
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/_(.*?)_/g, '<em>$1</em>')
.replace(/\n/g, '<br/>');
}