191 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 👋 Im 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>
</>
);
}