191 lines
7.3 KiB
TypeScript
191 lines
7.3 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { ArrowUpRight } from "lucide-react";
|
||
import MessageBubble from "./MessageBubble";
|
||
import ChatButton from "./ChatButton";
|
||
import {
|
||
matchAnswer,
|
||
outOfScopeAnswer,
|
||
suggestedQuestions,
|
||
} from "./qaMap";
|
||
|
||
type Message = {
|
||
id: string;
|
||
role: "user" | "bot";
|
||
text: string;
|
||
};
|
||
|
||
const welcomeMessage: Message = {
|
||
id: "welcome",
|
||
role: "bot",
|
||
text: "Hi 👋 I’m QuantFortune Assistant. How can I help you today?",
|
||
};
|
||
|
||
export default function ChatWidget() {
|
||
const [open, setOpen] = useState(false);
|
||
const [input, setInput] = useState("");
|
||
const [messages, setMessages] = useState<Message[]>([welcomeMessage]);
|
||
const [typing, setTyping] = useState(false);
|
||
const [showSuggestions, setShowSuggestions] = useState(true);
|
||
const endRef = useRef<HTMLDivElement | null>(null);
|
||
const debounceRef = useRef<number | null>(null);
|
||
const suggestTimerRef = useRef<number | null>(null);
|
||
|
||
useEffect(() => {
|
||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
}, [messages, typing, open]);
|
||
|
||
const handleSend = (question: string) => {
|
||
const trimmed = question.trim();
|
||
if (!trimmed) return;
|
||
const matched = matchAnswer(trimmed);
|
||
if (!matched) {
|
||
return;
|
||
}
|
||
if (debounceRef.current) {
|
||
window.clearTimeout(debounceRef.current);
|
||
}
|
||
if (suggestTimerRef.current) {
|
||
window.clearTimeout(suggestTimerRef.current);
|
||
}
|
||
debounceRef.current = window.setTimeout(() => {
|
||
const userMessage: Message = {
|
||
id: `u_${Date.now()}`,
|
||
role: "user",
|
||
text: trimmed,
|
||
};
|
||
setMessages((prev) => [...prev, userMessage]);
|
||
setInput("");
|
||
const answer = matched ?? outOfScopeAnswer;
|
||
const delay = 500 + Math.floor(Math.random() * 300);
|
||
setTyping(true);
|
||
setShowSuggestions(false);
|
||
window.setTimeout(() => {
|
||
setTyping(false);
|
||
const botMessage: Message = {
|
||
id: `b_${Date.now()}`,
|
||
role: "bot",
|
||
text: answer,
|
||
};
|
||
setMessages((prev) => [...prev, botMessage]);
|
||
suggestTimerRef.current = window.setTimeout(() => {
|
||
if (!input.trim()) {
|
||
setShowSuggestions(true);
|
||
}
|
||
}, 1400);
|
||
}, delay);
|
||
}, 250);
|
||
};
|
||
|
||
const chips = useMemo(() => suggestedQuestions.slice(0, 10), []);
|
||
const inputAnswer = matchAnswer(input);
|
||
const showHint = input.trim().length > 0 && !inputAnswer;
|
||
|
||
return (
|
||
<>
|
||
<ChatButton open={open} onToggle={() => setOpen((prev) => !prev)} />
|
||
<AnimatePresence>
|
||
{open && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20, scale: 0.98 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: 20, scale: 0.98 }}
|
||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||
className="fixed bottom-20 left-3 right-3 z-[60] h-[min(68vh,32rem)] sm:bottom-6 sm:left-auto sm:right-4 sm:top-20 sm:h-auto sm:w-[372px] sm:max-w-[calc(100vw-2rem)]"
|
||
>
|
||
<div className="flex h-full flex-col overflow-hidden rounded-3xl border border-white/10 bg-slate-900/70 shadow-2xl backdrop-blur-xl">
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div>
|
||
<h3 className="text-base font-semibold text-slate-100">
|
||
QuantFortune Assistant
|
||
</h3>
|
||
<p className="text-xs text-slate-400">
|
||
Instant answers • Always on
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => setOpen(false)}
|
||
className="text-slate-400 hover:text-slate-200"
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-6">
|
||
{messages.map((msg) => (
|
||
<MessageBubble key={msg.id} role={msg.role} text={msg.text} />
|
||
))}
|
||
{typing && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
className="flex items-center gap-2 text-xs text-slate-400"
|
||
>
|
||
<span className="h-2 w-2 animate-pulse rounded-full bg-slate-400" />
|
||
<span className="h-2 w-2 animate-pulse rounded-full bg-slate-400 [animation-delay:160ms]" />
|
||
<span className="h-2 w-2 animate-pulse rounded-full bg-slate-400 [animation-delay:320ms]" />
|
||
<span>Typing...</span>
|
||
</motion.div>
|
||
)}
|
||
<div ref={endRef} />
|
||
</div>
|
||
|
||
<div className="border-t border-white/10 px-4 pb-4">
|
||
{showSuggestions && (
|
||
<div className="max-h-56 space-y-2 overflow-y-auto pt-3 pr-1">
|
||
{chips.map((chip) => (
|
||
<motion.button
|
||
key={chip}
|
||
whileHover={{ y: -2 }}
|
||
whileTap={{ scale: 0.98 }}
|
||
onClick={() => handleSend(chip)}
|
||
className="flex w-full items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs text-slate-200 transition hover:border-sky-400/40 hover:bg-sky-500/10"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span className="text-sky-300">➜</span>
|
||
{chip}
|
||
</span>
|
||
<span className="text-slate-500">↗</span>
|
||
</motion.button>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="mt-3">
|
||
<div className="relative">
|
||
<input
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
handleSend(input);
|
||
}
|
||
}}
|
||
placeholder="Ask about QuantFortune, SIPs, or broker connection…"
|
||
className="w-full rounded-full border border-white/10 bg-white/5 px-4 py-2 pr-10 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-400/50 focus:outline-none"
|
||
/>
|
||
<button
|
||
onClick={() => handleSend(input)}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-sky-500 p-2 text-white hover:bg-sky-400"
|
||
aria-label="Send"
|
||
disabled={!inputAnswer}
|
||
>
|
||
<ArrowUpRight className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{showHint && (
|
||
<p className="mt-2 text-xs text-slate-500">
|
||
Try a suggested question for instant answers.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</>
|
||
);
|
||
}
|