anggars commited on
Commit
788e35b
·
verified ·
1 Parent(s): c511474

Sync from GitHub Actions: 540ff7f8fcf6bee734d76d29f1661f40e9ca9aa5

Browse files
Files changed (2) hide show
  1. src/app/chat/page.tsx +512 -438
  2. src/app/quiz/page.tsx +13 -5
src/app/chat/page.tsx CHANGED
@@ -1,478 +1,552 @@
1
  "use client";
2
 
3
  import { useState, useRef, useEffect } from "react";
4
- import { Send, Bot, User, Loader2, Sparkles, AlertCircle, Mic, MicOff } from "lucide-react";
5
- import ReactMarkdown from 'react-markdown';
6
- import remarkGfm from 'remark-gfm';
 
 
 
 
 
 
 
 
 
7
  import { cn } from "@/lib/utils";
8
  import { useLanguage } from "@/app/providers";
9
  import { motion, AnimatePresence } from "framer-motion";
10
 
11
  // Fallback utility
12
  function classNames(...classes: (string | undefined | null | false)[]) {
13
- return classes.filter(Boolean).join(" ");
14
  }
15
 
16
  interface Message {
17
- id: string;
18
- role: "user" | "bot";
19
- content: string;
20
  }
21
 
22
  // --- REUSABLE MARKDOWN COMPONENTS ---
