DocDoeAI / src /components /onboarding /StudySetupFlow.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
47.4 kB
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/animate-ui/primitives/radix/sheet";
import { LiquidButton } from "@/components/animate-ui/primitives/buttons/liquid-button";
import {
ArrowLeft,
BookOpen,
FileImage,
FileText,
LoaderCircle,
Search,
Upload,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode, type RefObject } from "react";
import { DocDoeLogoMark } from "@/components/landing/DocDoeLogoMark";
import { uploadDocument } from "@/lib/api/documents";
import { saveStudyProfile } from "@/lib/api/brain";
import { normalizeApiError } from "@/lib/api/client";
import { hasLiveSession } from "@/lib/api/mode";
import {
completeOnboarding,
emptyStudyProfile,
getOnboardingProfile,
isOnboardingCompleted,
labelForOnboardingGoal,
saveOnboardingProfile,
saveStudyProfileId,
skipOnboarding,
type BoardChoice,
type ClassLevelChoice,
type FirstUploadStatus,
type OnboardingGoalChoice,
type PrimaryNeedChoice,
type StreamChoice,
type StudentStudyProfile,
} from "@/lib/onboarding";
import { cn } from "@/lib/utils";
import {
MATERIAL_UPLOAD_ACCEPT,
validateStudentUpload,
} from "@/lib/uploads/document-upload-contract";
type SetupScreen = "welcome" | "context" | "subjects" | "exam-mode" | "material" | "ready";
type MaterialKind = "notes" | "chapter" | "question-paper";
const progression: Array<{ key: Exclude<SetupScreen, "welcome">; label: string }> = [
{ key: "context", label: "Context" },
{ key: "subjects", label: "Subjects" },
{ key: "exam-mode", label: "Exam mode" },
{ key: "material", label: "Material" },
{ key: "ready", label: "Ready" },
];
const boardOptions: BoardChoice[] = ["Kerala HSE", "Kerala SSLC", "CBSE", "ICSE", "Other State Board", "Other"];
const classOptions: ClassLevelChoice[] = ["SSLC / Class 10", "+1 / Class 11", "+2 / Class 12", "College", "Other"];
const streamOptions: StreamChoice[] = ["General", "Science", "Commerce", "Humanities", "Computer Science", "Other"];
const subjectOptions = [
"Physics",
"Chemistry",
"Biology",
"Mathematics",
"Computer Science",
"English",
"Malayalam",
"Accountancy",
"Business Studies",
"Economics",
] as const;
const subjectCombinations: Array<{ label: string; subjects: readonly string[] }> = [
{ label: "Science Stream", subjects: ["Physics", "Chemistry", "Mathematics", "Computer Science"] },
{ label: "Commerce Stream", subjects: ["Accountancy", "Business Studies", "Economics", "Mathematics"] },
{ label: "Humanities Stream", subjects: ["English", "Malayalam", "Economics", "Computer Science"] },
];
const examGoals: Array<{ id: OnboardingGoalChoice; title: string; text: string }> = [
{ id: "last_night_prep", title: "Last-night prep", text: "Fastest path when the exam is close." },
{
id: "pyq_intelligence",
title: "PYQ intelligence",
text: "Repeated topics, high-mark chapters, and scoring patterns.",
},
{ id: "understand_chapters", title: "Understand chapters", text: "Hard concepts explained without extra noise." },
{ id: "practice_mode", title: "Practice mode", text: "Quizzes, flashcards, and revision checks." },
{ id: "full_study_path", title: "Full study path", text: "A clear order for what to study next." },
];
const materialKinds: Array<{ id: MaterialKind; title: string; description: string; icon: typeof FileText }> = [
{ id: "notes", title: "Notes", description: "PDF or plain-text export", icon: FileImage },
{ id: "chapter", title: "Chapter PDF", description: "A chapter or portion", icon: BookOpen },
{ id: "question-paper", title: "Question paper", description: "Past paper or PYQ", icon: FileText },
];
const primaryNeedForGoal: Record<OnboardingGoalChoice, PrimaryNeedChoice> = {
last_night_prep: "last_night_plan",
pyq_intelligence: "pyq_intelligence",
understand_chapters: "scoring_notes",
practice_mode: "quiz_me",
full_study_path: "what_to_study",
};
const screenIndex: Record<SetupScreen, number> = {
welcome: 0,
context: 0,
subjects: 1,
"exam-mode": 2,
material: 3,
ready: 4,
};
const ease = [0.22, 1, 0.36, 1] as const;
function createProfile(): StudentStudyProfile {
const now = new Date().toISOString();
return { ...emptyStudyProfile, createdAt: now, updatedAt: now };
}
function profilePayload(profile: StudentStudyProfile) {
return {
exam: profile.board || null,
board: profile.board || null,
grade: profile.classLevel || profile.grade || null,
subject: profile.subjects?.[0] || profile.subject || null,
chapter: null,
topic: null,
goal: labelForOnboardingGoal(profile.primaryGoal),
time_left: null,
level: "intermediate" as const,
language_preference: "English",
primary_need: profile.primaryNeed,
weak_areas: [],
onboarding_completed: Boolean(profile.onboardingCompleted),
extra: {
onboardingSkipped: Boolean(profile.onboardingSkipped),
stream: profile.stream || null,
subjects: profile.subjects ?? [],
primaryGoal: profile.primaryGoal || null,
},
};
}
function documentStatus(status: string): FirstUploadStatus {
if (status === "ready") return "ready";
return status === "processing" || status === "uploaded" ? "processing" : "received";
}
export function StudySetupFlow({ className }: { className?: string }) {
const router = useRouter();
const reduceMotion = Boolean(useReducedMotion());
const [mounted, setMounted] = useState(false);
const [screen, setScreen] = useState<SetupScreen>("welcome");
const [profile, setProfile] = useState<StudentStudyProfile>(() => createProfile());
const [customSubject, setCustomSubject] = useState("");
const [selectedMaterialKind, setSelectedMaterialKind] = useState<MaterialKind>("notes");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadNotice, setUploadNotice] = useState<string | null>(null);
const [showSkipSheet, setShowSkipSheet] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Calculate completed steps for progress bar
const completedSteps = useMemo(() => {
let count = 0;
if (profile.board) count++;
if (profile.subjects?.length) count++;
if (profile.primaryGoal) count++;
if (profile.firstUploadDocumentId) count++;
return count;
}, [profile]);
useEffect(() => {
const id = window.setTimeout(() => {
const stored = getOnboardingProfile();
const editing = new URLSearchParams(window.location.search).get("edit") === "1";
if (isOnboardingCompleted() && !editing) {
router.replace("/home");
return;
}
if (stored) setProfile({ ...emptyStudyProfile, ...stored });
if (editing) setScreen("context");
setMounted(true);
}, 0);
return () => window.clearTimeout(id);
}, [router]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setShowSkipSheet(true);
return;
}
if (event.key === "Enter" && event.ctrlKey) {
event.preventDefault();
// Navigate to next screen based on current screen
const nextMap: Partial<Record<SetupScreen, SetupScreen>> = {
welcome: "context",
context: "subjects",
subjects: "exam-mode",
"exam-mode": "material",
material: "ready",
};
const next = nextMap[screen];
if (next) goNext(next);
}
if (event.key === "Backspace" && !event.ctrlKey && !event.metaKey) {
const target = event.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
event.preventDefault();
const backMap: Partial<Record<SetupScreen, SetupScreen>> = {
context: "welcome",
subjects: "context",
"exam-mode": "subjects",
material: "exam-mode",
ready: "material",
};
const back = backMap[screen];
if (back) goNext(back);
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [screen]);
function updateProfile(patch: Partial<StudentStudyProfile>) {
setProfile((current) => {
const updated = saveOnboardingProfile({ ...current, ...patch });
return updated;
});
}
function goNext(next: SetupScreen) {
setScreen(next);
}
function persistCompletedProfile(nextProfile: StudentStudyProfile) {
const completed = completeOnboarding(nextProfile) ?? nextProfile;
setProfile(completed);
if (!hasLiveSession()) return;
void saveStudyProfile(profilePayload(completed))
.then((saved) => saveStudyProfileId(saved.id))
.catch(() => {
// Local profile remains usable when the API is unavailable during beta.
});
}
async function handleSkipSetup() {
const skippedItems = [];
if (!profile.board) skippedItems.push("Personalized exam signals");
if (!profile.subjects?.length) skippedItems.push("Subject-specific recommendations");
if (!profile.primaryGoal) skippedItems.push("Tailored study modes");
if (!profile.firstUploadDocumentId) skippedItems.push("First study material analysis");
if (skippedItems.length > 2) {
setShowSkipSheet(true);
return;
}
await completeSkip();
}
async function completeSkip() {
const skipped = skipOnboarding(profile);
if (hasLiveSession()) {
try {
const saved = await saveStudyProfile(profilePayload(skipped));
saveStudyProfileId(saved.id);
} catch {
// Skipping must never trap a student behind network availability.
}
}
router.push("/home");
}
function selectSubject(subject: string) {
const current = profile.subjects ?? [];
const subjects = current.includes(subject) ? current.filter((item) => item !== subject) : [...current, subject];
updateProfile({ subjects, subject: subjects[0] ?? "" });
}
function addCustomSubject() {
const subject = customSubject.trim();
if (!subject) return;
const current = profile.subjects ?? [];
if (!current.includes(subject)) updateProfile({ subjects: [...current, subject], subject: current[0] ?? subject });
setCustomSubject("");
}
function toggleGoal(goal: OnboardingGoalChoice) {
const current = profile.selectedGoals ?? [];
if (current.includes(goal)) {
const selectedGoals = current.filter((item) => item !== goal);
const primaryGoal = profile.primaryGoal === goal ? selectedGoals[0] ?? "" : profile.primaryGoal ?? "";
updateProfile({
selectedGoals,
primaryGoal,
primaryNeed: primaryGoal ? primaryNeedForGoal[primaryGoal] : "what_to_study",
});
return;
}
const selectedGoals = [...current, goal];
const primaryGoal = profile.primaryGoal || goal;
updateProfile({ selectedGoals, primaryGoal, primaryNeed: primaryNeedForGoal[primaryGoal] });
}
function makePrimaryGoal(goal: OnboardingGoalChoice) {
updateProfile({ primaryGoal: goal, primaryNeed: primaryNeedForGoal[goal] });
}
function handleFile(file: File | undefined) {
if (!file) return;
const validation = validateStudentUpload(file, "material");
if (!validation.ok) {
setUploadNotice(validation.message);
return;
}
setSelectedFile(file);
setUploadNotice(null);
}
function handleDrop(event: DragEvent<HTMLDivElement>) {
event.preventDefault();
setIsDragging(false);
handleFile(event.dataTransfer.files[0]);
}
function finishWithoutUpload() {
const nextProfile = saveOnboardingProfile({
...profile,
sourceMode: "skip",
firstUploadStatus: "skipped",
});
persistCompletedProfile(nextProfile);
setScreen("ready");
}
async function uploadFirstMaterial() {
if (!selectedFile) return;
setIsUploading(true);
setUploadNotice(null);
try {
// Simulate progress for better UX
setUploadNotice("Reading your material...");
await new Promise(resolve => setTimeout(resolve, 500));
setUploadNotice("Finding study text...");
const document = await uploadDocument({
file: selectedFile,
title: selectedFile.name.replace(/\.[^/.]+$/, ""),
subject: profile.subjects?.[0] || undefined,
syllabus: profile.board || undefined,
});
setUploadNotice("Preparing study signals...");
await new Promise(resolve => setTimeout(resolve, 300));
const nextProfile = saveOnboardingProfile({
...profile,
sourceMode: "upload",
sourceId: document.id,
sourceTitle: document.title,
firstUploadDocumentId: document.id,
firstUploadStatus: documentStatus(document.status),
});
setUploadNotice("Material received. DocDoe will use it inside your study space.");
persistCompletedProfile(nextProfile);
setScreen("ready");
} catch (error) {
const errorMessage = normalizeApiError(error) || "Could not upload this material right now.";
setUploadNotice(`${errorMessage} You can retry, skip, or try a different file.`);
} finally {
setIsUploading(false);
}
}
const stage = screenIndex[screen];
const subjects = profile.subjects ?? [];
const selectedGoals = profile.selectedGoals ?? [];
if (!mounted) {
return (
<main className={cn(className, "docdoe-setup grid min-h-svh place-items-center bg-[#030303] text-white")}>
<LoaderCircle className="size-5 animate-spin text-white/45 motion-reduce:animate-none" aria-label="Loading study setup" />
</main>
);
}
return (
<main className={cn(className, "docdoe-setup relative min-h-svh overflow-x-hidden bg-[#030303] text-[#f8fafc]")}>
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/28 to-transparent" />
<div className="pointer-events-none absolute left-1/2 top-0 h-[24rem] w-[min(46rem,100vw)] -translate-x-1/2 bg-[radial-gradient(ellipse_at_top,rgba(255,255,255,0.065),transparent_62%)]" />
<header className="relative z-10 mx-auto flex w-full max-w-[1160px] items-center justify-between px-5 pb-5 pt-5 sm:px-8 sm:pt-7">
<Link href="/" className="inline-flex items-center gap-2.5 shrink-0" aria-label="DocDoe AI home">
<span className="grid size-10 place-items-center rounded-xl border border-white/10 bg-[#080808]">
<DocDoeLogoMark className="size-7 text-white" aria-hidden="true" />
</span>
<span className="text-[15px] font-semibold tracking-[-0.03em]">DocDoe AI</span>
</Link>
<button
type="button"
onClick={() => void handleSkipSetup()}
className="min-h-12 shrink-0 rounded-full border border-white/10 px-4 text-[12px] font-medium text-white/58 transition hover:border-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/35"
>
Skip for now
</button>
</header>
<nav
className="relative z-10 mx-auto w-full max-w-[1160px] overflow-x-auto px-5 sm:px-8"
aria-label="Study profile progress"
role="progressbar"
aria-valuenow={completedSteps}
aria-valuemin={0}
aria-valuemax={4}
aria-valuetext={`${completedSteps} of 4 steps completed`}
>
<ol className="grid grid-cols-5 min-w-[300px]">
{progression.map((item, index) => {
const active = index === stage && screen !== "welcome";
const done = index < stage || screen === "ready";
return (
<li key={item.key} className="relative pb-5 shrink-0">
<div className="absolute left-[0.58rem] right-0 top-[0.43rem] h-px bg-white/[0.09]" aria-hidden="true">
{done ? <span className="block h-full bg-white/48" /> : null}
</div>
<div className="relative flex flex-col gap-2">
<span
className={cn(
"relative z-10 block size-[0.9rem] rounded-full border bg-[#030303] transition-colors",
active
? "docdoe-setup-signal-dot border-white/70 after:absolute after:inset-[3px] after:rounded-full after:bg-white/80"
: done
? "border-white/52 bg-white/80"
: "border-white/20",
)}
/>
<span className={cn("text-[10px] font-medium tracking-[0.08em] sm:text-[11px]", active ? "text-white/92" : done ? "text-white/52" : "text-white/38")}>
<span className="mr-1 text-white/30">0{index + 1}</span>
<span className="hidden min-[370px]:inline">{item.label}</span>
</span>
</div>
</li>
);
})}
</ol>
</nav>
<div className="relative z-10 mx-auto grid w-full max-w-[1160px] gap-8 px-5 pb-8 pt-6 sm:px-8 sm:pt-10 lg:grid-cols-[minmax(0,1fr)_350px] lg:gap-12">
<div className="min-w-0">
<AnimatePresence mode="wait" initial={false}>
<motion.section
key={screen}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? undefined : { opacity: 0, y: -8 }}
transition={{ duration: reduceMotion ? 0 : 0.2, ease }}
>
{screen === "welcome" ? (
<WelcomeScreen onStart={() => goNext("context")} onSkip={() => void handleSkipSetup()} />
) : null}
{screen === "context" ? (
<ContextScreen
profile={profile}
onChange={updateProfile}
onContinue={() => goNext("subjects")}
onBack={() => goNext("welcome")}
/>
) : null}
{screen === "subjects" ? (
<SubjectsScreen
subjects={subjects}
customSubject={customSubject}
onSubjectChange={selectSubject}
onCustomChange={setCustomSubject}
onCustomAdd={addCustomSubject}
onCombinationAdd={(combinationSubjects) => {
const current = profile.subjects ?? [];
const newSubjects = [...new Set([...current, ...combinationSubjects])];
updateProfile({ subjects: newSubjects, subject: newSubjects[0] ?? "" });
}}
onContinue={() => goNext("exam-mode")}
onBack={() => goNext("context")}
/>
) : null}
{screen === "exam-mode" ? (
<ExamModeScreen
selected={selectedGoals}
primary={profile.primaryGoal ?? ""}
onToggle={toggleGoal}
onPrimary={makePrimaryGoal}
onContinue={() => goNext("material")}
onBack={() => goNext("subjects")}
/>
) : null}
{screen === "material" ? (
<MaterialScreen
materialKind={selectedMaterialKind}
file={selectedFile}
inputRef={fileInputRef}
dragging={isDragging}
uploading={isUploading}
notice={uploadNotice}
onKindSelect={setSelectedMaterialKind}
onFile={handleFile}
onFileRemove={() => setSelectedFile(null)}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onUpload={() => void uploadFirstMaterial()}
onSkip={finishWithoutUpload}
onBack={() => goNext("exam-mode")}
/>
) : null}
{screen === "ready" ? (
<ReadyScreen
profile={profile}
onDashboard={() => router.push("/home")}
onUploadAnother={() => router.push("/upload")}
/>
) : null}
</motion.section>
</AnimatePresence>
</div>
<SignalPreview profile={profile} screen={screen} />
</div>
<Sheet open={showSkipSheet} onOpenChange={setShowSkipSheet}>
<SheetContent side="bottom" className="rounded-t-2xl">
<SheetHeader>
<SheetTitle>Skip setup?</SheetTitle>
<SheetDescription>
You can always set this up later, but DocDoe works best with your exam context.
</SheetDescription>
</SheetHeader>
<SheetFooter className="mt-6">
<LiquidButton variant="secondary" onClick={() => setShowSkipSheet(false)}>
Continue setup
</LiquidButton>
<LiquidButton onClick={() => { setShowSkipSheet(false); completeSkip(); }}>
Skip anyway
</LiquidButton>
</SheetFooter>
</SheetContent>
</Sheet>
</main>
);
}
function StepHeading({ title, body, eyebrow }: { title: string; body: string; eyebrow?: string }) {
return (
<div className="max-w-[590px]">
{eyebrow ? (
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/38">{eyebrow}</p>
) : (
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/38">Build your exam path</p>
)}
<h1 className="mt-4 text-[clamp(2.1rem,7.5vw,3.35rem)] font-semibold leading-[1.02] tracking-[-0.065em] text-[#f8fafc]">
{title}
</h1>
<p className="mt-4 max-w-[520px] text-[15px] leading-7 text-white/57 sm:text-[16px]">{body}</p>
</div>
);
}
function WelcomeScreen({ onStart, onSkip }: { onStart: () => void; onSkip: () => void }) {
return (
<div className="pt-5 sm:pt-10">
<StepHeading
eyebrow="SETUP TAKES LESS THAN A MINUTE"
title="Let's build your exam path."
body="Tell DocDoe what you're preparing for. Then upload material and get a study path built around what matters."
/>
<div className="mt-10 grid max-w-[560px] grid-cols-3 items-center gap-2 rounded-[22px] border border-white/[0.08] bg-white/[0.02] p-4 sm:p-5">
{["Material", "Signals", "Study path"].map((label, index) => (
<div key={label} className="flex min-w-0 items-center gap-2">
<span className={cn("size-2 shrink-0 rounded-full", index === 1 ? "bg-white/70" : "bg-white/28")} />
<span className="truncate text-[12px] font-medium text-white/58">{label}</span>
{index < 2 ? <span className="ml-auto h-px flex-1 bg-white/12" aria-hidden="true" /> : null}
</div>
))}
</div>
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
<LiquidButton onClick={onStart} className="!min-h-12">Start setup</LiquidButton>
<SecondaryAction onClick={onSkip}>Skip for now</SecondaryAction>
</div>
</div>
);
}
function ContextScreen({
profile,
onChange,
onContinue,
onBack,
}: {
profile: StudentStudyProfile;
onChange: (patch: Partial<StudentStudyProfile>) => void;
onContinue: () => void;
onBack: () => void;
}) {
const valid = Boolean(profile.board && profile.classLevel && profile.stream);
return (
<div>
<StepHeading
title="What exam are you preparing for?"
body="DocDoe uses this to understand your board, class, and stream. Kerala HSE is supported first for closed beta."
/>
<div className="mt-9 grid gap-7">
<OptionGroup
title="Board / exam system"
values={boardOptions}
selected={profile.board}
onSelect={(board) => onChange({ board, exam: board })}
recommendedValue="Kerala HSE"
helper="Your exam board determines the syllabus and question patterns."
/>
<OptionGroup
title="Class"
values={classOptions}
selected={profile.classLevel ?? ""}
onSelect={(classLevel) => onChange({ classLevel, grade: classLevel })}
helper="Your current academic level helps tailor difficulty appropriately."
/>
<OptionGroup
title="Stream"
values={streamOptions}
selected={profile.stream ?? ""}
onSelect={(stream) => onChange({ stream })}
helper="Your subject stream determines available electives and focus areas."
/>
</div>
{!valid && (
<p role="alert" className="mt-5 flex items-center gap-2 text-[12px] text-white/48">
<span className="size-1.5 rounded-full bg-white/68" />
Choose your board, class, and stream to continue.
</p>
)}
<StepActions onBack={onBack} onNext={onContinue} disabled={!valid} />
</div>
);
}
function SubjectsScreen({
subjects,
customSubject,
onSubjectChange,
onCustomChange,
onCustomAdd,
onCombinationAdd,
onContinue,
onBack,
}: {
subjects: string[];
customSubject: string;
onSubjectChange: (subject: string) => void;
onCustomChange: (value: string) => void;
onCustomAdd: () => void;
onCombinationAdd: (combinationSubjects: readonly string[]) => void;
onContinue: () => void;
onBack: () => void;
}) {
const hasSubjects = subjects.length > 0;
const [search, setSearch] = useState("");
const filteredSubjects = subjectOptions.filter((subject) =>
subject.toLowerCase().includes(search.trim().toLowerCase()),
);
function addCombination(combinationSubjects: readonly string[]) {
onCombinationAdd(combinationSubjects);
}
return (
<div>
<StepHeading
title="What subjects should DocDoe focus on?"
body="Pick the subjects you want help with first. You can change this later."
/>
<div className="mt-6 flex flex-wrap gap-2">
{subjectCombinations.map((combo) => (
<button
key={combo.label}
type="button"
onClick={() => addCombination(combo.subjects)}
className="min-h-11 rounded-lg border border-white/12 bg-white/[0.02] px-3 text-[11px] font-medium text-white/50 transition hover:border-white/24 hover:text-white/70"
>
+ {combo.label}
</button>
))}
</div>
<label className="mt-6 flex h-12 max-w-[440px] items-center gap-3 rounded-xl border border-white/[0.1] bg-[#080808] px-4 focus-within:border-white/26">
<Search className="size-4 shrink-0 text-white/35" strokeWidth={1.7} />
<span className="sr-only">Search subjects</span>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search subjects"
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/30"
/>
</label>
<div className="mt-6 flex max-w-[640px] flex-wrap gap-2.5">
{filteredSubjects.map((subject) => (
<button
key={subject}
type="button"
aria-pressed={subjects.includes(subject)}
onClick={() => onSubjectChange(subject)}
className={cn(
"relative min-h-12 rounded-full border px-5 text-[13px] font-medium transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[#030303] hover:-translate-y-px active:scale-95",
subjects.includes(subject)
? "border-white/28 bg-white/[0.09] text-white after:absolute after:inset-0 after:rounded-full after:border after:border-white/28"
: "border-white/[0.09] bg-[#080808] text-white/58 hover:border-white/17 hover:text-white",
)}
>
{subject}
{subjects.includes(subject) && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 size-1.5 rounded-full bg-white/70" aria-hidden="true" />
)}
</button>
))}
{filteredSubjects.length === 0 ? (
<p className="py-3 text-[12px] text-white/40">
No match. Add your subject below.
</p>
) : null}
</div>
{!hasSubjects && (
<p role="alert" className="mt-4 flex items-center gap-2 text-[12px] text-white/48">
<span className="size-1.5 rounded-full bg-white/68" />
Choose at least one subject to continue.
</p>
)}
<div className="mt-7 flex max-w-[440px] gap-2">
<label className="sr-only" htmlFor="custom-subject">
Add another subject
</label>
<input
id="custom-subject"
value={customSubject}
onChange={(event) => onCustomChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onCustomAdd();
}
}}
placeholder="Add another subject"
className="h-12 min-w-0 flex-1 rounded-xl border border-white/[0.1] bg-[#080808] px-4 text-[13px] text-white outline-none placeholder:text-white/30 transition-all duration-200 ease-out focus:border-white/40 focus:shadow-[0_0_0_3px_rgba(255,255,255,0.12)] focus:bg-white/[0.02]"
/>
<button
type="button"
onClick={onCustomAdd}
aria-label={`Add ${customSubject || "custom subject"}`}
className="h-12 rounded-xl border border-white/[0.11] px-4 text-[13px] font-medium text-white/70 transition hover:border-white/22 hover:text-white"
>
Add
</button>
</div>
<StepActions onBack={onBack} onNext={onContinue} disabled={!hasSubjects} />
</div>
);
}
function ExamModeScreen({
selected,
primary,
onToggle,
onPrimary,
onContinue,
onBack,
}: {
selected: OnboardingGoalChoice[];
primary: OnboardingGoalChoice | "";
onToggle: (goal: OnboardingGoalChoice) => void;
onPrimary: (goal: OnboardingGoalChoice) => void;
onContinue: () => void;
onBack: () => void;
}) {
return (
<div>
<StepHeading
title="What do you need most right now?"
body="Choose your main focus. You can use other modes later."
/>
<div className="mt-8 grid max-w-[670px] gap-2.5 sm:grid-cols-2">
{examGoals.map((goal) => {
const checked = selected.includes(goal.id);
const isPrimary = primary === goal.id;
return (
<div
key={goal.id}
className={cn(
"relative flex min-h-[126px] flex-col rounded-[18px] border px-4 py-4 transition-all duration-250 ease-out",
checked
? "border-white/28 bg-white/[0.052] shadow-[0_0_0_1px_rgba(59,130,246,0.25)] hover:shadow-[0_0_0_1px_rgba(59,130,246,0.35)]"
: "border-white/[0.08] bg-[#080808] hover:border-white/14 hover:-translate-y-px hover:shadow-[0_4px_16px_rgba(0,0,0,0.4)]",
)}
>
<button
type="button"
onClick={() => onToggle(goal.id)}
aria-pressed={checked}
className="absolute inset-0 rounded-[18px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/38 focus-visible:ring-offset-2 focus-visible:ring-offset-[#030303] active:scale-[0.99]"
aria-label={`${checked ? "Remove" : "Select"} ${goal.title}`}
/>
<div className="relative pointer-events-none flex items-start justify-between gap-3">
<div>
<p className="text-[15px] font-medium tracking-[-0.02em] text-white/90">{goal.title}</p>
<p className="mt-2 text-[12.5px] leading-5 text-white/48">{goal.text}</p>
</div>
<span
className={cn(
"mt-1 size-2.5 shrink-0 rounded-full border",
checked ? "border-white/70 bg-white/80" : "border-white/22",
)}
/>
</div>
{checked ? (
<button
type="button"
onClick={() => onPrimary(goal.id)}
aria-label={`${isPrimary ? "Primary focus" : `Set ${goal.title} as primary focus`}`}
className={cn(
"relative mt-auto self-start pt-3 text-[10px] font-semibold uppercase tracking-[0.17em] transition",
isPrimary ? "text-white/86" : "text-white/36 hover:text-white/62",
)}
>
{isPrimary ? "Primary focus" : "Set as primary"}
</button>
) : null}
</div>
);
})}
</div>
<StepActions onBack={onBack} onNext={onContinue} disabled={!primary} />
</div>
);
}
function MaterialScreen({
materialKind,
file,
inputRef,
dragging,
uploading,
notice,
onKindSelect,
onFile,
onFileRemove,
onDragEnter,
onDragLeave,
onDrop,
onUpload,
onSkip,
onBack,
}: {
materialKind: MaterialKind;
file: File | null;
inputRef: RefObject<HTMLInputElement | null>;
dragging: boolean;
uploading: boolean;
notice: string | null;
onKindSelect: (kind: MaterialKind) => void;
onFile: (file: File | undefined) => void;
onFileRemove: () => void;
onDragEnter: () => void;
onDragLeave: () => void;
onDrop: (event: DragEvent<HTMLDivElement>) => void;
onUpload: () => void;
onSkip: () => void;
onBack: () => void;
}) {
return (
<div>
<StepHeading
title="Give DocDoe something to read."
body="Upload notes, a chapter, or a question paper. DocDoe will use it to create your first study signal."
/>
<div className="mt-8 grid gap-2 sm:grid-cols-3">
{materialKinds.map((option) => {
const Icon = option.icon;
const active = option.id === materialKind;
return (
<button
type="button"
key={option.id}
onClick={() => onKindSelect(option.id)}
className={cn(
"relative min-h-[100px] rounded-[16px] border p-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30",
active
? "border-white/28 bg-white/[0.052] shadow-[0_0_0_1px_rgba(59,130,246,0.25)]"
: "border-white/[0.08] bg-[#080808] hover:border-white/16",
)}
>
<Icon className="size-4 text-white/54" strokeWidth={1.65} aria-hidden="true" />
<span className="mt-3 block text-[13px] font-medium text-white/84">{option.title}</span>
<span className="mt-1 block text-[11px] text-white/42">{option.description}</span>
{active && (
<span className="absolute right-3 top-3 size-1.5 rounded-full bg-white/70" aria-hidden="true" />
)}
</button>
);
})}
</div>
<input
ref={inputRef}
type="file"
accept={MATERIAL_UPLOAD_ACCEPT}
className="sr-only"
onChange={(event) => {
onFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<div
onDragOver={(event) => event.preventDefault()}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
"mt-4 rounded-[20px] border border-dashed px-5 py-7 transition",
dragging ? "border-white/28 bg-white/[0.045]" : "border-white/[0.12] bg-white/[0.018]",
)}
>
{file ? (
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-white/10 bg-white/[0.035]">
<FileText className="size-5 text-white/64" strokeWidth={1.65} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-white/86">{file.name}</p>
<p className="mt-1 text-[11px] text-white/38">{(file.size / 1024 / 1024).toFixed(2)} MB · Selectable-text PDF or TXT</p>
</div>
<button
type="button"
onClick={onFileRemove}
aria-label={`Remove ${file.name}`}
className="text-[12px] text-white/42 transition hover:text-white"
>
Remove
</button>
</div>
) : (
<div className="text-center">
<Upload className="mx-auto size-5 text-white/43" strokeWidth={1.6} aria-hidden="true" />
<p className="mt-3 text-[13px] text-white/64">Drop a selectable-text PDF or TXT file here</p>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="mt-4 min-h-11 rounded-full border border-white/14 px-5 text-[12px] font-medium text-white/72 transition hover:border-white/25 hover:text-white"
>
Choose file
</button>
<p className="mt-3 text-[11px] text-white/35">Up to 20 MB · scanned PDFs need selectable text</p>
</div>
)}
</div>
{notice ? <p role="alert" className="mt-3 text-[12px] leading-5 text-white/54">{notice}</p> : null}
<div className="mt-9 flex flex-wrap items-center gap-3">
<SecondaryAction onClick={onBack} icon="back">
Back
</SecondaryAction>
{file ? (
<LiquidButton onClick={onUpload} disabled={uploading} className="!min-h-12">
{uploading ? "Receiving material..." : "Upload and continue"}
</LiquidButton>
) : null}
<button type="button" onClick={onSkip} className="min-h-12 px-3 text-[13px] text-white/45 transition hover:text-white/78">
Skip and open DocDoe
</button>
</div>
</div>
);
}
function ReadyScreen({
profile,
onDashboard,
onUploadAnother,
}: {
profile: StudentStudyProfile;
onDashboard: () => void;
onUploadAnother: () => void;
}) {
const summaries = [
["Board", profile.board || "Not selected"],
["Class", profile.classLevel || "Not selected"],
["Stream", profile.stream || "Not selected"],
["Subjects", profile.subjects?.join(", ") || "Add later"],
["Main focus", labelForOnboardingGoal(profile.primaryGoal)],
[
"First material",
profile.firstUploadStatus === "ready" || profile.firstUploadStatus === "received"
? "Material received"
: profile.firstUploadStatus === "processing"
? "Scanning pending"
: "Upload from Sources",
],
];
const hasMaterial = profile.firstUploadStatus === "ready" || profile.firstUploadStatus === "received";
return (
<div>
<StepHeading
title="Your exam path is ready."
body="Start with an upload, generate notes, or open exam mode."
/>
<div className="mt-9 max-w-[620px] overflow-hidden rounded-[22px] border border-white/[0.09] bg-[#080808]">
{summaries.map(([label, value]) => (
<div key={label} className="flex items-start justify-between gap-5 border-b border-white/[0.06] px-5 py-4 last:border-b-0">
<span className="text-[12px] text-white/38">{label}</span>
<span className="max-w-[64%] text-right text-[13px] font-medium text-white/78">{value}</span>
</div>
))}
</div>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<LiquidButton onClick={onDashboard} className="!min-h-12">Open DocDoe</LiquidButton>
<SecondaryAction onClick={onUploadAnother}>{hasMaterial ? "Upload another material" : "Upload material"}</SecondaryAction>
</div>
</div>
);
}
function SignalPreview({ profile, screen }: { profile: StudentStudyProfile; screen: SetupScreen }) {
const contextValue = profile.board ? `${profile.board} • ${profile.classLevel || "Class pending"}` : "Not set";
const subjectLabel = profile.subjects?.slice(0, 2).join(", ") || "Awaiting subjects";
const examModeValue = labelForOnboardingGoal(profile.primaryGoal);
const materialValue = profile.firstUploadDocumentId ? "Material received" : screen === "material" ? "Ready for upload" : "Pending";
const steps = [
{ label: "Context set", done: Boolean(profile.board) },
{ label: "Subjects chosen", done: Boolean(profile.subjects?.length) },
{ label: "Focus chosen", done: Boolean(profile.primaryGoal) },
{ label: "Material ready", done: Boolean(profile.firstUploadDocumentId) },
];
const completedSteps = steps.filter((s) => s.done).length;
const progressLabel = completedSteps === 4 ? "Signal found" : "Ready for signal";
return (
<aside aria-label="Study setup progress" className="hidden lg:block">
<div className="sticky top-8 rounded-[24px] border border-white/[0.09] bg-[#070707] p-5 shadow-[0_28px_70px_rgba(0,0,0,0.22)]">
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-white/38">Study signal</p>
<h2 className="mt-3 text-[20px] font-medium tracking-[-0.045em] text-white/92">Exam path setup</h2>
<div className="mt-6 grid gap-0">
<div className="relative grid grid-cols-[18px_1fr] gap-3 pb-6">
<span className="mt-[5px] size-[9px] rounded-full border border-white/20 bg-[#070707]" />
<div>
<p className="text-[11px] font-medium text-white/40">Study context</p>
<p className="mt-1 text-[12px] leading-5 text-white/68">{contextValue}</p>
</div>
</div>
<div className="relative grid grid-cols-[18px_1fr] gap-3 pb-6">
<span className="absolute bottom-0 left-[4px] top-3 w-px bg-white/[0.09]" />
<span className={cn("mt-[5px] size-[9px] rounded-full border", profile.subjects?.length ? "border-white/70 bg-white/80" : "border-white/20 bg-[#070707]")} />
<div>
<p className="text-[11px] font-medium text-white/40">Subjects</p>
<p className="mt-1 text-[12px] leading-5 text-white/68">{subjectLabel}</p>
</div>
</div>
<div className="relative grid grid-cols-[18px_1fr] gap-3 pb-6">
<span className="absolute bottom-0 left-[4px] top-3 w-px bg-white/[0.09]" />
<span className={cn("mt-[5px] size-[9px] rounded-full border", profile.primaryGoal ? "border-white/70 bg-white/80" : "border-white/20 bg-[#070707]")} />
<div>
<p className="text-[11px] font-medium text-white/40">Exam mode</p>
<p className="mt-1 text-[12px] leading-5 text-white/68">{examModeValue}</p>
</div>
</div>
<div className="relative grid grid-cols-[18px_1fr] gap-3 pb-0">
<span className={cn("mt-[5px] size-[9px] rounded-full border", profile.firstUploadDocumentId ? "border-white/70 bg-white/80" : "border-white/20 bg-[#070707]")} />
<div>
<p className="text-[11px] font-medium text-white/40">First material</p>
<p className="mt-1 text-[12px] leading-5 text-white/68">{materialValue}</p>
</div>
</div>
</div>
<div className="mt-7 border-t border-white/[0.07] pt-4">
<div className="flex items-center justify-between text-[10px] font-semibold uppercase tracking-[0.16em] text-white/32">
<span>{completedSteps}/4 steps</span>
<span>{progressLabel}</span>
</div>
<div className="mt-3 h-px bg-white/[0.08]">
<span className="block h-px bg-gradient-to-r from-white/22 to-white/55" style={{ width: `${(completedSteps / 4) * 100}%` }} />
</div>
</div>
</div>
</aside>
);
}
function OptionGroup<T extends string>({
title,
values,
selected,
onSelect,
recommendedValue,
helper,
}: {
title: string;
values: readonly T[];
selected: string;
onSelect: (value: T) => void;
recommendedValue?: T;
helper?: string;
}) {
return (
<fieldset>
<legend className="mb-3 text-[12px] font-medium text-white/52">{title}</legend>
<div className="flex flex-wrap gap-2">
{values.map((value) => {
const isRecommended = value === recommendedValue;
return (
<button
key={value}
type="button"
aria-pressed={selected === value}
onClick={() => onSelect(value)}
className={cn(
"relative min-h-12 rounded-xl border px-4 text-[13px] font-medium transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[#030303] hover:-translate-y-px hover:shadow-[0_2px_8px_rgba(0,0,0,0.3)] active:scale-95",
selected === value
? "border-white/28 bg-white/[0.09] text-white shadow-[0_0_0_1px_rgba(59,130,246,0.25)]"
: "border-white/[0.09] bg-[#080808] text-white/58 hover:border-white/18 hover:text-white",
)}
>
{value}
{isRecommended && (
<span className="absolute -right-1.5 -top-1.5 flex size-3.5 items-center justify-center rounded-full bg-white text-[8px] font-bold text-[#080808]">
✓
</span>
)}
</button>
);
})}
</div>
{helper && (
<p className="mt-2 text-[11px] text-white/38">{helper}</p>
)}
</fieldset>
);
}
function StepActions({ onBack, onNext, disabled = false }: { onBack: () => void; onNext: () => void; disabled?: boolean }) {
return (
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row">
<SecondaryAction onClick={onBack} icon="back">
Back
</SecondaryAction>
<LiquidButton onClick={onNext} disabled={disabled} className="!min-h-12">
Continue
</LiquidButton>
</div>
);
}
function SecondaryAction({
children,
onClick,
icon,
}: {
children: ReactNode;
onClick: () => void;
icon?: "back";
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-full border border-white/[0.12] bg-transparent px-6 text-[13px] font-medium text-white/65 transition hover:border-white/22 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
>
{icon ? <ArrowLeft className="size-4" strokeWidth={1.7} aria-hidden="true" /> : null}
{children}
</button>
);
}