From 995088ece2023cd532308b3747a6a4644627aa5c Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Tue, 14 Apr 2026 05:59:33 -0600 Subject: [PATCH] Chat now present in the bottom right. --- .claude/settings.local.json | 3 +- .../backend/controller/ChatController.java | 5 +- .../petshop/backend/service/ChatService.java | 4 +- web/app/globals.css | 13 +- web/components/ClientProviders.js | 9 +- web/components/FloatingChat.js | 464 ++++++++++++++++++ web/context/ChatWidgetContext.js | 166 +++++++ 7 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 web/components/FloatingChat.js create mode 100644 web/context/ChatWidgetContext.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 739d260a..253cb04b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")" + "Bash(grep -E \"\\\\.\\(tsx?|jsx?\\)$\")", + "Bash(grep -E \"\\\\.js$|^d\")" ] } } diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index 5c7d87de..e9d8459a 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -68,9 +68,10 @@ public class ChatController { @GetMapping("/conversations") @PreAuthorize("hasAnyRole('CUSTOMER', 'STAFF', 'ADMIN')") - public ResponseEntity> getConversations() { + public ResponseEntity> getConversations( + @RequestParam(required = false, defaultValue = "false") boolean mine) { User user = getCurrentUser(); - List conversations = chatService.getConversations(user.getId(), user.getRole()); + List conversations = chatService.getConversations(user.getId(), user.getRole(), mine); return ResponseEntity.ok(conversations); } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 5b347cf5..bee48228 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -65,10 +65,10 @@ public class ChatService { return ConversationResponse.fromEntity(conversation, request.getMessage(), userId); } - public List getConversations(Long userId, User.Role role) { + public List getConversations(Long userId, User.Role role, boolean mine) { List conversations; - if (role == User.Role.CUSTOMER) { + if (mine || role == User.Role.CUSTOMER) { conversations = conversationRepository.findByCustomerId(userId); } else if (role == User.Role.STAFF) { List assignedToMe = conversationRepository.findByStaffId(userId); diff --git a/web/app/globals.css b/web/app/globals.css index 25de93db..dd607b01 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -74,6 +74,7 @@ body { color: #2f2f2f; text-decoration: none; font-size: 1.1rem; + font-weight: 700; padding: 0.5rem 1rem; border-radius: 4px; transition: all 0.3s ease; @@ -1009,7 +1010,7 @@ body { } .nav-greeting { - font-weight: 600; + font-weight: 700; white-space: nowrap; } @@ -2473,6 +2474,16 @@ body { @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-6px); } } @keyframes spin { to { transform: rotate(360deg); } } +/* Floating chat widget */ +.fc-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #aaa; + animation: bounce 1s infinite; +} + /* Footer */ .site-footer { background: #e68672; diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js index 58cea326..a5c6f9f3 100644 --- a/web/components/ClientProviders.js +++ b/web/components/ClientProviders.js @@ -2,11 +2,18 @@ import { AuthProvider } from "@/context/AuthContext"; import { CartProvider } from "@/context/CartContext"; +import { ChatWidgetProvider } from "@/context/ChatWidgetContext"; +import FloatingChat from "@/components/FloatingChat"; export default function ClientProviders({ children }) { return ( - {children} + + + {children} + + + ); } diff --git a/web/components/FloatingChat.js b/web/components/FloatingChat.js new file mode 100644 index 00000000..4b17232d --- /dev/null +++ b/web/components/FloatingChat.js @@ -0,0 +1,464 @@ +"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"; + +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 messagesEndRef = useRef(null); + + useEffect(() => { + if (isOpen) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [aiMessages, liveMessages, isOpen]); + + useEffect(() => { + if (view === "history" && token && isOpen) loadConversations(token); + }, [view, token, isOpen, loadConversations]); + + // Hide widget on dedicated chat pages + if (pathname === "/ai-chat" || pathname === "/chat") return null; + + const openConvCount = conversations.filter((c) => c.status === "OPEN").length; + const isLiveClosed = activeConv?.status === "CLOSED"; + + 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 */} + + + {/* Chat window */} + {isOpen && ( +
+ + {/* Header */} +
+
+
🐾
+
+
Leon's Assistant
+
+ {view === "live" + ? (activeConv?.mode === "HUMAN" ? "Live Support" : "AI Support") + : view === "history" ? "Your Conversations" + : "AI Chat"} +
+
+
+
+ {view !== "ai" && ( + + )} + + +
+
+ + {/* Guest */} + {!user && ( +
+ 🐾 +

+ Log in to chat with our pet assistant! +

+ Log In +
+ )} + + {/* History view */} + {user && view === "history" && ( +
+
+ + +
+ + {convsLoading && ( +

+ Loading… +

+ )} + + {!convsLoading && conversations.length === 0 && ( +
+ 💬 +

No conversations yet.
Start a live chat above.

+
+ )} + + {conversations.map((conv) => ( + + ))} +
+ )} + + {/* Live chat view */} + {user && view === "live" && ( + <> + {activeConv && ( +
+ + {activeConv.status} + + + {activeConv.mode === "HUMAN" ? "👤 Live Support" : "🤖 AI Support"} + + + Full page ↗ + +
+ )} + +
+ {liveMessages.length === 0 && ( +
+

No messages yet.

+
+ )} + {liveMessages.map((msg) => { + const isUser = msg.senderRole === "CUSTOMER"; + return ( +
+ {!isUser && ( +
{msg.senderRole === "BOT" ? "🐾" : "👤"}
+ )} +
+ {!isUser && ( +
+ {msg.senderName || (msg.senderRole === "BOT" ? "AI Bot" : "Staff")} +
+ )} + {msg.content} +
+ {isUser && ( +
+ {user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ); + })} +
+
+ + {isLiveClosed ? ( +
This conversation is closed.
+ ) : ( +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type a message…" + disabled={liveSending} + autoComplete="off" + /> + +
+ )} + + )} + + {/* AI chat view */} + {user && view === "ai" && ( + <> +
+ Open full page ↗ + +
+ +
+ {aiMessages.length === 0 && ( +
+ 🐾 +

+ Hi{user?.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}!
+ Ask me anything about pets. +

+
+ )} + + {aiMessages.map((msg) => ( +
+ {msg.role === "assistant" &&
🐾
} +
+ {msg.content.split("\n").map((line, i, arr) => ( + {line}{i < arr.length - 1 &&
}
+ ))} +
+ {msg.role === "user" && ( +
+ {user?.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ))} + + {aiSending && ( +
+
🐾
+
+ + + +
+
+ )} + + {aiError && ( +
+ {aiError} + +
+ )} + +
+
+ +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask about pet care…" + disabled={aiSending} + autoComplete="off" + /> + +
+ + )} + +
+ )} + + ); +} + +// Styles +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, + }, + closedBanner: { + background: "#f5f5f5", borderTop: "1px solid #e0e0e0", color: "#888", + padding: "0.65rem 0.85rem", fontSize: "0.84rem", textAlign: "center", flexShrink: 0, + }, +}; diff --git a/web/context/ChatWidgetContext.js b/web/context/ChatWidgetContext.js new file mode 100644 index 00000000..0b506dbf --- /dev/null +++ b/web/context/ChatWidgetContext.js @@ -0,0 +1,166 @@ +"use client"; + +import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react"; + +const ChatWidgetContext = createContext(null); +const API_BASE = ""; + +export function ChatWidgetProvider({ children }) { + const [isOpen, setIsOpen] = useState(false); + const [view, setView] = useState("ai"); // "ai" | "history" | "live" + + // AI chat + const [aiMessages, setAiMessages] = useState([]); + const [aiSending, setAiSending] = useState(false); + const [aiError, setAiError] = useState(null); + + // Keep a ref so sendAiMessage stays stable (no stale-closure over messages) + const aiMessagesRef = useRef(aiMessages); + useEffect(() => { aiMessagesRef.current = aiMessages; }, [aiMessages]); + + const sendAiMessage = useCallback(async (text, token) => { + if (!text.trim() || !token) return; + const userMsg = { role: "user", content: text, id: Date.now() }; + setAiMessages((prev) => [...prev, userMsg]); + setAiSending(true); + setAiError(null); + try { + const history = aiMessagesRef.current.map((m) => ({ role: m.role, content: m.content })); + const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ message: text, history }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { setAiError(data.error || "Failed to get a response."); return; } + setAiMessages((prev) => [...prev, { role: "assistant", content: data.message, id: Date.now() + 1 }]); + } catch { + setAiError("Network error. Please try again."); + } finally { + setAiSending(false); + } + }, []); + + //Live chat + const [conversations, setConversations] = useState([]); + const [convsLoading, setConvsLoading] = useState(false); + const [activeConvId, setActiveConvId] = useState(null); + const [activeConv, setActiveConv] = useState(null); + const [liveMessages, setLiveMessages] = useState([]); + const [liveSending, setLiveSending] = useState(false); + const [switchingToHuman, setSwitchingToHuman] = useState(false); + + const pollRef = useRef(null); + const activeConvIdRef = useRef(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + }, []); + + const fetchLiveMessages = useCallback(async (convId, token) => { + if (!convId || !token) return; + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + if (Array.isArray(data)) setLiveMessages(data); + } catch { /* silent */ } + }, []); + + const loadConversations = useCallback(async (token) => { + if (!token) return; + setConvsLoading(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations?mine=true`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) return; + const data = await res.json(); + setConversations(Array.isArray(data) ? data : (data.content ?? [])); + } catch { /* silent */ } finally { + setConvsLoading(false); + } + }, []); + + const openLiveConversation = useCallback(async (convId, token) => { + if (!convId || !token) return; + stopPolling(); + setActiveConvId(convId); + activeConvIdRef.current = convId; + setLiveMessages([]); + setView("live"); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) setActiveConv(await res.json()); + } catch { /* silent */ } + await fetchLiveMessages(convId, token); + pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500); + }, [stopPolling, fetchLiveMessages]); + + const sendLiveMessage = useCallback(async (text, token, convId) => { + if (!text.trim() || liveSending || !token || !convId) return; + setLiveSending(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ content: text }), + }); + if (res.ok) await fetchLiveMessages(convId, token); + } catch { /* silent */ } finally { + setLiveSending(false); + } + }, [liveSending, fetchLiveMessages]); + + const startLiveChat = useCallback(async (token) => { + if (!token || switchingToHuman) return; + setSwitchingToHuman(true); + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ message: "Hi, I'd like to speak with a support agent." }), + }); + if (!res.ok) return; + const conv = await res.json(); + await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, { + method: "POST", headers: { Authorization: `Bearer ${token}` }, + }); + setConversations((prev) => [conv, ...prev.filter((c) => c.id !== conv.id)]); + await openLiveConversation(conv.id, token); + } catch { /* silent */ } finally { + setSwitchingToHuman(false); + } + }, [switchingToHuman, openLiveConversation]); + + // Stop polling when navigating away from live view or closing widget + useEffect(() => { if (view !== "live") stopPolling(); }, [view, stopPolling]); + useEffect(() => { if (!isOpen) stopPolling(); }, [isOpen, stopPolling]); + + const toggleOpen = useCallback(() => setIsOpen((o) => !o), []); + const openView = useCallback((v) => setView(v), []); + + return ( + + {children} + + ); +} + +export function useChatWidget() { + const ctx = useContext(ChatWidgetContext); + if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider"); + return ctx; +}