23
  const markdownComponents = {
24
- // Style untuk bold
25
- strong: ({ children }: any) => <strong className="font-bold text-orange-500">{children}</strong>,
26
- // Style untuk table
27
- table: ({ children }: any) => <div className="overflow-x-auto my-4"><table className="border-collapse w-full text-sm">{children}</table></div>,
28
- thead: ({ children }: any) => <thead className="bg-orange-500/10 dark:bg-orange-500/20">{children}</thead>,
29
- th: ({ children }: any) => <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-bold">{children}</th>,
30
- td: ({ children }: any) => <td className="border border-gray-300 dark:border-gray-600 px-3 py-2">{children}</td>,
31
- // Style untuk list
32
- ul: ({ children }: any) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>,
33
- ol: ({ children }: any) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>,
34
- // Code block styling
35
- code: ({ node, inline, className, children, ...props }: any) => {
36
- const match = /language-(\w+)/.exec(className || "");
37
- return !inline ? (
38
- <div className="rounded-md overflow-hidden my-2 border border-gray-200 dark:border-gray-700">
39
- <div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs text-gray-500 font-mono border-b border-gray-200 dark:border-gray-700">
40
- {match ? match[1] : 'code'}
41
- </div>
42
- <div className="bg-gray-50 dark:bg-[#1e1e1e] p-3 overflow-x-auto">
43
- <code className={className} {...props}>
44
- {children}
45
- </code>
46
- </div>
47
- </div>
48
- ) : (
49
- <code className="bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm font-mono text-orange-600 dark:text-orange-400" {...props}>
50
- {children}
51
- </code>
52
- );
53
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  };
55
 
56
  // --- TYPEWRITER COMPONENT dengan Markdown Real-time ---
57
- const Typewriter = ({ text, speed = 10, onTyping, onComplete }: { text: string; speed?: number; onTyping?: () => void; onComplete?: () => void }) => {
58
- const [displayedText, setDisplayedText] = useState("");
59
- const [isTyping, setIsTyping] = useState(true);
60
-
61
- useEffect(() => {
62
- setDisplayedText("");
63
- setIsTyping(true);
64
- let i = 0;
65
- const interval = setInterval(() => {
66
- if (i < text.length) {
67
- setDisplayedText((prev) => prev + text.charAt(i));
68
- i++;
69
- // Scroll ke bawah setiap karakter baru
70
- onTyping?.();
71
- } else {
72
- clearInterval(interval);
73
- setIsTyping(false);
74
- onComplete?.();
75
- }
76
- }, speed);
77
-
78
- return () => clearInterval(interval);
79
- }, [text, speed]);
80
-
81
- return (
82
- <div className="markdown-content">
83
- <ReactMarkdown
84
- remarkPlugins={[remarkGfm]}
85
- components={markdownComponents}
86
- >
87
- {displayedText}
88
- </ReactMarkdown>
89
- {isTyping && (
90
- <span className="inline-block w-1.5 h-4 ml-1 align-middle bg-orange-500 animate-pulse" />
91
- )}
92
- </div>
93
- );
 
 
 
 
 
 
 
 
 
 
94
  };
95
 
96
  export default function ChatPage() {
97
- const { lang } = useLanguage();
98
- const [messages, setMessages] = useState<Message[]>([]);
99
- const [inputValue, setInputValue] = useState("");
100
- const [isLoading, setIsLoading] = useState(false);
101
- const [error, setError] = useState<string | null>(null);
102
- const [isListening, setIsListening] = useState(false);
103
- const [typedMessages, setTypedMessages] = useState<Set<string>>(new Set());
104
-
105
- const messagesEndRef = useRef<HTMLDivElement>(null);
106
- const messagesContainerRef = useRef<HTMLDivElement>(null);
107
- const recognitionRef = useRef<any>(null);
108
-
109
- // Only auto-scroll if user is already near the bottom
110
- const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
111
- const container = messagesContainerRef.current;
112
- if (!container) return;
113
-
114
- // Threshold lebih kecil (50px) biar user gampang scroll ke atas tanpa ditarik balik
115
- const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 50;
116
- if (isNearBottom) {
117
- messagesEndRef.current?.scrollIntoView({ behavior, block: "end" });
118
- }
119
- };
120
-
121
- useEffect(() => {
122
- // Force scroll on new messages
123
- scrollToBottom("smooth");
124
- }, [messages, isLoading]);
125
-
126
- // Initialize Speech Recognition
127
- const accumulatedTranscriptRef = useRef<string>('');
128
-
129
- useEffect(() => {
130
- if (typeof window !== 'undefined') {
131
- const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
132
- if (SpeechRecognition) {
133
- const recognition = new SpeechRecognition();
134
- recognition.continuous = true;
135
- recognition.interimResults = true;
136
- recognition.lang = lang === 'id' ? 'id-ID' : 'en-US';
137
-
138
- recognition.onresult = (event: any) => {
139
- let finalTranscript = '';
140
- let interimTranscript = '';
141
-
142
- for (let i = 0; i < event.results.length; ++i) {
143
- if (event.results[i].isFinal) {
144
- finalTranscript += event.results[i][0].transcript + ' ';
145
- } else {
146
- interimTranscript += event.results[i][0].transcript;
147
- }
148
- }
149
-
150
- // Gabungkan final + interim untuk display
151
- setInputValue(finalTranscript + interimTranscript);
152
- accumulatedTranscriptRef.current = finalTranscript;
153
- };
154
-
155
- recognition.onerror = (event: any) => {
156
- console.error("Voice Error:", event.error);
157
- if (event.error !== 'no-speech') {
158
- setIsListening(false);
159
- }
160
- };
161
-
162
- recognitionRef.current = recognition;
163
  }
164
- }
165
- }, [lang]);
166
-
167
- const toggleListening = () => {
168
- if (!recognitionRef.current) {
169
- alert("Browser kamu gak support voice input bro. Coba Chrome.");
170
- return;
171
- }
172
-
173
- if (isListening) {
174
- recognitionRef.current.stop();
175
- setIsListening(false);
176
- } else {
177
- // Reset input pas mulai ngomong (opsional, tergantung preferensi)
178
- // setInputValue("");
179
- recognitionRef.current.start();
180
- setIsListening(true);
181
- }
182
- };
183
 
184
- const handleSendMessage = async (text: string) => {
185
- const messageText = text || inputValue;
186
- if (!messageText.trim()) return;
 
187
 
188
- // Stop listening if sending
189
- if (isListening && recognitionRef.current) {
190
- recognitionRef.current.stop();
191
  setIsListening(false);
192
- }
193
-
194
- setError(null);
195
- const userMessage: Message = {
196
- id: Date.now().toString(),
197
- role: "user",
198
- content: messageText,
199
  };
200
 
201
- setMessages((prev) => [...prev, userMessage]);
202
- setInputValue("");
203
- setIsLoading(true);
 
204
 
205
- try {
206
- const apiUrl = process.env.NEXT_PUBLIC_CHATBOT_URL || "http://localhost:8000/api/chat";
 
 
 
207
 
208
- const response = await fetch(apiUrl, {
209
- method: "POST",
210
- headers: { "Content-Type": "application/json" },
211
- body: JSON.stringify({
212
- message: messageText,
213
- lang: lang
214
- }),
215
- });
 
 
216
 
217
- if (!response.ok) {
218
- throw new Error(`Server returned ${response.status}`);
219
- }
220
 
221
- const data = await response.json();
222
-
223
- const botMessage: Message = {
224
- id: (Date.now() + 1).toString(),
225
- role: "bot",
226
- content: data.response || "Maaf, saya tidak mengerti.",
227
- };
228
-
229
- setMessages((prev) => [...prev, botMessage]);
230
- } catch (err: any) {
231
- console.error("Chat Error:", err);
232
- setError("Gagal terhubung ke backend. Pastikan server API (port 8000) sudah jalan.");
233
- } finally {
234
- setIsLoading(false);
235
- }
236
- };
237
 
238
- const t = {
239
- en: {
240
- title: "Sentimind Chat",
241
- desc: "Consult about MBTI, psychology, and mental health.",
242
- placeholder: "Type or use voice...",
243
- thinking: "Thinking...",
244
- powerBy: "Powered by Gemini. AI may make mistakes.",
245
- suggestions: [
246
- "What is INTJ personality?",
247
- "How to overcome social anxiety?",
248
- "Explain Fe vs Fi cognitive functions",
249
- "Why do INFJs feel lonely?"
250
- ],
251
- emptyState: "Start a conversation..."
252
- },
253
- id: {
254
- title: "Sentimind Chat",
255
- desc: "Ngobrol santai soal MBTI, psikologi, dan kesehatan mental.",
256
- placeholder: "Ketik atau ngomong langsung...",
257
- thinking: "Bentar bre, mikir dulu...",
258
- powerBy: "Ditenagai Gemini. AI bisa aja salah, namanya juga bot.",
259
- suggestions: [
260
- "Apa itu tipe kepribadian INTJ?",
261
- "Gimana cara ngilangin cemas?",
262
- "Bedanya Fe sama Fi apa sih?",
263
- "Kenapa INFJ sering merasa kesepian?"
264
- ],
265
- emptyState: "Tanya apa gitu..."
266
- }
267
  };
268
 
269
- const content = t[lang] || t.en;
270
-
271
- return (
272
- <div className="w-full flex flex-col pt-28 md:pt-32 font-sans min-h-screen justify-start">
273
-
274
- {/* Main Chat Content */}
275
- <div className="flex-1 w-full max-w-3xl mx-auto px-4 md:px-0 flex flex-col">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
- {/* Header (Only show if no messages) */}
278
- <AnimatePresence>
279
- {messages.length === 0 && (
280
- <motion.div
281
- initial={{ opacity: 0, y: 20 }}
282
- animate={{ opacity: 1, y: 0 }}
283
- exit={{ opacity: 0, y: -20 }}
284
- transition={{ duration: 0.5 }}
285
- className="flex flex-col items-center text-center space-y-6 py-10"
 
 
 
 
 
 
286
  >
287
- <motion.div
288
- initial={{ scale: 0 }}
289
- animate={{ scale: 1 }}
290
- transition={{ type: "spring", stiffness: 260, damping: 20, delay: 0.1 }}
291
- className="p-2 bg-orange-100 dark:bg-orange-500/10 rounded-full mb-2"
292
- >
293
- <Sparkles className="text-orange-600 dark:text-orange-400 w-6 h-6" />
294
- </motion.div>
295
- <div>
296
- <h1 className="text-5xl md:text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 leading-[1.1] pb-2">
297
- {content.title}
298
- </h1>
299
- <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed">
300
- {content.desc}
301
- </p>
302
- </div>
303
-
304
- <motion.div
305
- className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-2xl mt-12"
306
- initial="hidden"
307
- animate="visible"
308
- variants={{
309
- hidden: { opacity: 0 },
310
- visible: {
311
- opacity: 1,
312
- transition: {
313
- staggerChildren: 0.1
314
- }
315
- }
316
- }}
317
- >
318
- {content.suggestions.map((s, i) => (
319
- <motion.button
320
- key={i}
321
- variants={{
322
- hidden: { opacity: 0, y: 20 },
323
- visible: { opacity: 1, y: 0 }
324
- }}
325
- whileHover={{ scale: 1.02 }}
326
- whileTap={{ scale: 0.98 }}
327
- onClick={() => handleSendMessage(s)}
328
- className="p-4 text-left text-sm bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:bg-orange-50 dark:hover:bg-neutral-800 hover:border-orange-300 dark:hover:border-orange-700/50 rounded-2xl transition-colors text-gray-600 dark:text-gray-300 shadow-sm"
329
- >
330
- "{s}"
331
- </motion.button>
332
- ))}
333
- </motion.div>
334
- </motion.div>
335
- )}
336
- </AnimatePresence>
337
-
338
- {/* Chat Messages */}
339
- <div ref={messagesContainerRef} className="space-y-6 flex-1 mb-8">
340
- <AnimatePresence mode="popLayout">
341
- {messages.map((msg) => (
342
- <motion.div
343
- key={msg.id}
344
- layout
345
- initial={{ opacity: 0, scale: 0.9, y: 20 }}
346
- animate={{ opacity: 1, scale: 1, y: 0 }}
347
- exit={{ opacity: 0, scale: 0.9 }}
348
- transition={{ duration: 0.3 }}
349
- className={classNames(
350
- "flex gap-4 md:gap-6",
351
- msg.role === "user" ? "flex-row-reverse" : "flex-row"
352
- )}
353
- >
354
- {/* Avatar */}
355
- <div className={classNames(
356
- "w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm mt-1",
357
- msg.role === "user"
358
- ? "bg-gray-200 dark:bg-neutral-700 text-gray-600 dark:text-gray-200"
359
- : "bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400"
360
- )}>
361
- {msg.role === "user" ? <User size={18} /> : <Bot size={20} />}
362
- </div>
363
-
364
- {/* Content */}
365
- <div className={classNames(
366
- "max-w-[85%] md:max-w-[80%] text-[15px] md:text-base leading-7",
367
- msg.role === "user"
368
- ? "bg-orange-600 text-white px-5 py-3 rounded-2xl rounded-tr-sm shadow-md"
369
- : "text-gray-800 dark:text-gray-200 px-2 py-1 prose dark:prose-invert max-w-none"
370
- )}>
371
- {msg.role === "bot" ? (
372
- typedMessages.has(msg.id) ? (
373
- <div className="markdown-content">
374
- <ReactMarkdown
375
- remarkPlugins={[remarkGfm]}
376
- components={markdownComponents}
377
- >
378
- {msg.content}
379
- </ReactMarkdown>
380
- </div>
381
- ) : (
382
- <Typewriter
383
- text={msg.content}
384
- speed={15}
385
- onTyping={() => scrollToBottom("auto")}
386
- onComplete={() => setTypedMessages(prev => new Set([...prev, msg.id]))}
387
- />
388
- )
389
- ) : (
390
- msg.content
391
- )}
392
- </div>
393
- </motion.div>
394
- ))}
395
- </AnimatePresence>
396
-
397
- {/* Loading State */}
398
- {isLoading && (
399
- <motion.div
400
- initial={{ opacity: 0, y: 10 }}
401
- animate={{ opacity: 1, y: 0 }}
402
- className="flex gap-4 md:gap-6"
403
- >
404
- <div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400 flex items-center justify-center shrink-0 mt-1">
405
- <Bot size={20} />
406
- </div>
407
- <div className="flex flex-col gap-2 mt-2">
408
- <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
409
- <Loader2 size={16} className="animate-spin" />
410
- {content.thinking}
411
- </div>
412
- </div>
413
- </motion.div>
414
- )}
415
-
416
- {error && (
417
- <motion.div
418
- initial={{ opacity: 0 }}
419
- animate={{ opacity: 1 }}
420
- className="p-4 bg-red-50 text-red-600 border border-red-200 rounded-xl text-center"
421
- >
422
- <p>{error}</p>
423
- </motion.div>
424
- )}
425
- <div ref={messagesEndRef} />
426
  </div>
427
- </div>
428
-
429
- {/* STICKY Input Area */}
430
- <div className="sticky bottom-0 left-0 w-full bg-background pb-6 pt-4 px-4 md:px-0 z-30">
431
- <div className="max-w-3xl mx-auto relative">
432
- {/* Shadow gradient top for nice effect */}
433
- <div className="absolute -top-10 left-0 w-full h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
434
-
435
- <motion.div
436
- initial={{ y: 50, opacity: 0 }}
437
- animate={{ y: 0, opacity: 1 }}
438
- transition={{ delay: 0.5, type: "spring" }}
439
- className="relative flex items-center bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 shadow-sm rounded-3xl p-2 transition-all focus-within:border-orange-500"
440
- >
441
- {/* Voice Button */}
442
- <button
443
- onClick={toggleListening}
444
- className={classNames(
445
- "p-3 rounded-full transition-all flex items-center justify-center mr-1",
446
- isListening
447
- ? "bg-red-500 text-white animate-pulse"
448
- : "bg-transparent text-gray-400 hover:bg-gray-200 dark:hover:bg-neutral-800 hover:text-gray-600"
449
- )}
450
- >
451
- {isListening ? <MicOff size={20} /> : <Mic size={20} />}
452
- </button>
453
-
454
- <input
455
- type="text"
456
- value={inputValue}
457
- onChange={(e) => setInputValue(e.target.value)}
458
- onKeyDown={(e) => e.key === "Enter" && handleSendMessage(inputValue)}
459
- placeholder={content.placeholder}
460
- disabled={isLoading}
461
- className="w-full bg-transparent border-none outline-none focus:ring-0 focus:outline-none rounded-full px-2 py-3 text-base text-gray-800 dark:text-gray-200 placeholder:text-gray-400"
462
- />
463
- <button
464
- onClick={() => handleSendMessage(inputValue)}
465
- disabled={!inputValue.trim() || isLoading}
466
- className="p-3 bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:hover:bg-orange-600 text-white rounded-full transition-all shadow-sm transform hover:scale-105 active:scale-95 ml-2"
467
- >
468
- <Send size={18} />
469
- </button>
470
- </motion.div>
471
- <p className="text-center text-[10px] md:text-xs text-gray-400 mt-3 -mb-3 opacity-70">
472
- {content.powerBy}
473
- </p>
474
  </div>
475
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  </div>
477
- );
478
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  "use client";
2
 
3
  import { useState, useRef, useEffect } from "react";
4
+ import {
5
+ Send,
6
+ Bot,
7
+ User,
8
+ Loader2,
9
+ Sparkles,
10
+ AlertCircle,
11
+ Mic,
12
+ MicOff,
13
+ } from "lucide-react";
14
+ import ReactMarkdown from "react-markdown";
15
+ import remarkGfm from "remark-gfm";
16
  import { cn } from "@/lib/utils";
17
  import { useLanguage } from "@/app/providers";
18
  import { motion, AnimatePresence } from "framer-motion";
19
 
20
  // Fallback utility
21
  function classNames(...classes: (string | undefined | null | false)[]) {
22
+ return classes.filter(Boolean).join(" ");
23
  }
24
 
25
  interface Message {
26
+ id: string;
27
+ role: "user" | "bot";
28
+ content: string;
29
  }
30
 
31
  // --- REUSABLE MARKDOWN COMPONENTS ---
32
  const markdownComponents = {
33
+ // Style untuk bold
34
+ strong: ({ children }: any) => (
35
+ <strong className="font-bold text-orange-500">{children}</strong>
36
+ ),
37
+ // Style untuk table
38
+ table: ({ children }: any) => (
39
+ <div className="overflow-x-auto my-4">
40
+ <table className="border-collapse w-full text-sm">{children}</table>
41
+ </div>
42
+ ),
43
+ thead: ({ children }: any) => (
44
+ <thead className="bg-orange-500/10 dark:bg-orange-500/20">{children}</thead>
45
+ ),
46
+ th: ({ children }: any) => (
47
+ <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-bold">
48
+ {children}
49
+ </th>
50
+ ),
51
+ td: ({ children }: any) => (
52
+ <td className="border border-gray-300 dark:border-gray-600 px-3 py-2">
53
+ {children}
54
+ </td>
55
+ ),
56
+ // Style untuk list
57
+ ul: ({ children }: any) => (
58
+ <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>
59
+ ),
60
+ ol: ({ children }: any) => (
61
+ <ol className="list-decimal list-outside pl-5 my-2 space-y-1">
62
+ {children}
63
+ </ol>
64
+ ),
65
+ // Code block styling
66
+ code: ({ node, inline, className, children, ...props }: any) => {
67
+ const match = /language-(\w+)/.exec(className || "");
68
+ return !inline ? (
69
+ <div className="rounded-md overflow-hidden my-2 border border-gray-200 dark:border-gray-700">
70
+ <div className="bg-gray-100 dark:bg-gray-800 px-3 py-1 text-xs text-gray-500 font-mono border-b border-gray-200 dark:border-gray-700">
71
+ {match ? match[1] : "code"}
72
+ </div>
73
+ <div className="bg-gray-50 dark:bg-[#1e1e1e] p-3 overflow-x-auto">
74
+ <code className={className} {...props}>
75
+ {children}
76
+ </code>
77
+ </div>
78
+ </div>
79
+ ) : (
80
+ <code
81
+ className="bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm font-mono text-orange-600 dark:text-orange-400"
82
+ {...props}
83
+ >
84
+ {children}
85
+ </code>
86
+ );
87
+ },
88
  };
89
 
90
  // --- TYPEWRITER COMPONENT dengan Markdown Real-time ---
91
+ const Typewriter = ({
92
+ text,
93
+ speed = 10,
94
+ onTyping,
95
+ onComplete,
96
+ }: {
97
+ text: string;
98
+ speed?: number;
99
+ onTyping?: () => void;
100
+ onComplete?: () => void;
101
+ }) => {
102
+ const [displayedText, setDisplayedText] = useState("");
103
+ const [isTyping, setIsTyping] = useState(true);
104
+
105
+ useEffect(() => {
106
+ setDisplayedText("");
107
+ setIsTyping(true);
108
+ let i = 0;
109
+ const interval = setInterval(() => {
110
+ if (i < text.length) {
111
+ setDisplayedText((prev) => prev + text.charAt(i));
112
+ i++;
113
+ // Scroll ke bawah setiap karakter baru
114
+ onTyping?.();
115
+ } else {
116
+ clearInterval(interval);
117
+ setIsTyping(false);
118
+ onComplete?.();
119
+ }
120
+ }, speed);
121
+
122
+ return () => clearInterval(interval);
123
+ }, [text, speed]);
124
+
125
+ return (
126
+ <div className="markdown-content">
127
+ <ReactMarkdown
128
+ remarkPlugins={[remarkGfm]}
129
+ components={markdownComponents}
130
+ >
131
+ {displayedText}
132
+ </ReactMarkdown>
133
+ {isTyping && (
134
+ <span className="inline-block w-1.5 h-4 ml-1 align-middle bg-orange-500 animate-pulse" />
135
+ )}
136
+ </div>
137
+ );
138
  };
139
 
140
  export default function ChatPage() {
141
+ const { lang } = useLanguage();
142
+ const [messages, setMessages] = useState<Message[]>([]);
143
+ const [inputValue, setInputValue] = useState("");
144
+ const [isLoading, setIsLoading] = useState(false);
145
+ const [error, setError] = useState<string | null>(null);
146
+ const [isListening, setIsListening] = useState(false);
147
+ const [typedMessages, setTypedMessages] = useState<Set<string>>(new Set());
148
+
149
+ const messagesEndRef = useRef<HTMLDivElement>(null);
150
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
151
+ const recognitionRef = useRef<any>(null);
152
+
153
+ // Only auto-scroll if user is already near the bottom
154
+ const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
155
+ const container = messagesContainerRef.current;
156
+ if (!container) return;
157
+
158
+ // Threshold lebih kecil (50px) biar user gampang scroll ke atas tanpa ditarik balik
159
+ const isNearBottom =
160
+ container.scrollHeight - container.scrollTop - container.clientHeight <
161
+ 50;
162
+ if (isNearBottom) {
163
+ messagesEndRef.current?.scrollIntoView({ behavior, block: "end" });
164
+ }
165
+ };
166
+
167
+ useEffect(() => {
168
+ // Force scroll on new messages
169
+ scrollToBottom("smooth");
170
+ }, [messages, isLoading]);
171
+
172
+ // Initialize Speech Recognition
173
+ const accumulatedTranscriptRef = useRef<string>("");
174
+
175
+ useEffect(() => {
176
+ if (typeof window !== "undefined") {
177
+ const SpeechRecognition =
178
+ (window as any).SpeechRecognition ||
179
+ (window as any).webkitSpeechRecognition;
180
+ if (SpeechRecognition) {
181
+ const recognition = new SpeechRecognition();
182
+ recognition.continuous = true;
183
+ recognition.interimResults = true;
184
+ recognition.lang = lang === "id" ? "id-ID" : "en-US";
185
+
186
+ recognition.onresult = (event: any) => {
187
+ let finalTranscript = "";
188
+ let interimTranscript = "";
189
+
190
+ for (let i = 0; i < event.results.length; ++i) {
191
+ if (event.results[i].isFinal) {
192
+ finalTranscript += event.results[i][0].transcript + " ";
193
+ } else {
194
+ interimTranscript += event.results[i][0].transcript;
 
 
 
 
 
 
 
 
 
 
 
 
195
  }
196
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
+ // Gabungkan final + interim untuk display
199
+ setInputValue(finalTranscript + interimTranscript);
200
+ accumulatedTranscriptRef.current = finalTranscript;
201
+ };
202
 
203
+ recognition.onerror = (event: any) => {
204
+ console.error("Voice Error:", event.error);
205
+ if (event.error !== "no-speech") {
206
  setIsListening(false);
207
+ }
 
 
 
 
 
 
208
  };
209
 
210
+ recognitionRef.current = recognition;
211
+ }
212
+ }
213
+ }, [lang]);
214
 
215
+ const toggleListening = () => {
216
+ if (!recognitionRef.current) {
217
+ alert("Browser kamu gak support voice input bro. Coba Chrome.");
218
+ return;
219
+ }
220
 
221
+ if (isListening) {
222
+ recognitionRef.current.stop();
223
+ setIsListening(false);
224
+ } else {
225
+ // Reset input pas mulai ngomong (opsional, tergantung preferensi)
226
+ // setInputValue("");
227
+ recognitionRef.current.start();
228
+ setIsListening(true);
229
+ }
230
+ };
231
 
232
+ const handleSendMessage = async (text: string) => {
233
+ const messageText = text || inputValue;
234
+ if (!messageText.trim()) return;
235
 
236
+ // Stop listening if sending
237
+ if (isListening && recognitionRef.current) {
238
+ recognitionRef.current.stop();
239
+ setIsListening(false);
240
+ }
 
 
 
 
 
 
 
 
 
 
 
241
 
242
+ setError(null);
243
+ const userMessage: Message = {
244
+ id: Date.now().toString(),
245
+ role: "user",
246
+ content: messageText,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  };
248
 
249
+ setMessages((prev) => [...prev, userMessage]);
250
+ setInputValue("");
251
+ setIsLoading(true);
252
+
253
+ try {
254
+ const apiUrl =
255
+ process.env.NEXT_PUBLIC_CHATBOT_URL || "http://localhost:8000/api/chat";
256
+
257
+ const response = await fetch(apiUrl, {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({
261
+ message: messageText,
262
+ lang: lang,
263
+ }),
264
+ });
265
+
266
+ if (!response.ok) {
267
+ throw new Error(`Server returned ${response.status}`);
268
+ }
269
+
270
+ const data = await response.json();
271
+
272
+ const botMessage: Message = {
273
+ id: (Date.now() + 1).toString(),
274
+ role: "bot",
275
+ content: data.response || "Maaf, saya tidak mengerti.",
276
+ };
277
+
278
+ setMessages((prev) => [...prev, botMessage]);
279
+ } catch (err: any) {
280
+ console.error("Chat Error:", err);
281
+ setError(
282
+ "Gagal terhubung ke backend. Pastikan server API (port 8000) sudah jalan."
283
+ );
284
+ } finally {
285
+ setIsLoading(false);
286
+ }
287
+ };
288
+
289
+ const t = {
290
+ en: {
291
+ title: "Sentimind Chat",
292
+ desc: "Consult about MBTI, psychology, and mental health.",
293
+ placeholder: "Type or use voice...",
294
+ thinking: "Thinking...",
295
+ powerBy: "Powered by Gemini. AI may make mistakes.",
296
+ suggestions: [
297
+ "What is INTJ personality?",
298
+ "How to overcome social anxiety?",
299
+ "Explain Fe vs Fi cognitive functions",
300
+ "Why do INFJs feel lonely?",
301
+ ],
302
+ emptyState: "Start a conversation...",
303
+ },
304
+ id: {
305
+ title: "Sentimind Chat",
306
+ desc: "Ngobrol santai soal MBTI, psikologi, dan kesehatan mental.",
307
+ placeholder: "Ketik atau ngomong langsung...",
308
+ thinking: "Bentar bre, mikir dulu...",
309
+ powerBy: "Ditenagai Gemini. AI bisa aja salah, namanya juga bot.",
310
+ suggestions: [
311
+ "Apa itu tipe kepribadian INTJ?",
312
+ "Gimana cara ngilangin cemas?",
313
+ "Bedanya Fe sama Fi apa sih?",
314
+ "Kenapa INFJ sering merasa kesepian?",
315
+ ],
316
+ emptyState: "Tanya apa gitu...",
317
+ },
318
+ };
319
+
320
+ const content = t[lang] || t.en;
321
+
322
+ return (
323
+ <div className="w-full flex flex-col pt-28 md:pt-32 font-sans min-h-screen justify-start">
324
+ {/* Main Chat Content */}
325
+ <div className="flex-1 w-full max-w-3xl mx-auto px-4 md:px-0 flex flex-col">
326
+ {/* Header (Only show if no messages) */}
327
+ <AnimatePresence>
328
+ {messages.length === 0 && (
329
+ <motion.div
330
+ initial={{ opacity: 0, y: 20 }}
331
+ animate={{ opacity: 1, y: 0 }}
332
+ exit={{ opacity: 0, y: -20 }}
333
+ transition={{ duration: 0.5 }}
334
+ className="flex flex-col items-center text-center space-y-6 py-10"
335
+ >
336
+ <motion.div
337
+ initial={{ scale: 0 }}
338
+ animate={{ scale: 1 }}
339
+ transition={{
340
+ type: "spring",
341
+ stiffness: 260,
342
+ damping: 20,
343
+ delay: 0.1,
344
+ }}
345
+ className="p-2 bg-orange-100 dark:bg-orange-500/10 rounded-full mb-2"
346
+ >
347
+ <Sparkles className="text-orange-600 dark:text-orange-400 w-6 h-6" />
348
+ </motion.div>
349
+ <div>
350
+ <h1 className="text-5xl md:text-7xl font-black tracking-tighter text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500 leading-[1.1] pb-2">
351
+ {content.title}
352
+ </h1>
353
+ <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl leading-relaxed">
354
+ {content.desc}
355
+ </p>
356
+ </div>
357
+
358
+ <motion.div
359
+ className="grid grid-cols-1 md:grid-cols-2 gap-3 w-full max-w-2xl mt-12"
360
+ initial="hidden"
361
+ animate="visible"
362
+ variants={{
363
+ hidden: { opacity: 0 },
364
+ visible: {
365
+ opacity: 1,
366
+ transition: {
367
+ staggerChildren: 0.1,
368
+ },
369
+ },
370
+ }}
371
+ >
372
+ {content.suggestions.map((s, i) => (
373
+ <motion.button
374
+ key={i}
375
+ variants={{
376
+ hidden: { opacity: 0, y: 20 },
377
+ visible: { opacity: 1, y: 0 },
378
+ }}
379
+ whileHover={{ scale: 1.02 }}
380
+ whileTap={{ scale: 0.98 }}
381
+ onClick={() => handleSendMessage(s)}
382
+ className="p-4 text-left text-sm bg-white dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 hover:bg-orange-50 dark:hover:bg-neutral-800 hover:border-orange-300 dark:hover:border-orange-700/50 rounded-2xl transition-colors text-gray-600 dark:text-gray-300 shadow-sm"
383
+ >
384
+ "{s}"
385
+ </motion.button>
386
+ ))}
387
+ </motion.div>
388
+ </motion.div>
389
+ )}
390
+ </AnimatePresence>
391
+
392
+ {/* Chat Messages */}
393
+ <div ref={messagesContainerRef} className="space-y-6 flex-1 mb-8">
394
+ <AnimatePresence mode="popLayout">
395
+ {messages.map((msg) => (
396
+ <motion.div
397
+ key={msg.id}
398
+ layout
399
+ initial={{ opacity: 0, scale: 0.9, y: 20 }}
400
+ animate={{ opacity: 1, scale: 1, y: 0 }}
401
+ exit={{ opacity: 0, scale: 0.9 }}
402
+ transition={{ duration: 0.3 }}
403
+ className={classNames(
404
+ "flex gap-4 md:gap-6",
405
+ msg.role === "user" ? "flex-row-reverse" : "flex-row"
406
+ )}
407
+ >
408
+ {/* Avatar */}
409
+ <div
410
+ className={classNames(
411
+ "w-8 h-8 md:w-10 md:h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm mt-1",
412
+ msg.role === "user"
413
+ ? "bg-gray-200 dark:bg-neutral-700 text-gray-600 dark:text-gray-200"
414
+ : "bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400"
415
+ )}
416
+ >
417
+ {msg.role === "user" ? <User size={18} /> : <Bot size={20} />}
418
+ </div>
419
 
420
+ {/* Content */}
421
+ <div
422
+ className={classNames(
423
+ "max-w-[85%] md:max-w-[80%] text-[15px] md:text-base leading-7",
424
+ msg.role === "user"
425
+ ? "bg-orange-600 text-white px-5 py-3 rounded-2xl rounded-tr-sm shadow-md"
426
+ : "text-gray-800 dark:text-gray-200 px-2 py-1 prose dark:prose-invert max-w-none"
427
+ )}
428
+ >
429
+ {msg.role === "bot" ? (
430
+ typedMessages.has(msg.id) ? (
431
+ <div className="markdown-content">
432
+ <ReactMarkdown
433
+ remarkPlugins={[remarkGfm]}
434
+ components={markdownComponents}
435
  >
436
+ {msg.content}
437
+ </ReactMarkdown>
438
+ </div>
439
+ ) : (
440
+ <Typewriter
441
+ text={msg.content}
442
+ speed={15}
443
+ onTyping={() => scrollToBottom("auto")}
444
+ onComplete={() =>
445
+ setTypedMessages((prev) => new Set([...prev, msg.id]))
446
+ }
447
+ />
448
+ )
449
+ ) : (
450
+ msg.content
451
+ )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  </div>
453
+ </motion.div>
454
+ ))}
455
+ </AnimatePresence>
456
+
457
+ {/* Loading State */}
458
+ {isLoading && (
459
+ <motion.div
460
+ initial={{ opacity: 0, y: 10 }}
461
+ animate={{ opacity: 1, y: 0 }}
462
+ className="flex gap-4 md:gap-6"
463
+ >
464
+ <div className="w-8 h-8 md:w-10 md:h-10 rounded-full bg-orange-100 dark:bg-orange-500/20 text-orange-600 dark:text-orange-400 flex items-center justify-center shrink-0 mt-1">
465
+ <Bot size={20} />
466
+ </div>
467
+ <div className="flex flex-col gap-2 mt-2">
468
+ <div className="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
469
+ <Loader2 size={16} className="animate-spin" />
470
+ {content.thinking}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  </div>
472
+ </div>
473
+ </motion.div>
474
+ )}
475
+
476
+ {error && (
477
+ <motion.div
478
+ initial={{ opacity: 0 }}
479
+ animate={{ opacity: 1 }}
480
+ className="p-4 bg-red-50 text-red-600 border border-red-200 rounded-xl text-center"
481
+ >
482
+ <p>{error}</p>
483
+ </motion.div>
484
+ )}
485
+ <div ref={messagesEndRef} />
486
  </div>
487
+ </div>
488
+
489
+ {/* STICKY Input Area */}
490
+ <div className="sticky bottom-0 left-0 w-full bg-background pb-6 pt-4 px-4 md:px-0 z-30">
491
+ <div className="max-w-3xl mx-auto relative">
492
+ {/* Shadow gradient top for nice effect */}
493
+ <div className="absolute -top-10 left-0 w-full h-10 bg-gradient-to-t from-background to-transparent pointer-events-none" />
494
+
495
+ <motion.div
496
+ initial={{ y: 50, opacity: 0 }}
497
+ animate={{ y: 0, opacity: 1 }}
498
+ transition={{ delay: 0.5, type: "spring" }}
499
+ className="relative flex items-center bg-gray-50 dark:bg-neutral-900 border border-gray-200 dark:border-neutral-800 shadow-sm rounded-3xl p-2 transition-all focus-within:border-orange-500"
500
+ >
501
+ {/* Voice Button */}
502
+ <button
503
+ onClick={toggleListening}
504
+ className={classNames(
505
+ "p-3 rounded-full transition-all flex items-center justify-center mr-1 self-end mb-1",
506
+ isListening
507
+ ? "bg-red-500 text-white animate-pulse"
508
+ : "bg-transparent text-gray-400 hover:bg-gray-200 dark:hover:bg-neutral-800 hover:text-gray-600"
509
+ )}
510
+ >
511
+ {isListening ? <MicOff size={20} /> : <Mic size={20} />}
512
+ </button>
513
+
514
+ <textarea
515
+ value={inputValue}
516
+ onChange={(e) => {
517
+ setInputValue(e.target.value);
518
+ e.target.style.height = "auto";
519
+ e.target.style.height = `${e.target.scrollHeight}px`;
520
+ }}
521
+ onKeyDown={(e) => {
522
+ if (e.key === "Enter" && !e.shiftKey) {
523
+ // Check if mobile (basic check using width)
524
+ // Or clearer: default is newline, but if desktop, prevent default and send
525
+ if (window.innerWidth >= 768) {
526
+ e.preventDefault();
527
+ handleSendMessage(inputValue);
528
+ }
529
+ }
530
+ }}
531
+ placeholder={content.placeholder}
532
+ disabled={isLoading}
533
+ rows={1}
534
+ style={{ maxHeight: "200px" }}
535
+ className="flex-1 bg-transparent border-none outline-none focus:ring-0 focus:outline-none rounded-2xl px-2 py-3 text-base text-gray-800 dark:text-gray-200 placeholder:text-gray-400 resize-none overflow-y-auto"
536
+ />
537
+ <button
538
+ onClick={() => handleSendMessage(inputValue)}
539
+ disabled={!inputValue.trim() || isLoading}
540
+ className="p-3 bg-orange-600 hover:bg-orange-700 disabled:opacity-50 disabled:hover:bg-orange-600 text-white rounded-full transition-all shadow-sm transform hover:scale-105 active:scale-95 ml-2 self-end mb-1"
541
+ >
542
+ <Send size={18} />
543
+ </button>
544
+ </motion.div>
545
+ <p className="text-center text-[10px] md:text-xs text-gray-400 mt-3 -mb-3 opacity-70">
546
+ {content.powerBy}
547
+ </p>
548
+ </div>
549
+ </div>
550
+ </div>
551
+ );
552
+ }
src/app/quiz/page.tsx CHANGED
@@ -87,6 +87,11 @@ export default function QuizPage() {
87
  });
88
  }, []);
