Spaces:
Sleeping
Sleeping
| import React, { useRef, useState, useEffect, useMemo } from "react"; | |
| import { Button } from "./ui/button"; | |
| import { Input } from "./ui/input"; | |
| import { Label } from "./ui/label"; | |
| import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"; | |
| import type { User as UserType } from "../App"; | |
| import { toast } from "sonner"; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| import { ChevronLeft, ChevronRight } from "lucide-react"; | |
| import { Textarea } from "./ui/textarea"; | |
| // ✅ Add Bio step. Total steps: 5 | |
| const TOTAL_STEPS = 5; | |
| type InitQ = { | |
| id: string; | |
| title: string; | |
| placeholder?: string; | |
| }; | |
| const INIT_QUESTIONS: InitQ[] = [ | |
| { | |
| id: "course_goal", | |
| title: "What’s the single most important outcome you want from this course?", | |
| placeholder: "e.g., understand LLM basics, build a project, prep for an exam, apply to work…", | |
| }, | |
| { | |
| id: "background", | |
| title: "What’s your current background (major, job, or anything relevant)?", | |
| placeholder: "One sentence is totally fine.", | |
| }, | |
| { | |
| id: "ai_experience", | |
| title: "Have you worked with AI/LLMs before? If yes, at what level?", | |
| placeholder: "e.g., none / used ChatGPT / built small projects / research…", | |
| }, | |
| { | |
| id: "python_level", | |
| title: "How comfortable are you with Python? (Beginner / Intermediate / Advanced)", | |
| placeholder: "Type one: Beginner / Intermediate / Advanced", | |
| }, | |
| { | |
| id: "preferred_format", | |
| title: "What helps you learn best? (You can list multiple, separated by commas)", | |
| placeholder: "Step-by-step, examples, visuals, concise answers, Socratic questions…", | |
| }, | |
| { | |
| id: "pace", | |
| title: "What pace do you prefer from me? (Fast / Steady / Very detailed)", | |
| placeholder: "Type one: Fast / Steady / Very detailed", | |
| }, | |
| { | |
| id: "biggest_pain", | |
| title: "Where do you typically get stuck when learning technical topics?", | |
| placeholder: "Concepts, tools, task breakdown, math, confidence, time management…", | |
| }, | |
| { | |
| id: "support_pref", | |
| title: "When you’re unsure, how should I support you?", | |
| placeholder: "Hints first / guided questions / direct answer / ask then answer…", | |
| }, | |
| ]; | |
| interface OnboardingProps { | |
| user: UserType; | |
| onComplete: (user: UserType) => void; | |
| onSkip: () => void; | |
| } | |
| export function Onboarding({ user, onComplete, onSkip }: OnboardingProps) { | |
| const [currentStep, setCurrentStep] = useState(1); | |
| // Step 1: Basic | |
| const [name, setName] = useState(user.name ?? ""); | |
| const [email, setEmail] = useState(user.email ?? ""); | |
| // Step 2: Academic | |
| const [studentId, setStudentId] = useState(user.studentId ?? ""); | |
| const [department, setDepartment] = useState(user.department ?? ""); | |
| const [yearLevel, setYearLevel] = useState(user.yearLevel ?? ""); | |
| const [major, setMajor] = useState(user.major ?? ""); | |
| // Step 3: Preferences | |
| const [learningStyle, setLearningStyle] = useState(user.learningStyle ?? "visual"); | |
| const [learningPace, setLearningPace] = useState(user.learningPace ?? "moderate"); | |
| // Step 4: Bio (8 questions -> generate bio) | |
| const [bioQIndex, setBioQIndex] = useState(0); | |
| const [bioInput, setBioInput] = useState(""); | |
| const [bioAnswers, setBioAnswers] = useState<Record<string, string>>({}); | |
| const [bioSubmitting, setBioSubmitting] = useState(false); | |
| const [generatedBio, setGeneratedBio] = useState<string>(user.bio ?? ""); | |
| const [bioReady, setBioReady] = useState<boolean>(!!(user.bio && user.bio.trim().length > 0)); | |
| const currentBioQ = useMemo(() => INIT_QUESTIONS[bioQIndex], [bioQIndex]); | |
| // Optional: if user already has bio, mark ready. | |
| useEffect(() => { | |
| if (user.bio && user.bio.trim().length > 0) { | |
| setGeneratedBio(user.bio); | |
| setBioReady(true); | |
| } | |
| }, [user.bio]); | |
| // Step 5: Photo | |
| const [photoPreview, setPhotoPreview] = useState<string | null>(user.avatarUrl ?? null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const handlePhotoSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| if (!file.type.startsWith("image/")) { | |
| toast.error("Please select an image file"); | |
| return; | |
| } | |
| if (file.size > 2 * 1024 * 1024) { | |
| toast.error("File size must be less than 2MB"); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (ev) => setPhotoPreview(ev.target?.result as string); | |
| reader.readAsDataURL(file); | |
| }; | |
| const handleChangePhotoClick = () => fileInputRef.current?.click(); | |
| const handlePrevious = () => { | |
| if (currentStep > 1) setCurrentStep((s) => s - 1); | |
| }; | |
| const handleSkip = () => onSkip(); | |
| // -------------------------- | |
| // Step 4: Bio generation flow | |
| // -------------------------- | |
| const handleBioNext = async () => { | |
| const v = bioInput.trim(); | |
| if (!v) return; | |
| const q = INIT_QUESTIONS[bioQIndex]; | |
| const nextAnswers = { ...bioAnswers, [q.id]: v }; | |
| setBioAnswers(nextAnswers); | |
| setBioInput(""); | |
| const nextIndex = bioQIndex + 1; | |
| // Continue questions | |
| if (nextIndex < INIT_QUESTIONS.length) { | |
| setBioQIndex(nextIndex); | |
| return; | |
| } | |
| // Last question -> submit to backend and generate bio | |
| // NOTE: use same backend logic as before; we do NOT touch parsing/storage logic. | |
| setBioSubmitting(true); | |
| try { | |
| const r = await fetch("/api/profile/init_submit", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| user_id: email.trim() || user.email, // prefer current email input | |
| answers: nextAnswers, | |
| language_preference: "English", | |
| }), | |
| }); | |
| if (!r.ok) throw new Error("init_submit failed"); | |
| const j = await r.json(); | |
| const bio = (j?.bio || "").toString(); | |
| if (!bio.trim()) { | |
| throw new Error("empty bio"); | |
| } | |
| setGeneratedBio(bio); | |
| setBioReady(true); | |
| toast.success("Bio generated!"); | |
| } catch (e) { | |
| toast.error("Failed to generate bio. Please try again."); | |
| // allow retry: keep last answer stored; user can edit generated flow by resetting if needed | |
| } finally { | |
| setBioSubmitting(false); | |
| } | |
| }; | |
| const handleBioReset = () => { | |
| setBioQIndex(0); | |
| setBioInput(""); | |
| setBioAnswers({}); | |
| setBioSubmitting(false); | |
| setGeneratedBio(""); | |
| setBioReady(false); | |
| }; | |
| // Main Next handler (respects Step 4 gating) | |
| const handleNext = async () => { | |
| // Step 1 validation (kept) | |
| if (currentStep === 1) { | |
| if (!name.trim() || !email.trim()) { | |
| toast.error("Please fill in all required fields"); | |
| return; | |
| } | |
| } | |
| // Step 4 gating: must finish + have bioReady before moving on | |
| if (currentStep === 4) { | |
| if (!bioReady) { | |
| // If still answering questions, Next acts as “Next question” | |
| if (bioQIndex < INIT_QUESTIONS.length) { | |
| await handleBioNext(); | |
| return; | |
| } | |
| // Safety: should not happen, but block | |
| toast.error("Please finish the Bio questions first."); | |
| return; | |
| } | |
| } | |
| if (currentStep < TOTAL_STEPS) setCurrentStep((s) => s + 1); | |
| else handleComplete(); | |
| }; | |
| const handleComplete = () => { | |
| if (!name.trim() || !email.trim()) { | |
| toast.error("Please fill in all required fields"); | |
| return; | |
| } | |
| // ✅ Bio now comes from Onboarding Step 4 | |
| const finalBio = (generatedBio || user.bio || "").trim() || undefined; | |
| const next: UserType = { | |
| ...user, | |
| name: name.trim(), | |
| email: email.trim(), | |
| studentId: studentId.trim() || undefined, | |
| department: department.trim() || undefined, | |
| yearLevel: yearLevel || undefined, | |
| major: major.trim() || undefined, | |
| learningStyle: learningStyle || undefined, | |
| learningPace: learningPace || undefined, | |
| avatarUrl: photoPreview || undefined, | |
| bio: finalBio, // ✅ sync to profile via your existing onComplete->save logic | |
| onboardingCompleted: true, | |
| }; | |
| onComplete(next); | |
| toast.success("Profile setup completed!"); | |
| }; | |
| const renderStepContent = () => { | |
| switch (currentStep) { | |
| case 1: | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-medium">Basic Information</h3> | |
| <p className="text-sm text-muted-foreground">Let's start with your basic information</p> | |
| <div className="space-y-4"> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-name">Full Name *</Label> | |
| <Input | |
| id="onboarding-name" | |
| value={name} | |
| onChange={(e) => setName(e.target.value)} | |
| placeholder="Enter your full name" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-email">Email *</Label> | |
| <Input | |
| id="onboarding-email" | |
| type="email" | |
| value={email} | |
| onChange={(e) => setEmail(e.target.value)} | |
| placeholder="Enter your email" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| case 2: | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-medium">Academic Background</h3> | |
| <p className="text-sm text-muted-foreground">Tell us about your academic information</p> | |
| <div className="space-y-4"> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-student-id">Student ID</Label> | |
| <Input | |
| id="onboarding-student-id" | |
| value={studentId} | |
| onChange={(e) => setStudentId(e.target.value)} | |
| placeholder="Enter your student ID" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-department">Department</Label> | |
| <Input | |
| id="onboarding-department" | |
| value={department} | |
| onChange={(e) => setDepartment(e.target.value)} | |
| placeholder="Enter your department" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-year">Year Level</Label> | |
| <Select value={yearLevel} onValueChange={setYearLevel}> | |
| <SelectTrigger id="onboarding-year"> | |
| <SelectValue placeholder="Select year level" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="1st Year">1st Year</SelectItem> | |
| <SelectItem value="2nd Year">2nd Year</SelectItem> | |
| <SelectItem value="3rd Year">3rd Year</SelectItem> | |
| <SelectItem value="4th Year">4th Year</SelectItem> | |
| <SelectItem value="Graduate">Graduate</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-major">Major</Label> | |
| <Input | |
| id="onboarding-major" | |
| value={major} | |
| onChange={(e) => setMajor(e.target.value)} | |
| placeholder="Enter your major" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| case 3: | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-medium">Learning Preferences</h3> | |
| <p className="text-sm text-muted-foreground">Help us personalize your learning experience</p> | |
| <div className="space-y-4"> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-learning-style">Preferred Learning Style</Label> | |
| <Select value={learningStyle} onValueChange={setLearningStyle}> | |
| <SelectTrigger id="onboarding-learning-style"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="visual">Visual</SelectItem> | |
| <SelectItem value="auditory">Auditory</SelectItem> | |
| <SelectItem value="reading">Reading/Writing</SelectItem> | |
| <SelectItem value="kinesthetic">Kinesthetic</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label htmlFor="onboarding-pace">Learning Pace</Label> | |
| <Select value={learningPace} onValueChange={setLearningPace}> | |
| <SelectTrigger id="onboarding-pace"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="slow">Slow & Steady</SelectItem> | |
| <SelectItem value="moderate">Moderate</SelectItem> | |
| <SelectItem value="fast">Fast-paced</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| case 4: | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-medium">Profile Bio</h3> | |
| <p className="text-sm text-muted-foreground"> | |
| Answer a few quick questions and we’ll generate a Bio that syncs to your profile. | |
| </p> | |
| {!bioReady ? ( | |
| <div className="space-y-3"> | |
| <div className="rounded-lg border border-border bg-muted/30 p-4 space-y-2"> | |
| <div className="text-sm font-medium">{currentBioQ.title}</div> | |
| {currentBioQ.placeholder && ( | |
| <div className="text-xs text-muted-foreground">{currentBioQ.placeholder}</div> | |
| )} | |
| <div className="text-xs text-muted-foreground"> | |
| Question {bioQIndex + 1} of {INIT_QUESTIONS.length} | |
| </div> | |
| <Textarea | |
| value={bioInput} | |
| onChange={(e) => setBioInput(e.target.value)} | |
| placeholder="Type your answer here..." | |
| className="min-h-[96px] mt-2" | |
| disabled={bioSubmitting} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleBioNext(); | |
| } | |
| }} | |
| /> | |
| <div className="flex items-center justify-between pt-2"> | |
| <Button variant="outline" onClick={handleBioReset} disabled={bioSubmitting}> | |
| Reset | |
| </Button> | |
| <Button onClick={handleBioNext} disabled={bioSubmitting || !bioInput.trim()}> | |
| {bioQIndex === INIT_QUESTIONS.length - 1 | |
| ? bioSubmitting | |
| ? "Generating…" | |
| : "Generate Bio" | |
| : "Next Question"} | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| Tip: Press Enter to go next (Shift+Enter for a new line). | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="space-y-3"> | |
| <div className="rounded-lg border border-border bg-background p-4 space-y-2"> | |
| <div className="text-sm font-medium">Generated Bio</div> | |
| <div className="text-xs text-muted-foreground"> | |
| You can edit it before continuing. This will be saved to your profile. | |
| </div> | |
| <Textarea | |
| value={generatedBio} | |
| onChange={(e) => setGeneratedBio(e.target.value)} | |
| className="min-h-[140px] mt-2" | |
| /> | |
| <div className="flex items-center justify-between pt-2"> | |
| <Button variant="outline" onClick={handleBioReset}> | |
| Regenerate | |
| </Button> | |
| <div className="text-xs text-muted-foreground"> | |
| Click “Next Step” to continue. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| case 5: | |
| return ( | |
| <div className="space-y-4"> | |
| <h3 className="text-lg font-medium">Profile Picture</h3> | |
| <p className="text-sm text-muted-foreground">Upload a photo to personalize your profile (optional)</p> | |
| <div className="flex items-center gap-4"> | |
| <div className="w-24 h-24 rounded-full bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center text-white text-3xl overflow-hidden"> | |
| {photoPreview ? ( | |
| <img src={photoPreview} alt="Profile" className="w-full h-full object-cover" /> | |
| ) : ( | |
| (name?.charAt(0) || "U").toUpperCase() | |
| )} | |
| </div> | |
| <div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/jpeg,image/png,image/gif,image/webp" | |
| onChange={handlePhotoSelect} | |
| className="hidden" | |
| /> | |
| <Button variant="outline" size="sm" onClick={handleChangePhotoClick}> | |
| Change Photo | |
| </Button> | |
| <p className="text-xs text-muted-foreground mt-1">JPG, PNG or GIF. Max size 2MB</p> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| default: | |
| return null; | |
| } | |
| }; | |
| return ( | |
| <Dialog | |
| open | |
| onOpenChange={(open) => { | |
| if (!open) onSkip(); | |
| }} | |
| > | |
| <DialogContent | |
| className="sm:max-w-lg p-0 gap-0 max-h-[90vh] overflow-hidden" | |
| style={{ zIndex: 1001 }} | |
| overlayClassName="!inset-0 !z-[99]" | |
| overlayStyle={{ top: 0, left: 0, right: 0, bottom: 0, zIndex: 99, position: "fixed" }} | |
| > | |
| <div className="flex flex-col max-h-[90vh]"> | |
| {/* Header */} | |
| <div className="border-b border-border p-4 flex items-center justify-between flex-shrink-0"> | |
| <div className="flex-1"> | |
| <DialogTitle className="text-xl font-medium">Welcome! Let's set up your profile</DialogTitle> | |
| <p className="text-sm text-muted-foreground mt-1"> | |
| Step {currentStep} of {TOTAL_STEPS} | |
| </p> | |
| </div> | |
| {/* Progress indicator */} | |
| <div className="flex gap-1"> | |
| {Array.from({ length: TOTAL_STEPS }).map((_, index) => ( | |
| <div | |
| key={index} | |
| className={`h-2 w-2 rounded-full transition-colors ${ | |
| index + 1 <= currentStep ? "bg-primary" : "bg-muted" | |
| }`} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Content */} | |
| <div className="p-6 overflow-y-auto flex-1">{renderStepContent()}</div> | |
| {/* Footer */} | |
| <div className="border-t border-border p-4 flex justify-between gap-2 flex-shrink-0"> | |
| <div className="flex gap-2"> | |
| {currentStep > 1 && ( | |
| <Button variant="outline" onClick={handlePrevious} disabled={bioSubmitting}> | |
| <ChevronLeft className="h-4 w-4 mr-1" /> | |
| Previous | |
| </Button> | |
| )} | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button variant="outline" onClick={handleSkip} disabled={bioSubmitting}> | |
| Skip all | |
| </Button> | |
| <Button onClick={handleNext} disabled={bioSubmitting}> | |
| {currentStep === TOTAL_STEPS ? "Complete" : "Next Step"} | |
| {currentStep < TOTAL_STEPS && <ChevronRight className="h-4 w-4 ml-1" />} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |