DocDoeAI / src /components /chapter-loop /SoundWavesChapterLoop.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
20.2 kB
"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import {
CHAPTER_META,
FLASHCARDS,
FORMULA_LOCK,
NOTE_SECTIONS,
QUIZ,
matchChatReply,
type Flashcard,
type QuizQuestion,
} from "@/content/sslc/physics/sound-waves/oneshot";
const display = "var(--font-docdoe-display), Archivo, ui-sans-serif, system-ui, sans-serif";
const body = "var(--font-docdoe-sans), Onest, ui-sans-serif, system-ui, sans-serif";
type StepId = "watch" | "notes" | "chat" | "test";
const STEPS: { id: StepId; label: string }[] = [
{ id: "watch", label: "Watch" },
{ id: "notes", label: "Notes" },
{ id: "chat", label: "Chat" },
{ id: "test", label: "Test" },
];
const WATCH_UNLOCK_SEC = 20;
const LS_DONE = "docdoe.preview.sound-waves.chapterDone";
type ChatMsg = { role: "tutor" | "student"; text: string };
const CHIPS = ["Explain again", "Give example", "Exam trap", "Quiz me"] as const;
export function SoundWavesChapterLoop() {
const [step, setStep] = useState<StepId>("watch");
const [watchedEnough, setWatchedEnough] = useState(false);
const [founderSkip, setFounderSkip] = useState(false);
const notesUnlocked = watchedEnough || founderSkip;
const videoRef = useRef<HTMLVideoElement | null>(null);
const onTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.currentTime >= WATCH_UNLOCK_SEC) setWatchedEnough(true);
}, []);
const go = (id: StepId) => {
if (id !== "watch" && !notesUnlocked && id === "notes") return;
if ((id === "notes" || id === "chat" || id === "test") && !notesUnlocked) return;
// Allow chat/test only after notes unlocked (same gate as notes for prototype simplicity)
setStep(id);
};
const unlockAndNotes = () => {
setFounderSkip(true);
setStep("notes");
};
return (
<div className="min-h-svh bg-[#10211c] text-[#f2f4f1]" style={{ fontFamily: body }}>
<div className="border-b border-white/10 bg-[#0c1a16] px-4 py-3 sm:px-6">
<div className="mx-auto max-w-[720px]">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
Sound Waves chapter loop preview
</p>
<p className="mt-1 text-[12px] leading-5 text-[#f2f4f1]/55">
Sound Waves chapter loop preview · /home untouched
</p>
</div>
</div>
<div className="mx-auto w-full max-w-[720px] px-4 py-5 pb-24 sm:px-6 sm:py-8">
<header className="mb-5">
<p className="text-[12px] font-medium text-[#f2f4f1]/55 sm:text-[13px]">
{CHAPTER_META.subject} · {CHAPTER_META.board}
</p>
<h1
className="mt-1.5 text-[32px] font-semibold leading-none tracking-[-0.04em] text-[#f2f4f1] sm:text-[44px]"
style={{ fontFamily: display }}
>
{CHAPTER_META.title}
</h1>
</header>
<nav
className="mb-6 flex gap-1 overflow-x-auto rounded-[14px] border border-white/10 bg-white/[0.04] p-1"
aria-label="Chapter steps"
>
{STEPS.map((s, i) => {
const locked = s.id !== "watch" && !notesUnlocked;
const active = step === s.id;
return (
<button
key={s.id}
type="button"
disabled={locked}
onClick={() => go(s.id)}
className={[
"min-h-10 flex-1 rounded-[10px] px-2 text-[12px] font-semibold sm:text-[13px]",
active
? "bg-[#c6e04a] text-[#10211c]"
: locked
? "cursor-not-allowed text-[#f2f4f1]/30"
: "text-[#f2f4f1]/70 hover:bg-white/[0.06]",
].join(" ")}
>
<span className="tabular-nums opacity-70">{i + 1}.</span> {s.label}
</button>
);
})}
</nav>
{step === "watch" ? (
<WatchStep
videoRef={videoRef}
onTimeUpdate={onTimeUpdate}
notesUnlocked={notesUnlocked}
onOpenNotes={() => setStep("notes")}
onFounderSkip={unlockAndNotes}
/>
) : null}
{step === "notes" ? <NotesStep onContinue={() => setStep("chat")} /> : null}
{step === "chat" ? <ChatStep onContinue={() => setStep("test")} /> : null}
{step === "test" ? <TestStep /> : null}
</div>
</div>
);
}
function WatchStep({
videoRef,
onTimeUpdate,
notesUnlocked,
onOpenNotes,
onFounderSkip,
}: {
videoRef: RefObject<HTMLVideoElement | null>;
onTimeUpdate: () => void;
notesUnlocked: boolean;
onOpenNotes: () => void;
onFounderSkip: () => void;
}) {
return (
<section className="space-y-4">
<p className="text-[14px] leading-6 text-[#f2f4f1]/75">
Watch the class first. Notes open after.
</p>
<div className="overflow-hidden rounded-[16px] border border-white/10 bg-black">
<video
ref={videoRef}
className="aspect-video w-full bg-black"
controls
playsInline
preload="metadata"
onTimeUpdate={onTimeUpdate}
onPlay={onTimeUpdate}
>
<source src={CHAPTER_META.videoSrc} type="video/mp4" />
<track
kind="captions"
src={CHAPTER_META.captionsSrc}
srcLang="en"
label="English"
default
/>
</video>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={onFounderSkip}
className="text-left text-[12px] font-medium text-[#f2f4f1]/45 underline-offset-2 hover:text-[#f2f4f1]/70 hover:underline"
>
I&apos;ve watched — open notes
</button>
<button
type="button"
disabled={!notesUnlocked}
onClick={onOpenNotes}
className={[
"inline-flex min-h-11 items-center justify-center rounded-full px-5 text-[14px] font-semibold",
notesUnlocked
? "bg-[#c6e04a] text-[#10211c]"
: "cursor-not-allowed bg-white/10 text-[#f2f4f1]/35",
].join(" ")}
>
Open notes
</button>
</div>
</section>
);
}
function NotesStep({ onContinue }: { onContinue: () => void }) {
return (
<section className="space-y-4">
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
One-shot notes
</p>
<div className="max-h-[70vh] space-y-4 overflow-y-auto rounded-[16px] bg-[#f4f3ee] p-4 text-[#10211c] sm:p-5">
{NOTE_SECTIONS.map((s) => (
<article
key={s.n}
className="rounded-[14px] border border-[#10211c]/10 bg-[#f2f4f1] p-4"
>
<h2
className="text-[17px] font-semibold tracking-[-0.02em]"
style={{ fontFamily: display }}
>
<span className="tabular-nums text-[#10211c]/45">{s.n}.</span> {s.title}
</h2>
<p className="mt-2 text-[14px] leading-6 text-[#10211c]/85">{s.concept}</p>
{s.formula ? (
<pre className="mt-3 overflow-x-auto rounded-[10px] border border-[#10211c]/12 bg-[#10211c] px-3 py-2.5 font-mono text-[12px] leading-5 text-[#c6e04a] sm:text-[13px]">
{s.formula}
</pre>
) : null}
{s.trap ? (
<p className="mt-3 rounded-[10px] border border-amber-700/25 bg-amber-50 px-3 py-2 text-[13px] leading-5 text-amber-950">
<span className="font-semibold">Trap · </span>
{s.trap}
</p>
) : null}
{s.example ? (
<p className="mt-3 text-[13px] leading-5 text-[#10211c]/70">
<span className="font-semibold text-[#10211c]">Example · </span>
{s.example}
</p>
) : null}
</article>
))}
<article className="rounded-[14px] border-2 border-[#10211c] bg-[#10211c] p-4 text-[#f2f4f1]">
<h2
className="text-[15px] font-semibold uppercase tracking-[0.12em] text-[#c6e04a]"
style={{ fontFamily: display }}
>
Chapter formula lock
</h2>
<p className="mt-1 text-[12px] text-[#f2f4f1]/55">
Write these without looking.
</p>
<ul className="mt-3 space-y-2">
{FORMULA_LOCK.map((line) => (
<li
key={line}
className="rounded-[8px] bg-white/[0.06] px-3 py-2 font-mono text-[13px] text-[#c6e04a]"
>
{line}
</li>
))}
</ul>
</article>
</div>
<button
type="button"
onClick={onContinue}
className="inline-flex min-h-11 w-full items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c] sm:w-auto"
>
Continue to study chat
</button>
</section>
);
}
function ChatStep({ onContinue }: { onContinue: () => void }) {
const [messages, setMessages] = useState<ChatMsg[]>([
{
role: "tutor",
text: `Locked to ${CHAPTER_META.contextLock}. Ask a trap, a formula, or tap a chip.`,
},
]);
const [draft, setDraft] = useState("");
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const send = (text: string) => {
const cleaned = text.trim();
if (!cleaned) return;
const reply = matchChatReply(cleaned);
setMessages((m) => [...m, { role: "student", text: cleaned }, { role: "tutor", text: reply }]);
setDraft("");
};
return (
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
Study chat
</p>
<span className="rounded-full border border-white/12 bg-white/[0.06] px-2.5 py-1 text-[11px] font-medium text-[#f2f4f1]/70">
{CHAPTER_META.contextLock}
</span>
</div>
<div className="flex max-h-[52vh] flex-col overflow-hidden rounded-[16px] border border-white/10 bg-[#0c1a16]">
<div className="flex-1 space-y-3 overflow-y-auto p-3 sm:p-4">
{messages.map((m, i) => (
<div
key={`${i}-${m.role}`}
className={[
"max-w-[92%] whitespace-pre-wrap rounded-[14px] px-3 py-2.5 text-[14px] leading-6",
m.role === "tutor"
? "bg-[#f2f4f1] text-[#10211c]"
: "ml-auto bg-white/[0.08] text-[#f2f4f1]",
].join(" ")}
>
{m.role === "tutor" ? (
<p className="mb-1 text-[10px] font-semibold uppercase tracking-[0.12em] text-[#10211c]/45">
Tutor
</p>
) : null}
{m.text}
</div>
))}
<div ref={bottomRef} />
</div>
<div className="flex flex-wrap gap-1.5 border-t border-white/10 px-3 py-2">
{CHIPS.map((c) => (
<button
key={c}
type="button"
onClick={() => send(c)}
className="rounded-full border border-white/12 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-[#f2f4f1]/75"
>
{c}
</button>
))}
</div>
<form
className="flex gap-2 border-t border-white/10 p-3"
onSubmit={(e) => {
e.preventDefault();
send(draft);
}}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ask about Sound Waves…"
className="min-h-11 flex-1 rounded-full border border-white/12 bg-white/[0.06] px-4 text-[14px] text-[#f2f4f1] outline-none placeholder:text-[#f2f4f1]/35 focus:border-[#c6e04a]/50"
/>
<button
type="submit"
className="inline-flex min-h-11 min-w-11 items-center justify-center rounded-full bg-[#c6e04a] px-4 text-[13px] font-semibold text-[#10211c]"
>
Send
</button>
</form>
</div>
<button
type="button"
onClick={onContinue}
className="inline-flex min-h-11 w-full items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c] sm:w-auto"
>
Continue to exam test
</button>
</section>
);
}
function TestStep() {
const [qi, setQi] = useState(0);
const [selected, setSelected] = useState<number | null>(null);
const [revealed, setRevealed] = useState(false);
const [score, setScore] = useState(0);
const [phase, setPhase] = useState<"quiz" | "score" | "cards">("quiz");
const [cardIndex, setCardIndex] = useState(0);
const [flipped, setFlipped] = useState(false);
const [knowCount, setKnowCount] = useState(0);
const [done, setDone] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(LS_DONE) === "1";
} catch {
return false;
}
});
const q: QuizQuestion = QUIZ[qi];
const card: Flashcard = FLASHCARDS[cardIndex];
const total = QUIZ.length;
const submitChoice = (idx: number) => {
if (revealed) return;
setSelected(idx);
setRevealed(true);
if (idx === q.answerIndex) setScore((s) => s + 1);
};
const nextQ = () => {
if (qi + 1 >= total) {
setPhase("score");
return;
}
setQi((i) => i + 1);
setSelected(null);
setRevealed(false);
};
const retryType = () => {
setSelected(null);
setRevealed(false);
if (selected === q.answerIndex) setScore((s) => Math.max(0, s - 1));
};
const markDone = () => {
try {
localStorage.setItem(LS_DONE, "1");
setDone(true);
} catch {
setDone(true);
}
};
const advanceCard = (know: boolean) => {
if (know) setKnowCount((c) => c + 1);
setFlipped(false);
if (cardIndex + 1 >= FLASHCARDS.length) {
setCardIndex(0);
return;
}
setCardIndex((i) => i + 1);
};
const scoreLine = `${score} / ${total}`;
if (phase === "quiz") {
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
Exam test
</p>
<span className="text-[12px] tabular-nums text-[#f2f4f1]/55">
{qi + 1} / {total}
</span>
</div>
<div className="rounded-[16px] bg-[#f4f3ee] p-4 text-[#10211c] sm:p-5">
<p className="text-[15px] font-semibold leading-6" style={{ fontFamily: display }}>
{q.prompt}
</p>
<div className="mt-4 space-y-2">
{q.choices.map((c, idx) => {
const isSel = selected === idx;
const isAns = idx === q.answerIndex;
let cls =
"w-full rounded-[12px] border border-[#10211c]/12 bg-[#f2f4f1] px-3 py-3 text-left text-[14px] leading-5";
if (revealed && isAns) cls += " border-[#10211c] bg-[#c6e04a]/35";
if (revealed && isSel && !isAns) cls += " border-red-700/40 bg-red-50";
if (!revealed) cls += " hover:border-[#10211c]/35";
return (
<button
key={c}
type="button"
disabled={revealed}
onClick={() => submitChoice(idx)}
className={cls}
>
{c}
</button>
);
})}
</div>
{revealed ? (
<div className="mt-4 space-y-3">
<p className="rounded-[10px] border border-[#10211c]/12 bg-white px-3 py-2 text-[13px] leading-5">
<span className="font-semibold">Fix · </span>
{q.fix}
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={retryType}
className="min-h-10 rounded-full border border-[#10211c]/20 px-4 text-[13px] font-semibold"
>
Retry this type
</button>
<button
type="button"
onClick={nextQ}
className="min-h-10 rounded-full bg-[#10211c] px-4 text-[13px] font-semibold text-[#c6e04a]"
>
{qi + 1 >= total ? "See score" : "Next"}
</button>
</div>
</div>
) : null}
</div>
</section>
);
}
if (phase === "score") {
return (
<section className="space-y-4">
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
Score
</p>
<div className="rounded-[16px] bg-[#f4f3ee] p-5 text-[#10211c]">
<p className="text-[40px] font-semibold tracking-[-0.04em]" style={{ fontFamily: display }}>
{scoreLine}
</p>
<p className="mt-2 text-[14px] text-[#10211c]/70">
Next: flashcards from the chapter formula lock.
</p>
<button
type="button"
onClick={() => setPhase("cards")}
className="mt-4 inline-flex min-h-11 items-center justify-center rounded-full bg-[#c6e04a] px-5 text-[14px] font-semibold text-[#10211c]"
>
Open flashcards
</button>
</div>
</section>
);
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[12px] font-semibold uppercase tracking-[0.14em] text-[#c6e04a]">
Formula flashcards
</p>
<span className="text-[12px] tabular-nums text-[#f2f4f1]/55">
{cardIndex + 1} / {FLASHCARDS.length} · know {knowCount}
</span>
</div>
<button
type="button"
onClick={() => setFlipped((f) => !f)}
className="flex min-h-[180px] w-full flex-col items-center justify-center rounded-[16px] border border-white/10 bg-[#f4f3ee] px-4 py-8 text-center text-[#10211c]"
>
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#10211c]/45">
{flipped ? "Answer" : "Tap to flip"}
</span>
<span
className="mt-3 text-[18px] font-semibold leading-7 sm:text-[20px]"
style={{ fontFamily: display }}
>
{flipped ? (
<span className="inline-block rounded-[10px] bg-[#10211c] px-3 py-2 font-mono text-[16px] text-[#c6e04a]">
{card.back}
</span>
) : (
card.front
)}
</span>
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => advanceCard(false)}
className="min-h-11 flex-1 rounded-full border border-white/15 text-[14px] font-semibold text-[#f2f4f1]/80"
>
Again
</button>
<button
type="button"
onClick={() => advanceCard(true)}
className="min-h-11 flex-1 rounded-full bg-[#c6e04a] text-[14px] font-semibold text-[#10211c]"
>
Know
</button>
</div>
<button
type="button"
onClick={markDone}
className="inline-flex min-h-11 w-full items-center justify-center rounded-full border border-[#c6e04a]/40 px-5 text-[14px] font-semibold text-[#c6e04a]"
>
{done ? "Tonight’s chapter step marked done ✓" : "Mark tonight’s chapter step done"}
</button>
<p className="text-[11px] text-[#f2f4f1]/40">localStorage only · account progress untouched</p>
</section>
);
}