Download src/components/onboarding/StudySetupFlow.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 47.4 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/onboarding/StudySetupFlow.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/onboarding/StudySetupFlow.tsx
-
curl -L -o StudySetupFlow.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/onboarding/StudySetupFlow.tsx
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> | |
| ); | |
| } | |