cpns / apps /web /src /components /test /AttemptTestView.tsx
rogasper's picture
feat: update package dependencies and enhance routing for email verification and password recovery. Add nodemailer and related types for email handling, and implement new routes for verify-email and forgot-password in the application. Improve sign-in and sign-up forms to support email verification flow.
bb6e880
Raw
History Blame
25.2 kB
import { useState, useEffect, useRef, useCallback, memo } from "react";
import { Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@labas/ui/components/button";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
import { formatTime } from "@/lib/time";
import { trpc } from "@/utils/trpc";
import { QuestionInput } from "./QuestionInput";
import { AccentKeyboard } from "./AccentKeyboard";
import { parseFurigana } from "@/lib/furigana";
interface AttemptTestViewProps {
attemptId: string;
pkg: any;
currentSectionIdx: number;
setCurrentSectionIdx: (idx: number) => void;
answers: Record<string, string>;
onAnswerChange: (questionId: string, sectionResultId: string | undefined, value: string) => void;
timeElapsed: number;
answeredCount: number;
totalQuestions: number;
onFinish: () => void;
onAbandon: () => void;
isFinished: boolean;
submittingQId: string | null;
markedQuestions: Set<string>;
toggleMarkQuestion: (questionId: string) => void;
startQuestionTimer: (questionId: string) => void;
}
interface QuestionCardProps {
q: any;
globalIdx: number;
answerValue: string;
sectionResultId: string | undefined;
isMarked: boolean;
isFinished: boolean;
isSubmitting: boolean;
onAnswerChange: (questionId: string, sectionResultId: string | undefined, value: string) => void;
toggleMarkQuestion: (questionId: string) => void;
}
const QuestionCard = memo(function QuestionCard({
q,
globalIdx,
answerValue,
sectionResultId,
isMarked,
isFinished,
isSubmitting,
onAnswerChange,
toggleMarkQuestion,
}: QuestionCardProps) {
const handleChange = useCallback(
(val: string) => {
onAnswerChange(q.id, sectionResultId, val);
},
[q.id, sectionResultId, onAnswerChange],
);
const handleAccentInsert = useCallback(
(char: string) => {
onAnswerChange(q.id, sectionResultId, answerValue + char);
},
[q.id, sectionResultId, answerValue, onAnswerChange],
);
const showAccentKeyboard = q.format === "fill_blank" || q.format === "sentence_completion";
return (
<div
id={`question-${q.id}`}
className={`bg-[var(--pure-white)] p-6 md:p-8 rounded-xl shadow-sm border-2 transition-all ${
isMarked ? "border-[var(--pomegranate-400)]" : "border-transparent"
}`}
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="bg-[var(--clay-black)] text-[var(--pure-white)] w-8 h-8 flex items-center justify-center rounded-lg font-bold text-sm">
{globalIdx}
</span>
<h2 className="text-xl font-bold text-[var(--clay-black)] capitalize">
{q.format.replace(/_/g, " ")}
</h2>
</div>
<button
onClick={() => toggleMarkQuestion(q.id)}
className={`shrink-0 w-10 h-10 flex items-center justify-center rounded-full transition-colors ${
isMarked
? "bg-[var(--pomegranate-400)]/20 text-[var(--pomegranate-400)]"
: "bg-[var(--oat-light)] text-[var(--warm-silver)] hover:text-[var(--pomegranate-400)]"
}`}
title={isMarked ? "Hapus tanda" : "Tandai untuk review"}
>
<MaterialIcon name={isMarked ? "bookmark" : "bookmark_border"} className="text-lg" />
</button>
</div>
<p className="text-[var(--warm-charcoal)] mb-6 font-medium leading-relaxed" dir={q._isRtl ? "rtl" : undefined}>
{q._useFurigana ? parseFurigana(q.questionText) : q.questionText}
</p>
<div className="pl-0">
{isSubmitting && (
<div className="text-xs text-[var(--matcha-600)] mb-2 flex items-center gap-1">
<MaterialIcon name="sync" className="text-xs animate-spin" />
Menyimpan...
</div>
)}
<QuestionInput
question={q}
value={answerValue}
onChange={handleChange}
disabled={isFinished}
/>
{showAccentKeyboard && (
<div className="mt-3">
<AccentKeyboard
examType={q._examType}
onInsert={handleAccentInsert}
disabled={isFinished}
/>
</div>
)}
</div>
</div>
);
});
export function AttemptTestView({
attemptId,
pkg,
currentSectionIdx,
setCurrentSectionIdx,
answers,
onAnswerChange,
timeElapsed,
answeredCount,
totalQuestions,
onFinish,
onAbandon,
isFinished,
submittingQId,
markedQuestions,
toggleMarkQuestion,
startQuestionTimer,
}: AttemptTestViewProps) {
const [showFinishDialog, setShowFinishDialog] = useState(false);
const [showAbandonDialog, setShowAbandonDialog] = useState(false);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const questionPanelRef = useRef<HTMLDivElement>(null);
const navSliderRef = useRef<HTMLDivElement>(null);
const hasInitRef = useRef(false);
console.log("[AttemptTestView] render, attemptId:", attemptId, "sections:", pkg.sections?.length);
const attemptQuery = useQuery(
trpc.attempt.getById.queryOptions(
{ id: attemptId },
{ enabled: !!attemptId },
),
);
const attempt = attemptQuery.data;
const currentSection = pkg.sections[currentSectionIdx];
const sectionData = attempt?.sections?.[currentSectionIdx];
const sectionResultId = sectionData?.sectionResultId;
const examType: string = pkg.examType ?? "";
const isRtl = examType === "TOAFL";
const useFurigana = examType === "JLPT" || examType === "TOPIK";
// If user picks an answer before attempt.getById finishes, persist once sectionResultId exists
const flushKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!sectionResultId || isFinished || !currentSection?.questions?.length) return;
const key = `${currentSectionIdx}:${sectionResultId}`;
if (flushKeyRef.current === key) return;
flushKeyRef.current = key;
for (const q of currentSection.questions as any[]) {
const v = answers[q.id];
if (v) void onAnswerChange(q.id, sectionResultId, v);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- flush once per section when ids align; `answers` read from committing render
}, [sectionResultId, currentSectionIdx, isFinished, currentSection?.questions?.length, onAnswerChange]);
if (!currentSection) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--warm-cream)]">
<p className="text-[var(--warm-charcoal)]">Section tidak ditemukan.</p>
</div>
);
}
// Build global question index across all sections
const allQuestions: Array<{ id: string; sectionIdx: number; localIdx: number; passageText?: string }> = [];
pkg.sections.forEach((sec: any, sIdx: number) => {
sec.questions.forEach((q: any, qIdx: number) => {
allQuestions.push({ id: q.id, sectionIdx: sIdx, localIdx: qIdx, passageText: q.passageText });
});
});
const isAnswered = (qId: string) => !!answers[qId];
const isMarked = (qId: string) => markedQuestions.has(qId);
// Initialize active question once on mount
useEffect(() => {
if (!hasInitRef.current && allQuestions.length > 0) {
hasInitRef.current = true;
setActiveQuestionId(allQuestions[0].id);
}
}, [allQuestions]);
const addQuestionMeta = (q: any) => ({
...q,
_examType: examType,
_isRtl: isRtl,
_useFurigana: useFurigana,
});
const activeQuestion = currentSection?.questions?.find(
(q: any) => q.id === activeQuestionId,
);
const activeQuestionWithMeta = activeQuestion ? addQuestionMeta(activeQuestion) : null;
const passageToShow =
activeQuestion?.passageText ??
currentSection?.questions?.[0]?.passageText ??
"Tidak ada bacaan tambahan untuk section ini.";
const activeGlobalIdx = activeQuestionId
? allQuestions.findIndex((q) => q.id === activeQuestionId) + 1
: 0;
const isFirstQuestion = activeGlobalIdx === 1;
const isLastQuestion = activeGlobalIdx === allQuestions.length;
// Navigation helpers
const goToQuestion = useCallback(
(qId: string | null) => {
if (!qId) return;
const target = allQuestions.find((q) => q.id === qId);
if (!target) return;
setCurrentSectionIdx(target.sectionIdx);
setActiveQuestionId(qId);
// Scroll question panel to top when changing question
setTimeout(() => {
questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, 50);
// Scroll nav slider to keep active button visible
setTimeout(() => {
const btn = navSliderRef.current?.querySelector(`[data-qid="${qId}"]`) as HTMLElement | null;
btn?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
}, 100);
},
[allQuestions, setCurrentSectionIdx],
);
const goToPrevQuestion = useCallback(() => {
if (!activeQuestionId) return;
const currentIdx = allQuestions.findIndex((q) => q.id === activeQuestionId);
if (currentIdx > 0) {
goToQuestion(allQuestions[currentIdx - 1].id);
}
}, [activeQuestionId, allQuestions, goToQuestion]);
const goToNextQuestion = useCallback(() => {
if (!activeQuestionId) return;
const currentIdx = allQuestions.findIndex((q) => q.id === activeQuestionId);
if (currentIdx >= 0 && currentIdx < allQuestions.length - 1) {
goToQuestion(allQuestions[currentIdx + 1].id);
}
}, [activeQuestionId, allQuestions, goToQuestion]);
return (
<>
<style>{`
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: var(--oat-border); border-radius: 10px; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
`}</style>
<div className="h-full flex flex-col bg-[var(--warm-cream)] overflow-hidden">
{/* TopAppBar Shell */}
<header className="bg-[var(--pure-white)] border-b border-[var(--oat-border)] flex justify-between items-center w-full px-6 py-3 shrink-0 z-50">
<div className="flex items-center gap-4">
<button onClick={() => setShowAbandonDialog(true)} className="text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors flex items-center">
<MaterialIcon name="close" />
</button>
<span className="text-xl font-bold tracking-tight text-[var(--clay-black)] truncate max-w-[200px] md:max-w-sm">{pkg.title}</span>
<span className="bg-[var(--oat-light)] px-3 py-1 rounded-full text-xs font-semibold text-[var(--warm-charcoal)] uppercase tracking-widest hidden md:inline-block">
{currentSection.title}
</span>
</div>
{/* Timer Box */}
<div className="bg-[var(--pure-white)]/70 backdrop-blur-md rounded-xl hidden sm:flex items-center gap-3 px-6 py-2 shadow-sm border border-[var(--oat-border)]">
<MaterialIcon name="timer" className="text-[var(--matcha-600)]" />
<div className="flex flex-col">
<span className="text-[10px] leading-none uppercase font-bold text-[var(--warm-silver)] tracking-tighter">Waktu Berlalu</span>
<span className="text-xl font-bold font-headline tabular-nums text-[var(--clay-black)]">{formatTime(timeElapsed)}</span>
</div>
</div>
<div className="flex items-center gap-6">
<div className="hidden md:flex items-center gap-2">
<div className="w-24 h-2 bg-[var(--oat-light)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--matcha-600)] transition-all rounded-full"
style={{ width: `${totalQuestions > 0 ? (answeredCount / totalQuestions) * 100 : 0}%` }}
/>
</div>
<span className="text-xs font-bold text-[var(--clay-black)]">{answeredCount}/{totalQuestions} Dijawab</span>
</div>
<button className="bg-[var(--oat-light)] p-2 rounded-xl text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors sm:hidden">
<MaterialIcon name="timer" />
</button>
<Button
onClick={() => setShowFinishDialog(true)}
disabled={isFinished}
className="bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] px-4 py-2 rounded-xl text-sm font-bold transition-opacity"
>
Selesai Test
</Button>
</div>
</header>
{/* Main Exam Workspace */}
<main className="flex-1 flex overflow-hidden flex-col lg:flex-row">
{/* Left Column: Reading Passage */}
<section className="w-full lg:w-1/2 bg-[var(--pure-white)] overflow-y-auto custom-scrollbar p-6 md:p-12 border-b lg:border-b-0 lg:border-r border-[var(--oat-border)] h-1/2 lg:h-full">
<article className="max-w-2xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl font-extrabold text-[var(--clay-black)] mb-4 leading-tight">
{currentSection.title}
</h1>
<div className="flex gap-4 text-sm text-[var(--warm-silver)] italic">
<span>Section {currentSectionIdx + 1} dari {pkg.sections.length}</span>
</div>
</header>
<div className="space-y-6 text-lg leading-relaxed text-[var(--warm-charcoal)] font-body whitespace-pre-wrap" dir={isRtl ? "rtl" : undefined}>
{useFurigana ? parseFurigana(passageToShow) : passageToShow}
</div>
</article>
</section>
{/* Right Column: Question Panel */}
<section ref={questionPanelRef} className="w-full lg:w-1/2 bg-[var(--warm-cream)] overflow-y-auto custom-scrollbar p-6 md:p-10 relative h-1/2 lg:h-full">
<div className="max-w-2xl mx-auto pb-32">
{/* Top bar: question counter + Selesai button */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<span className="text-sm font-bold text-[var(--warm-charcoal)] bg-[var(--oat-light)] px-3 py-1.5 rounded-lg">
Soal {activeGlobalIdx} / {totalQuestions}
</span>
{isMarked(activeQuestionId ?? "") && (
<span className="flex items-center gap-1 text-xs font-semibold text-[var(--pomegranate-400)] bg-[var(--pomegranate-400)]/10 px-2 py-1 rounded-full">
<MaterialIcon name="bookmark" className="text-xs" />
Ditandai
</span>
)}
</div>
<Button
onClick={() => setShowFinishDialog(true)}
disabled={isFinished}
className="flex items-center gap-2 bg-[var(--matcha-600)] text-[var(--pure-white)] hover:bg-[var(--matcha-700)] px-4 py-2 rounded-xl text-sm font-bold shadow-sm transition-all"
>
Selesai
<MaterialIcon name="check_circle" className="text-sm" />
</Button>
</div>
{/* Active question only */}
{activeQuestionWithMeta ? (
<QuestionCard
key={activeQuestionWithMeta.id}
q={activeQuestionWithMeta}
globalIdx={activeGlobalIdx}
answerValue={answers[activeQuestionWithMeta.id] ?? ""}
sectionResultId={sectionResultId}
isMarked={isMarked(activeQuestionWithMeta.id)}
isFinished={isFinished}
isSubmitting={submittingQId === activeQuestionWithMeta.id}
onAnswerChange={onAnswerChange}
toggleMarkQuestion={toggleMarkQuestion}
/>
) : (
<div className="text-center py-20">
<p className="text-[var(--warm-charcoal)]">Tidak ada soal aktif.</p>
</div>
)}
{/* Prev / Next Navigation */}
<nav className="flex justify-between items-center pt-8">
<Button
variant="ghost"
onClick={goToPrevQuestion}
disabled={isFirstQuestion}
className="flex items-center gap-2 text-[var(--clay-black)] font-bold px-6 py-3 rounded-xl hover:bg-[var(--clay-black)]/5 transition-all disabled:opacity-50"
>
<MaterialIcon name="arrow_back" />
Sebelumnya
</Button>
<Button
onClick={goToNextQuestion}
disabled={isLastQuestion}
className="flex items-center gap-2 bg-[var(--clay-black)] text-[var(--pure-white)] font-bold px-8 py-3 rounded-xl shadow-lg hover:bg-[var(--warm-charcoal)] transition-all disabled:opacity-50"
>
Selanjutnya
<MaterialIcon name="arrow_forward" />
</Button>
</nav>
</div>
</section>
</main>
{/* Floating Question Navigation Slider */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 flex items-center gap-2 p-2 bg-[var(--clay-black)]/95 backdrop-blur-md rounded-full shadow-2xl border border-white/10 z-40 max-w-[90vw]">
{/* Scroll Left */}
<button
onClick={() => {
navSliderRef.current?.scrollBy({ left: -200, behavior: "smooth" });
}}
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
>
<MaterialIcon name="chevron_left" className="text-sm" />
</button>
{/* Scrollable Strip */}
<div
ref={navSliderRef}
className="flex items-center gap-1 overflow-x-auto hide-scrollbar max-w-[60vw] sm:max-w-[50vw] md:max-w-[40vw] lg:max-w-[30vw]"
>
{allQuestions.map((q, gIdx) => {
const answered = isAnswered(q.id);
const marked = isMarked(q.id);
const isActive = q.id === activeQuestionId;
return (
<button
key={q.id}
data-qid={q.id}
onClick={() => goToQuestion(q.id)}
className={`relative w-7 h-7 sm:w-8 sm:h-8 shrink-0 flex items-center justify-center rounded-full font-bold text-[10px] sm:text-xs transition-all ${
answered
? "bg-[var(--matcha-600)] text-[var(--pure-white)]"
: isActive
? "bg-white/30 text-white"
: "bg-white/10 text-white/60 hover:bg-white/20"
}`}
title={`Soal ${gIdx + 1}${marked ? " (ditandai)" : ""}`}
>
{gIdx + 1}
{marked && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--pomegranate-400)] rounded-full border border-[var(--clay-black)]" />
)}
</button>
);
})}
</div>
{/* Scroll Right */}
<button
onClick={() => {
navSliderRef.current?.scrollBy({ left: 200, behavior: "smooth" });
}}
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full bg-white/10 text-white/60 hover:bg-white/20 hover:text-white transition-all"
>
<MaterialIcon name="chevron_right" className="text-sm" />
</button>
<div className="w-px h-6 bg-white/10 shrink-0"></div>
{/* Top Button */}
<button
onClick={() => {
questionPanelRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
className="flex items-center gap-1 px-3 py-2 shrink-0 rounded-full text-white/80 font-semibold text-xs hover:text-white transition-all"
>
<MaterialIcon name="arrow_upward" className="text-sm" />
<span className="hidden sm:inline">Atas</span>
</button>
</div>
</div>
{/* Finish Confirmation Dialog */}
{showFinishDialog && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setShowFinishDialog(false);
}}
>
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
<MaterialIcon name="help" className="text-[var(--pomegranate-400)]" />
</div>
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
Selesaikan Latihan?
</h2>
</div>
<div className="space-y-3 mb-6">
<p className="text-sm text-[var(--warm-charcoal)]">
Kamu sudah menjawab <strong className="text-[var(--clay-black)]">{answeredCount} dari {totalQuestions}</strong> soal.
</p>
{answeredCount < totalQuestions && (
<div className="p-3 rounded-[var(--radius-md)] bg-[var(--lemon-400)]/20 border-2 border-[var(--lemon-500)]/30 text-sm text-[var(--lemon-800)] flex items-start gap-2">
<MaterialIcon name="warning" className="text-sm mt-0.5 shrink-0" />
<span>Masih ada {totalQuestions - answeredCount} soal yang belum dijawab.</span>
</div>
)}
<p className="text-sm text-[var(--warm-charcoal)]">
Setelah selesai, jawaban tidak bisa diubah dan hasil akan langsung terlihat.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowFinishDialog(false)}
className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
>
Lanjutkan
</Button>
<Button
onClick={() => {
setShowFinishDialog(false);
onFinish();
}}
className="flex-1 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
>
Selesaikan
</Button>
</div>
</div>
</div>
)}
{/* Abandon Confirmation Dialog */}
{showAbandonDialog && (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={(e) => {
if (e.target === e.currentTarget) setShowAbandonDialog(false);
}}
>
<div className="bg-[var(--warm-cream)] w-full max-w-md rounded-[var(--radius-xl)] border-2 border-[var(--oat-border)] shadow-xl p-6 md:p-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-[var(--pomegranate-400)]/20 flex items-center justify-center">
<MaterialIcon name="warning" className="text-[var(--pomegranate-400)]" />
</div>
<h2 className="text-xl font-headline font-bold text-[var(--clay-black)]">
Keluar dari Latihan?
</h2>
</div>
<div className="space-y-3 mb-6">
<p className="text-sm text-[var(--warm-charcoal)]">
Apakah Anda yakin ingin meninggalkan sesi latihan ini? Progress pengerjaan Anda mungkin tidak tersimpan dan akan ditandai sebagai gagal atau dibatalkan.
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowAbandonDialog(false)}
className="flex-1 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)]"
>
Batal
</Button>
<Button
onClick={() => {
setShowAbandonDialog(false);
onAbandon();
}}
className="flex-1 bg-[var(--pomegranate-400)] text-[var(--pure-white)] hover:bg-[var(--pomegranate-600)] rounded-[var(--radius-lg)]"
>
Keluar
</Button>
</div>
</div>
</div>
)}
</>
);
}