89
 
 
 
 
 
 
90
  const totalPages = Math.ceil(questions.length / QUESTIONS_PER_PAGE);
91
  const currentQuestions = questions.slice(
92
  currentPage * QUESTIONS_PER_PAGE,
@@ -104,7 +109,6 @@ export default function QuizPage() {
104
  const handleNext = () => {
105
  if (currentPage < totalPages - 1) {
106
  setCurrentPage(currentPage + 1);
107
- window.scrollTo({ top: 0, behavior: "smooth" });
108
  } else {
109
  submitAnswers();
110
  }
@@ -113,7 +117,6 @@ export default function QuizPage() {
113
  const handlePrev = () => {
114
  if (currentPage > 0) {
115
  setCurrentPage(currentPage - 1);
116
- window.scrollTo({ top: 0, behavior: "smooth" });
117
  }
118
  };
119
 
@@ -200,6 +203,10 @@ export default function QuizPage() {
200
 
201
  if (questions.length === 0) return null;
202
 
 
 
 
 
203
  return (
204
  <div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 font-sans flex flex-col items-center">
205
  <div className="max-w-3xl w-full z-10">
@@ -223,13 +230,14 @@ export default function QuizPage() {
223
  <span>
224
  {content.progress} {currentPage + 1} / {totalPages}
225
  </span>
226
- <span>{Math.round((currentPage / totalPages) * 100)}%</span>
 
227
  </div>
228
  <div className="w-full h-2 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
229
  <motion.div
230
  initial={{ width: 0 }}
231
- animate={{ width: `${((currentPage + 1) / totalPages) * 100}%` }}
232
- className="h-full bg-orange-500 rounded-full"
233
  />
234
  </div>
235
  </div>
 
87
  });
88
  }, []);
89
 
90
+ // Scroll to top when page changes
91
+ useEffect(() => {
92
+ window.scrollTo({ top: 0, behavior: "smooth" });
93
+ }, [currentPage]);
94
+
95
  const totalPages = Math.ceil(questions.length / QUESTIONS_PER_PAGE);
96
  const currentQuestions = questions.slice(
97
  currentPage * QUESTIONS_PER_PAGE,
 
109
  const handleNext = () => {
110
  if (currentPage < totalPages - 1) {
111
  setCurrentPage(currentPage + 1);
 
112
  } else {
113
  submitAnswers();
114
  }
 
117
  const handlePrev = () => {
118
  if (currentPage > 0) {
119
  setCurrentPage(currentPage - 1);
 
120
  }
121
  };
122
 
 
203
 
204
  if (questions.length === 0) return null;
205
 
206
+ // Calculate actual progress based on answered questions
207
+ const answeredCount = Object.keys(answers).length;
208
+ const progressPercent = Math.round((answeredCount / questions.length) * 100);
209
+
210
  return (
211
  <div className="w-full pt-28 pb-12 px-4 sm:px-6 lg:px-8 font-sans flex flex-col items-center">
212
  <div className="max-w-3xl w-full z-10">
 
230
  <span>
231
  {content.progress} {currentPage + 1} / {totalPages}
232
  </span>
233
+ {/* Show overall progress */}
234
+ <span>{progressPercent}%</span>
235
  </div>
236
  <div className="w-full h-2 bg-gray-200 dark:bg-white/10 rounded-full overflow-hidden">
237
  <motion.div
238
  initial={{ width: 0 }}
239
+ animate={{ width: `${progressPercent}%` }}
240
+ className="h-full bg-orange-600 rounded-full"
241
  />
242
  </div>
243
  </div>