| "use client"; |
|
|
| import Link from "next/link"; |
| import { useState, useRef, useEffect, useCallback } from "react"; |
| import { useRouter } from "next/navigation"; |
|
|
| |
| const QUESTIONS = [ |
| "Let's start by talking about things you usually enjoy. Lately, have you felt like you've lost interest or pleasure in doing them? Tell me a bit about how you've been feeling about your hobbies or daily activities.", |
| "I'd like to hear about your mood recently. Over the past couple of weeks, have you been feeling down, depressed, or perhaps a bit hopeless? Could you share what's been on your mind?", |
| "Let's talk about your sleep. How have you been sleeping lately? Tell me if you've had any trouble falling asleep, staying asleep, or maybe even sleeping too much.", |
| "How has your energy level been? Do you find yourself feeling tired or exhausted often? Tell me about a typical day and how you feel physically.", |
| "What about your eating habits? Have you noticed any changes, like a poor appetite or perhaps overeating? Tell me how your relationship with food has been lately.", |
| "How have you been feeling about yourself? Have there been moments where you felt bad, or like you let yourself or your family down? Feel free to share your thoughts openly.", |
| "Can you tell me about your focus and concentration? For example, when you read or watch TV, do you find your mind wandering? Tell me how it's been for you.", |
| "Have you or others noticed any changes in how you move or speak? Like moving much slower than usual, or maybe the opposite—feeling restless and unable to sit still? Tell me about it.", |
| ]; |
|
|
| const QUICK_OPTIONS = [ |
| "Not at all", |
| "Several days", |
| "More than half the days", |
| "Nearly every day", |
| ]; |
|
|
| const ITEM_SHORT_NAMES = [ |
| "Loss of Interest", |
| "Feeling Depressed", |
| "Sleep Issues", |
| "Fatigue", |
| "Appetite Changes", |
| "Self-Criticism", |
| "Concentration", |
| "Psychomotor", |
| ]; |
|
|
| type Message = { role: "bot" | "user"; text: string }; |
|
|
| export default function ChatScreening() { |
| const router = useRouter(); |
| const [messages, setMessages] = useState<Message[]>([ |
| { role: "bot", text: "Hello! I'm going to ask you 8 short questions based on the PHQ-8 screening. Please answer honestly — all data stays anonymous and is not stored on any server.\n\nLet's begin." }, |
| { role: "bot", text: QUESTIONS[0] }, |
| ]); |
| const [currentStep, setCurrentStep] = useState(0); |
| const [answers, setAnswers] = useState<string[]>([]); |
| const [inputValue, setInputValue] = useState(""); |
| const [isTyping, setIsTyping] = useState(false); |
| const [isSubmitting, setIsSubmitting] = useState(false); |
| const chatEndRef = useRef<HTMLDivElement>(null); |
| const inputRef = useRef<HTMLInputElement>(null); |
|
|
| |
| useEffect(() => { |
| chatEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| }, [messages, isTyping]); |
|
|
| |
| useEffect(() => { |
| if (!isSubmitting) inputRef.current?.focus(); |
| }, [currentStep, isSubmitting]); |
|
|
| |
| const submitToBackend = useCallback( |
| async (allAnswers: string[]) => { |
| setIsSubmitting(true); |
| setIsTyping(true); |
| setMessages((prev) => [ |
| ...prev, |
| { role: "bot", text: "Thank you for completing all 8 questions! I'm now analyzing your responses..." }, |
| ]); |
|
|
| try { |
| const res = await fetch("/api/predict", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ answers: allAnswers }), |
| }); |
|
|
| if (!res.ok) { |
| console.warn(`Backend returned ${res.status}, falling back to local scoring`); |
| throw "Fallback"; |
| } |
| const data = await res.json(); |
|
|
| |
| const result = { |
| ...data, |
| answers: allAnswers, |
| questions: QUESTIONS, |
| itemShortNames: ITEM_SHORT_NAMES, |
| date: new Date().toISOString(), |
| }; |
|
|
| localStorage.setItem("mindcheck_latest_result", JSON.stringify(result)); |
|
|
| setIsTyping(false); |
| setMessages((prev) => [ |
| ...prev, |
| { role: "bot", text: "Analysis complete! Redirecting you to your results..." }, |
| ]); |
|
|
| setTimeout(() => router.push("/result"), 1200); |
| } catch (err) { |
| console.warn("Prediction error:", err); |
| setIsTyping(false); |
|
|
| |
| const fallbackScores: number[] = allAnswers.map((ans) => { |
| const a = ans.toLowerCase().trim(); |
| if (/\b(not at all|never|no|none|tidak|ngga|ga|tidak pernah)\b/.test(a)) return 0; |
| if (/\b(several days|sometimes|a little|occasionally|kadang|jarang)\b/.test(a)) return 1; |
| if (/\b(more than half|often|frequently|most days|sering|lumayan sering)\b/.test(a)) return 2; |
| if (/\b(nearly every day|always|every day|constantly|everyday|selalu|tiap hari|hampir tiap hari)\b/.test(a)) return 3; |
| |
| |
| const negWords = ["hopeless","worthless","empty","tired","sad","lonely","fail","useless","burden","numb","anxious","hate","depressed","miserable","terrible","awful","struggling","suffering","exhausted","overwhelmed","stressed","worried", "sedih", "capek", "lelah", "hampa", "kesepian", "gagal", "beban", "benci", "stres", "bingung", "hancur", "buruk"]; |
| const words = a.split(/\s+/); |
| const negCount = words.filter((w) => negWords.includes(w)).length; |
| if (negCount >= 3) return 3; |
| if (negCount >= 2) return 2; |
| if (negCount >= 1) return 1; |
| return 1; |
|
|
| }); |
|
|
| const totalScore = fallbackScores.reduce((s, v) => s + v, 0); |
| let severity; |
| if (totalScore <= 4) severity = { label: "Minimal", color: "green", description: "Minimal depression — no treatment typically needed." }; |
| else if (totalScore <= 9) severity = { label: "Mild", color: "amber", description: "Mild depression — watchful waiting recommended." }; |
| else if (totalScore <= 14) severity = { label: "Moderate", color: "orange", description: "Moderate depression — consultation recommended." }; |
| else if (totalScore <= 19) severity = { label: "Moderately Severe", color: "red-orange", description: "Moderately severe depression — active treatment recommended." }; |
| else severity = { label: "Severe", color: "red", description: "Severe depression — immediate referral recommended." }; |
|
|
| const combined = allAnswers.join(" ").toLowerCase(); |
| const negWordsList = ["hopeless","worthless","empty","tired","sad","lonely","fail","useless","burden","numb","anxious","hate","depressed","miserable","terrible","awful","struggling","suffering","exhausted","overwhelmed","stressed","worried", "sedih", "capek", "lelah", "hampa", "kesepian", "gagal", "beban", "benci", "stres", "bingung", "hancur", "buruk"]; |
| const foundNeg = negWordsList.filter((w) => combined.includes(w)); |
| const fillerPattern = /\b(um|uh|uhm|hmm|hm|like|you know)\b/gi; |
| const fillerCount = (combined.match(fillerPattern) || []).length; |
|
|
| const result = { |
| scores: fallbackScores, |
| total_score: totalScore, |
| severity, |
| filler_count: fillerCount, |
| negative_words: foundNeg, |
| positive_words: [], |
| item_names: ITEM_SHORT_NAMES.map((n) => n.replace(/ /g, "")), |
| answers: allAnswers, |
| questions: QUESTIONS, |
| itemShortNames: ITEM_SHORT_NAMES, |
| date: new Date().toISOString(), |
| fallback: true, |
| }; |
|
|
| localStorage.setItem("mindcheck_latest_result", JSON.stringify(result)); |
|
|
| setMessages((prev) => [ |
| ...prev, |
| { role: "bot", text: "Analysis complete (using local scoring as the model server is unavailable). Redirecting to your results..." }, |
| ]); |
| setTimeout(() => router.push("/result"), 1200); |
| } |
| }, |
| [router], |
| ); |
|
|
| |
| const handleAnswer = useCallback( |
| (text: string) => { |
| if (isSubmitting || !text.trim()) return; |
|
|
| const newAnswers = [...answers, text.trim()]; |
| setAnswers(newAnswers); |
| setMessages((prev) => [...prev, { role: "user", text: text.trim() }]); |
| setInputValue(""); |
|
|
| const nextStep = currentStep + 1; |
|
|
| if (nextStep >= QUESTIONS.length) { |
| |
| setCurrentStep(nextStep); |
| submitToBackend(newAnswers); |
| } else { |
| |
| setIsTyping(true); |
| setCurrentStep(nextStep); |
| setTimeout(() => { |
| setIsTyping(false); |
| setMessages((prev) => [ |
| ...prev, |
| { role: "bot", text: QUESTIONS[nextStep] }, |
| ]); |
| }, 800); |
| } |
| }, |
| [answers, currentStep, isSubmitting, submitToBackend], |
| ); |
|
|
| const handleKeyDown = (e: React.KeyboardEvent) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| handleAnswer(inputValue); |
| } |
| }; |
|
|
| const progress = Math.min(((currentStep) / QUESTIONS.length) * 100, 100); |
| const isDone = currentStep >= QUESTIONS.length; |
|
|
| return ( |
| <> |
| {/* TopAppBar */} |
| <header className="bg-surface-bright dark:bg-surface-dim font-section-heading text-section-heading w-full sticky top-0 border-b border-outline-variant flex justify-between items-center h-14 px-gutter z-50"> |
| <div className="flex items-center gap-2"> |
| <span className="material-symbols-outlined text-primary dark:text-primary-fixed" data-icon="psychology">psychology</span> |
| <span className="font-section-heading text-section-heading text-primary font-bold">MindCheck</span> |
| </div> |
| <div className="text-primary dark:text-primary-fixed text-sm"> |
| {isDone ? "Complete" : `Question ${currentStep + 1} of ${QUESTIONS.length}`} |
| </div> |
| <Link href="/" className="text-secondary hover:opacity-80 transition-opacity active:opacity-70"> |
| Exit |
| </Link> |
| </header> |
| |
| <div className="flex-1 flex max-w-container-max mx-auto w-full h-[calc(100vh-3.5rem)]"> |
| {/* LEFT SIDEBAR */} |
| <aside className="hidden md:flex flex-col w-[280px] border-r border-outline-variant bg-surface-container-lowest p-gutter justify-between h-full sticky top-14 overflow-y-auto"> |
| <div> |
| <h2 className="font-section-heading text-section-heading text-on-surface mb-stack-md">Questions</h2> |
| <ul className="space-y-stack-sm flex flex-col"> |
| {QUESTIONS.map((_, i) => { |
| const isCompleted = i < currentStep; |
| const isCurrent = i === currentStep && !isDone; |
| return ( |
| <li |
| key={i} |
| className={`flex items-center gap-3 p-2 rounded transition-all duration-300 ${ |
| isCurrent |
| ? "text-primary bg-surface-container-low border border-outline-variant" |
| : isCompleted |
| ? "text-secondary" |
| : "text-secondary" |
| }`} |
| > |
| <span className="material-symbols-outlined" data-icon={ |
| isCompleted ? "check_circle" : isCurrent ? "radio_button_checked" : "radio_button_unchecked" |
| }> |
| {isCompleted ? "check_circle" : isCurrent ? "radio_button_checked" : "radio_button_unchecked"} |
| </span> |
| <span className={`font-caption-sm text-caption-sm ${ |
| isCompleted ? "text-on-surface-variant" : isCurrent ? "font-bold" : "text-outline-variant" |
| }`}> |
| {ITEM_SHORT_NAMES[i]} |
| </span> |
| </li> |
| ); |
| })} |
| </ul> |
| </div> |
| <div className="mt-stack-lg bg-surface-container-low border border-outline-variant rounded p-stack-md flex items-start gap-2 flex-col"> |
| <span className="material-symbols-outlined text-primary text-[20px]" data-icon="info">info</span> |
| <p className="font-caption-sm text-caption-sm text-on-surface-variant">Answer honestly, all data is anonymous and processed locally.</p> |
| <div className="w-full border-t border-outline-variant mt-4 pt-4 text-center"> |
| <p className="text-[11px] text-on-surface-variant opacity-60">© 2023 MindCheck. All rights reserved.</p> |
| </div> |
| </div> |
| </aside> |
| |
| {/* RIGHT MAIN */} |
| <main className="flex-1 flex flex-col relative h-full bg-surface-container-lowest"> |
| {/* Progress Bar */} |
| <div className="w-full h-1 bg-surface-variant absolute top-0 left-0 z-10"> |
| <div |
| className="h-full bg-primary transition-all duration-700 ease-out" |
| style={{ width: `${progress}%` }} |
| /> |
| </div> |
| |
| {/* Chat Area */} |
| <div className="flex-1 overflow-y-auto p-gutter pt-[40px] space-y-stack-lg pb-56"> |
| {messages.map((msg, i) => ( |
| <div |
| key={i} |
| className={`flex gap-4 items-end ${msg.role === "user" ? "justify-end w-full" : "max-w-[80%]"}`} |
| style={{ |
| animation: "fadeSlideIn 0.4s ease-out", |
| }} |
| > |
| {msg.role === "bot" && ( |
| <div className="w-8 h-8 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center shrink-0"> |
| <span className="material-symbols-outlined text-primary text-[18px]" data-icon="psychology">psychology</span> |
| </div> |
| )} |
| <div |
| className={`${ |
| msg.role === "bot" |
| ? "bg-surface-container-lowest border border-outline-variant rounded-t-lg rounded-br-lg" |
| : "bg-[#EEEDFE] border border-outline-variant rounded-t-lg rounded-bl-lg max-w-[80%]" |
| } p-4`} |
| > |
| <p className="font-body-md text-body-md text-on-surface whitespace-pre-line">{msg.text}</p> |
| </div> |
| </div> |
| ))} |
| |
| {/* Typing Indicator */} |
| {isTyping && ( |
| <div className="flex gap-4 items-end max-w-[80%]"> |
| <div className="w-8 h-8 rounded-full bg-surface-container-high border border-outline-variant flex items-center justify-center shrink-0"> |
| <span className="material-symbols-outlined text-primary text-[18px]" data-icon="psychology">psychology</span> |
| </div> |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-t-lg rounded-br-lg p-4 flex gap-1.5 items-center"> |
| <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: "0ms" }} /> |
| <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: "150ms" }} /> |
| <span className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: "300ms" }} /> |
| </div> |
| </div> |
| )} |
| |
| <div ref={chatEndRef} /> |
| </div> |
| |
| {/* Input Area Fixed Bottom */} |
| {!isDone && ( |
| <div className="absolute bottom-0 left-0 w-full bg-surface-container-lowest border-t border-outline-variant p-gutter"> |
| {/* Text Input */} |
| <div className="relative flex items-center"> |
| <input |
| ref={inputRef} |
| className="w-full h-12 pl-4 pr-12 rounded border border-outline-variant focus:border-primary focus:ring-0 focus:outline-none bg-surface-container-lowest text-on-surface placeholder:text-[#888780] font-body-md text-body-md transition-colors" |
| placeholder="Type your answer..." |
| type="text" |
| value={inputValue} |
| onChange={(e) => setInputValue(e.target.value)} |
| onKeyDown={handleKeyDown} |
| disabled={isTyping || isSubmitting} |
| /> |
| <button |
| onClick={() => handleAnswer(inputValue)} |
| disabled={!inputValue.trim() || isTyping || isSubmitting} |
| className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center text-primary hover:text-on-primary-fixed-variant transition-all duration-200 disabled:opacity-30 disabled:cursor-not-allowed hover:scale-110 active:scale-90" |
| > |
| <span className="material-symbols-outlined" data-icon="send">send</span> |
| </button> |
| </div> |
| </div> |
| )} |
| </main> |
| </div> |
| |
| {/* Inline animation keyframes */} |
| <style jsx global>{` |
| @keyframes fadeSlideIn { |
| from { |
| opacity: 0; |
| transform: translateY(12px); |
| } |
| to { |
| opacity: 1; |
| transform: translateY(0); |
| } |
| } |
| `}</style> |
| </> |
| ); |
| } |
|
|