Files
group-2-threaded-project-pe…/web/components/FloatingChat.js

539 lines
23 KiB
JavaScript

/*
* Floating chat button and popup for AI help and live support.
*
* Author: Shiv
* Date: April 2026
*/
"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useChatWidget } from "@/context/ChatWidgetContext";
//Floating chat button and popup window - handles AI chat, live support, and conversation history
export default function FloatingChat() {
const pathname = usePathname();
const { user, token } = useAuth();
const {
isOpen, toggleOpen,
view, openView,
aiMessages, aiSending, aiError, setAiError, sendAiMessage,
conversations, convsLoading, loadConversations,
activeConvId, activeConv, liveMessages, liveSending,
openLiveConversation, sendLiveMessage,
startLiveChat, switchingToHuman,
} = useChatWidget();
const [input, setInput] = useState("");
const [fabHovered, setFabHovered] = useState(false);
const messagesEndRef = useRef(null);
const prevAiLengthRef = useRef(0);
const prevLiveLengthRef = useRef(0);
//Scrolls to the bottom when new messages arrive, but only if already near the bottom
useEffect(() => {
if (!isOpen) return;
const aiGrew = aiMessages.length > prevAiLengthRef.current;
const liveGrew = liveMessages.length > prevLiveLengthRef.current;
prevAiLengthRef.current = aiMessages.length;
prevLiveLengthRef.current = liveMessages.length;
if (aiGrew || liveGrew) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [aiMessages, liveMessages, isOpen]);
useEffect(() => {
if (view === "history" && token && isOpen) loadConversations(token);
}, [view, token, isOpen, loadConversations]);
//Don't show the widget on the full chat pages since they have their own UI
if (pathname === "/ai-chat" || pathname === "/chat") return null;
const openConvCount = conversations.filter((c) => c.status === "OPEN").length;
const isLiveClosed = activeConv?.status === "CLOSED";
//Sends the typed message to whichever chat is currently active
async function handleSend(e) {
e?.preventDefault();
const text = input.trim();
if (!text) return;
setInput("");
if (view === "ai") {
await sendAiMessage(text, token);
} else if (view === "live" && activeConvId) {
await sendLiveMessage(text, token, activeConvId);
}
}
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
}
return (
<>
{/* Floating toggle button */}
<button onClick={toggleOpen} style={s.fab} aria-label={isOpen ? "Close chat" : "Open chat"} onMouseEnter={() => setFabHovered(true)} onMouseLeave={() => setFabHovered(false)}>
{isOpen ? (
<span style={{ fontSize: "1.4rem", lineHeight: 1 }}></span>
) : (
<span style={{ position: "relative", width: 24, height: 24, display: "inline-block" }}>
<img src="/bootstrap/chat.svg" alt="" style={{ width: 24, height: 24, position: "absolute", inset: 0, filter: "brightness(0) invert(1)", opacity: fabHovered ? 0 : 1, transition: "opacity 0.15s" }} />
<img src="/bootstrap/chat-fill.svg" alt="" style={{ width: 24, height: 24, position: "absolute", inset: 0, filter: "brightness(0) invert(1)", opacity: fabHovered ? 1 : 0, transition: "opacity 0.15s" }} />
</span>
)}
{!isOpen && openConvCount > 0 && <span style={s.fabBadge}>{openConvCount}</span>}
</button>
{/* Chat window */}
{isOpen && (
<div style={s.window}>
{/* Header */}
<div style={s.header}>
<div style={s.headerLeft}>
<div style={s.headerAvatar}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%", filter: "brightness(0) invert(1)" }} /></div>
<div>
<div style={s.headerTitle}>Leon's Assistant</div>
<div style={s.headerSub}>
{view === "live"
? (activeConv?.mode === "HUMAN" ? "Live Support" : "AI Support")
: view === "history" ? "Your Conversations"
: "AI Chat"}
</div>
</div>
</div>
<div style={s.headerRight}>
{view !== "ai" && (
<button style={s.headerNavBtn} onClick={() => openView("ai")}>AI</button>
)}
<button style={s.headerClose} onClick={toggleOpen} aria-label="Close"></button>
</div>
</div>
{/* Guest */}
{!user && (
<div style={s.guestBody}>
<img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "2.5rem", height: "2.5rem", opacity: 0.6 }} />
<p style={{ color: "#555", fontSize: "0.95rem", margin: "0.75rem 0 1.25rem", textAlign: "center" }}>
Log in to chat with our pet assistant!
</p>
<Link href="/login" style={s.loginBtn} onClick={toggleOpen}>Log In</Link>
</div>
)}
{/* History view */}
{user && view === "history" && (
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column" }}>
<div style={s.historyToolbar}>
<button
style={{ ...s.newChatBtn, ...(switchingToHuman ? s.disabledBtn : {}) }}
onClick={() => startLiveChat(token)}
disabled={switchingToHuman}
>
{switchingToHuman ? "Starting…" : "+ New Live Chat"}
</button>
<button style={s.aiTabBtn} onClick={() => openView("ai")}>AI Chat</button>
</div>
{convsLoading && (
<p style={{ color: "#aaa", fontSize: "0.85rem", padding: "1.25rem", textAlign: "center" }}>
Loading
</p>
)}
{!convsLoading && conversations.length === 0 && (
<div style={{ ...s.empty, flex: 1 }}>
<img src="/bootstrap/chat.svg" alt="chat" style={{ width: 32, height: 32, opacity: 0.5 }} />
<p style={s.emptyText}>No conversations yet.<br />Start a live chat above.</p>
</div>
)}
{/*open & active conversations*/}
{conversations.filter(c => c.status === "OPEN" && c.staffId).length > 0 && (
<div style={s.sectionLabel}> Active
</div>
)}
{conversations.filter(c => c.status === "OPEN" && c.staffId).map((conv) => (
<button key={conv.id} style={s.convItem} onClick={() => openLiveConversation(conv.id, token)}>
<div style={s.convTop}>
<span style={s.convSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.statusBadge, ...s.statusOpen }}>Active</span>
</div>
<div style={s.convBottom}>
<span style={s.convMode}>Live Support</span>
<span style={s.convDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
{/* Unclaimed - waiting for staff */}
{conversations.filter(c => c.status === "OPEN" && !c.staffId).length > 0 && (
<div style={s.sectionLabel}> Waiting </div>
)}
{conversations.filter(c => c.status === "OPEN" && !c.staffId).map((conv) => (
<button key={conv.id} style={s.convItem} onClick={() => openLiveConversation(conv.id, token)}>
<div style={s.convTop}>
<span style={s.convSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.statusBadge, ...s.statusUnclaimed }}>Waiting</span>
</div>
<div style={s.convBottom}>
<span style={s.convMode}> Waiting for agent</span>
<span style={s.convDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
</div>
</button>
))}
{/* Closed convesations*/}
{conversations.filter(c => c.status === "CLOSED").length > 0 && (
<div style={s.sectionLabel}> Closed</div>
)}
{conversations.filter(c => c.status === "CLOSED").map((conv) => (
<button key={conv.id} style={{ ...s.convItem, ...s.convItemClosed }} onClick={() => openLiveConversation(conv.id, token)}>
<div style={s.convTop}>
<span style={{ ...s.convSubject, color: "#aaa" }}>{conv.subject || `Conversation #${conv.id}`}</span>
<span style={{ ...s.statusBadge, ...s.statusClosed }}>Closed</span>
</div>
</button>
))}
</div>
)}
{/* Live chat view */}
{user && view === "live" && (
<>
{activeConv && (
<div style={s.liveStatus}>
<span style={{ ...s.statusBadge, ...(activeConv.status === "OPEN" ? s.statusOpen : s.statusClosed) }}>
{activeConv.status}
</span>
<span style={s.convMode}>
{activeConv.mode === "HUMAN" ? "👤 Live Support" : "🤖 AI Support"}
</span>
<Link href={`/chat?id=${activeConvId}`} style={s.fullPageLink} onClick={toggleOpen}>
Full page
</Link>
</div>
)}
<div style={s.messages}>
{liveMessages.length === 0 && (
<div style={s.empty}>
<p style={{ color: "#aaa", fontSize: "0.85rem" }}>No messages yet.</p>
</div>
)}
{liveMessages.map((msg) => {
const isUser = msg.senderRole === "CUSTOMER";
return (
<div key={msg.id} style={{ ...s.row, ...(isUser ? s.rowUser : s.rowOther) }}>
{!isUser && (
<div style={s.otherAvatar}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%" }} /></div>
)}
<div style={{ ...s.bubble, ...(isUser ? s.bubbleUser : s.bubbleOther) }}>
{!isUser && (
<div style={s.senderName}>
{msg.senderName || (msg.senderRole === "BOT" ? "AI Bot" : "Staff")}
</div>
)}
{msg.content}
</div>
{isUser && (
<div style={s.userAvatar}>
{user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
</div>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{isLiveClosed ? (
<div style={s.closedBanner}>This conversation is closed.</div>
) : (
<form style={s.inputRow} onSubmit={handleSend}>
<input
style={s.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message…"
disabled={liveSending}
autoComplete="off"
/>
<button type="submit"
style={{ ...s.sendBtn, ...(!input.trim() || liveSending ? s.sendBtnDisabled : {}) }}
disabled={!input.trim() || liveSending}></button>
</form>
)}
</>
)}
{/* AI chat view */}
{user && view === "ai" && (
<>
<div style={s.toolbar}>
<Link href="/ai-chat" style={s.fullPageLink} onClick={toggleOpen}>Open full page </Link>
<button
style={{ ...s.humanBtn, ...(switchingToHuman ? s.disabledBtn : {}) }}
onClick={() => startLiveChat(token)}
disabled={switchingToHuman}
>
{switchingToHuman ? "Connecting…" : "Chat with a human"}
</button>
</div>
<div style={s.messages}>
{aiMessages.length === 0 && (
<div style={s.empty}>
<img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "2rem", height: "2rem", opacity: 0.5 }} />
<p style={s.emptyText}>
Hi{user?.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}!<br />
Ask me anything about pets.
</p>
</div>
)}
{aiMessages.map((msg) => (
<div key={msg.id} style={{ ...s.row, ...(msg.role === "user" ? s.rowUser : s.rowOther) }}>
{msg.role === "assistant" && <div style={s.otherAvatar}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%" }} /></div>}
<div style={{ ...s.bubble, ...(msg.role === "user" ? s.bubbleUser : s.bubbleOther) }}>
{msg.content.split("\n").map((line, i, arr) => (
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
))}
</div>
{msg.role === "user" && (
<div style={s.userAvatar}>
{user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"}
</div>
)}
</div>
))}
{aiSending && (
<div style={{ ...s.row, ...s.rowOther }}>
<div style={s.otherAvatar}><img src="/bootstrap/person-circle.svg" alt="assistant" style={{ width: "100%", height: "100%" }} /></div>
<div style={{ ...s.bubble, ...s.bubbleOther, ...s.typingBubble }}>
<span className="fc-dot" />
<span className="fc-dot" style={{ animationDelay: "0.2s" }} />
<span className="fc-dot" style={{ animationDelay: "0.4s" }} />
</div>
</div>
)}
{aiError && (
<div style={s.errorBar}>
{aiError}
<button style={s.errorClose} onClick={() => setAiError(null)}></button>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form style={s.inputRow} onSubmit={handleSend}>
<input
style={s.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about pet care…"
disabled={aiSending}
autoComplete="off"
/>
<button type="submit"
style={{ ...s.sendBtn, ...(!input.trim() || aiSending ? s.sendBtnDisabled : {}) }}
disabled={!input.trim() || aiSending}></button>
</form>
</>
)}
</div>
)}
</>
);
}
//Inline style objects for the floating chat widget
const s = {
fab: {
position: "fixed", bottom: 24, right: 24,
width: 56, height: 56, borderRadius: "50%",
background: "#e68672", color: "#2f2f2f", border: "none",
cursor: "pointer", zIndex: 9999,
boxShadow: "0 4px 18px rgba(0,0,0,0.22)",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "transform 0.15s, box-shadow 0.15s",
},
fabBadge: {
position: "absolute", top: 2, right: 2,
background: "#e53935", color: "white",
borderRadius: "999px", fontSize: "0.62rem", fontWeight: 700,
minWidth: 17, height: 17,
display: "flex", alignItems: "center", justifyContent: "center", padding: "0 3px",
},
window: {
position: "fixed", bottom: 92, right: 24,
width: 370, height: 530,
background: "#fff", borderRadius: 16,
boxShadow: "0 8px 36px rgba(0,0,0,0.18)",
zIndex: 9998, display: "flex", flexDirection: "column",
overflow: "hidden", fontFamily: "Arial, sans-serif",
},
header: {
background: "#e68672", padding: "0.8rem 1rem",
display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0,
},
headerLeft: { display: "flex", alignItems: "center", gap: "0.6rem" },
headerAvatar: {
width: 36, height: 36, borderRadius: "50%",
background: "rgba(255,255,255,0.25)",
display: "flex", alignItems: "center", justifyContent: "center", fontSize: "1.1rem",
},
headerTitle: { fontWeight: 700, fontSize: "0.92rem", color: "#2f2f2f" },
headerSub: { fontSize: "0.7rem", color: "rgba(47,47,47,0.65)", marginTop: 1 },
headerRight: { display: "flex", alignItems: "center", gap: "0.35rem" },
headerNavBtn: {
background: "rgba(255,255,255,0.22)", border: "none", borderRadius: 6,
padding: "0.28rem 0.6rem", fontSize: "0.75rem", fontWeight: 600,
color: "#2f2f2f", cursor: "pointer",
},
headerNavBtnActive: { background: "rgba(255,255,255,0.45)" },
headerClose: {
background: "transparent", border: "none",
fontSize: "0.95rem", color: "#2f2f2f", cursor: "pointer", padding: "0.2rem 0.35rem", lineHeight: 1,
},
guestBody: {
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", padding: "2rem",
},
loginBtn: {
background: "#e68672", color: "#2f2f2f",
padding: "0.6rem 1.5rem", borderRadius: 8,
textDecoration: "none", fontWeight: 700, fontSize: "0.95rem",
},
toolbar: {
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0.5rem 0.85rem", borderBottom: "1px solid #f0f0f0",
background: "#fafafa", flexShrink: 0,
},
fullPageLink: { color: "#e68672", fontSize: "0.76rem", fontWeight: 600, textDecoration: "none" },
humanBtn: {
background: "transparent", border: "1.5px solid #e68672", color: "#e68672",
borderRadius: 6, padding: "0.28rem 0.65rem", fontSize: "0.76rem", fontWeight: 600, cursor: "pointer",
},
disabledBtn: { opacity: 0.55, cursor: "not-allowed" },
messages: {
flex: 1, overflowY: "auto", padding: "0.8rem",
display: "flex", flexDirection: "column", gap: "0.55rem",
},
empty: {
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center", gap: "0.5rem", margin: "auto",
},
emptyText: { color: "#aaa", fontSize: "0.88rem", textAlign: "center", lineHeight: 1.5, margin: 0 },
row: { display: "flex", alignItems: "flex-end", gap: "0.4rem" },
rowUser: { flexDirection: "row-reverse" },
rowOther: { flexDirection: "row" },
otherAvatar: {
width: 26, height: 26, borderRadius: "50%", background: "#f0f0f0",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.75rem", flexShrink: 0,
},
userAvatar: {
width: 26, height: 26, borderRadius: "50%", background: "#e68672", color: "#2f2f2f",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "0.72rem", fontWeight: 700, flexShrink: 0,
},
bubble: {
maxWidth: "76%", padding: "0.5rem 0.75rem", borderRadius: 12,
fontSize: "0.86rem", lineHeight: 1.5, wordBreak: "break-word",
},
bubbleUser: { background: "#e68672", color: "#2f2f2f", borderBottomRightRadius: 4 },
bubbleOther: { background: "#f4f4f4", color: "#1a1a1a", borderBottomLeftRadius: 4 },
typingBubble: { display: "flex", alignItems: "center", gap: "4px", padding: "0.65rem 0.85rem" },
senderName: { fontSize: "0.7rem", fontWeight: 700, color: "#888", marginBottom: 2 },
errorBar: {
background: "#fff0f0", borderTop: "1px solid #ffd0d0", color: "#c0392b",
padding: "0.5rem 0.85rem", fontSize: "0.8rem",
display: "flex", alignItems: "center", justifyContent: "space-between", flexShrink: 0,
},
errorClose: { background: "none", border: "none", color: "#c0392b", cursor: "pointer" },
inputRow: {
display: "flex", gap: "0.45rem", padding: "0.6rem 0.85rem",
borderTop: "1px solid #f0f0f0", flexShrink: 0,
},
input: {
flex: 1, border: "1.5px solid #e0e0e0", borderRadius: 8,
padding: "0.48rem 0.75rem", fontSize: "0.86rem", outline: "none", fontFamily: "inherit",
},
sendBtn: {
background: "#e68672", color: "#2f2f2f", border: "none", borderRadius: 8,
padding: "0.48rem 0.75rem", fontSize: "0.95rem", fontWeight: 700, cursor: "pointer", flexShrink: 0,
},
sendBtnDisabled: { background: "#f0c8be", cursor: "not-allowed" },
historyToolbar: {
display: "flex", gap: "0.5rem", padding: "0.65rem 0.85rem",
borderBottom: "1px solid #f0f0f0", flexShrink: 0,
},
newChatBtn: {
flex: 1, background: "#e68672", color: "#2f2f2f", border: "none",
borderRadius: 8, padding: "0.5rem", fontSize: "0.8rem", fontWeight: 700, cursor: "pointer",
},
aiTabBtn: {
background: "#f4f4f4", color: "#555", border: "none", borderRadius: 8,
padding: "0.5rem 0.85rem", fontSize: "0.8rem", fontWeight: 600, cursor: "pointer",
},
convItem: {
display: "flex", flexDirection: "column", gap: "0.2rem",
padding: "0.7rem 0.85rem", borderBottom: "1px solid #f0f0f0",
background: "white", border: "none", textAlign: "left", cursor: "pointer", width: "100%",
},
convTop: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem" },
convBottom: { display: "flex", alignItems: "center", justifyContent: "space-between" },
convSubject: {
fontSize: "0.86rem", fontWeight: 600, color: "#222",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1,
},
convMode: { fontSize: "0.74rem", color: "#999" },
convDate: { fontSize: "0.7rem", color: "#bbb" },
statusBadge: {
fontSize: "0.67rem", fontWeight: 700, borderRadius: 20,
padding: "0.13rem 0.5rem", flexShrink: 0,
textTransform: "uppercase", letterSpacing: "0.04em",
},
statusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
statusClosed: { background: "#f0f0f0", color: "#888" },
liveStatus: {
display: "flex", alignItems: "center", gap: "0.5rem",
padding: "0.45rem 0.85rem", borderBottom: "1px solid #f0f0f0",
background: "#fafafa", flexShrink: 0,
},
statusUnclaimed: { background: "#fff8e1", color: "#f57f17" },
sectionLabel: {
fontSize: "0.7rem", fontWeight: 700, color: "#aaa",
padding: "0.4rem 0.85rem 0.2rem", textTransform: "uppercase",
letterSpacing: "0.05em", background: "#fafafa",
},
convItemClosed: { background: "#fafafa", opacity: 0.75 },
closedBanner: {
background: "#f5f5f5", borderTop: "1px solid #e0e0e0", color: "#888",
padding: "0.65rem 0.85rem", fontSize: "0.84rem", textAlign: "center", flexShrink: 0,
},
};