merge main into branch
This commit is contained in:
@@ -74,7 +74,7 @@ export default function AdoptPage() {
|
||||
[pets]
|
||||
);
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
const ITEMS_PER_PAGE = 24;
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
const filteredPets = useMemo(
|
||||
@@ -192,15 +192,42 @@ export default function AdoptPage() {
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
onClick={() => { setCurrentPage((p) => Math.max(0, p - 1)); window.scrollTo(0, 0); }}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="pagination-info">Page {currentPage + 1} of {totalPages}</span>
|
||||
{(() => {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
const left = Math.max(0, currentPage - delta);
|
||||
const right = Math.min(totalPages - 1, currentPage + delta);
|
||||
if (left > 0) {
|
||||
pages.push(0);
|
||||
if (left > 1) pages.push("...");
|
||||
}
|
||||
for (let i = left; i <= right; i++) pages.push(i);
|
||||
if (right < totalPages - 1) {
|
||||
if (right < totalPages - 2) pages.push("...");
|
||||
pages.push(totalPages - 1);
|
||||
}
|
||||
return pages.map((p, i) =>
|
||||
p === "..." ? (
|
||||
<span key={`ellipsis-${i}`} className="pagination-ellipsis">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`pagination-btn${p === currentPage ? " pagination-btn--active" : ""}`}
|
||||
onClick={() => { setCurrentPage(p); window.scrollTo(0, 0); }}
|
||||
>
|
||||
{p + 1}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
onClick={() => { setCurrentPage((p) => Math.min(totalPages - 1, p + 1)); window.scrollTo(0, 0); }}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
>
|
||||
Next →
|
||||
|
||||
@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const API_BASE = "";
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
function isImageFilename(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
|
||||
}
|
||||
|
||||
function AttachmentPreview({ url, name, token }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const isImage = isImageFilename(name);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
let objectUrl;
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => (r.ok ? r.blob() : null))
|
||||
.then((blob) => {
|
||||
if (blob) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [url, token]);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
{blobUrl ? (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={name || "attachment"}
|
||||
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
|
||||
onClick={() => window.open(blobUrl, "_blank")}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image…</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "0.4rem" }}>
|
||||
<a
|
||||
href="#"
|
||||
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!blobUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = name || "attachment";
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
📎 {name || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AiChatPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
@@ -22,15 +82,20 @@ function AiChatPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [convsLoading, setConvsLoading] = useState(false);
|
||||
const [closedExpanded, setClosedExpanded] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [botTyping, setBotTyping] = useState(false);
|
||||
const [switchingConv, setSwitchingConv] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesAreaRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const lastMessageIdRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const lastScrolledIdRef = useRef(null);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
const botTypingTimeoutRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -42,6 +107,7 @@ function AiChatPage() {
|
||||
if (messages.length === 0) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg.id === lastScrolledIdRef.current) return;
|
||||
if (!initialLoadDoneRef.current) return;
|
||||
lastScrolledIdRef.current = lastMsg.id;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
@@ -51,8 +117,17 @@ function AiChatPage() {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!botTyping) return;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [botTyping]);
|
||||
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -61,10 +136,17 @@ function AiChatPage() {
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setMessages(data);
|
||||
if (data.length > 0) lastMessageIdRef.current = data[data.length - 1].id;
|
||||
if (data.length > 0) {
|
||||
lastMessageIdRef.current = data[data.length - 1].id;
|
||||
lastScrolledIdRef.current = data[data.length - 1].id;
|
||||
}
|
||||
setTimeout(() => {
|
||||
const area = messagesAreaRef.current;
|
||||
if (area) area.scrollTop = area.scrollHeight;
|
||||
initialLoadDoneRef.current = true;
|
||||
}, 50);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -100,38 +182,36 @@ function AiChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const startPolling = useCallback((convId) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (!token || !convId) return;
|
||||
try {
|
||||
const [msgsRes, convRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (msgsRes.ok) {
|
||||
const data = await msgsRes.json();
|
||||
if (Array.isArray(data)) {
|
||||
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||
if (lastId !== lastMessageIdRef.current) {
|
||||
lastMessageIdRef.current = lastId;
|
||||
setMessages(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (convRes.ok) {
|
||||
const convData = await convRes.json();
|
||||
setConversation(convData);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, [token]);
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
|
||||
setBotTyping(false);
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
const convTopic = user?.role === "CUSTOMER"
|
||||
? `/user/queue/chat/conversations`
|
||||
: `/topic/chat/conversations`;
|
||||
client.subscribe(convTopic, (frame) => {
|
||||
try {
|
||||
const conv = JSON.parse(frame.body);
|
||||
if (conv.id === convId) setConversation(conv);
|
||||
setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
};
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}, [token, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || authLoading) return;
|
||||
@@ -171,7 +251,7 @@ function AiChatPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (stale) return;
|
||||
if (res.ok) {
|
||||
@@ -197,7 +277,7 @@ function AiChatPage() {
|
||||
]);
|
||||
if (stale) return;
|
||||
setLoadingConv(false);
|
||||
startPolling(convId);
|
||||
connectStomp(convId);
|
||||
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
@@ -205,9 +285,9 @@ function AiChatPage() {
|
||||
|
||||
return () => {
|
||||
stale = true;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
};
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations, router]);
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
|
||||
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
@@ -250,6 +330,11 @@ function AiChatPage() {
|
||||
const msg = await res.json();
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
if (!isEscalated) {
|
||||
setBotTyping(true);
|
||||
if (botTypingTimeoutRef.current) clearTimeout(botTypingTimeoutRef.current);
|
||||
botTypingTimeoutRef.current = setTimeout(() => setBotTyping(false), 30000);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
setInput(text);
|
||||
@@ -320,7 +405,7 @@ function AiChatPage() {
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setError(null);
|
||||
setLoadingConv(true);
|
||||
try {
|
||||
@@ -330,7 +415,7 @@ function AiChatPage() {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ message: "Hello! I'd like to chat with the AI assistant." }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
@@ -342,7 +427,7 @@ function AiChatPage() {
|
||||
setConversation(conv);
|
||||
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
||||
setLoadingConv(false);
|
||||
startPolling(conv.id);
|
||||
connectStomp(conv.id);
|
||||
router.replace(`/ai-chat?id=${conv.id}`, { scroll: false });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -351,9 +436,10 @@ function AiChatPage() {
|
||||
}
|
||||
|
||||
async function switchConversation(convId) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setBotTyping(false);
|
||||
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
|
||||
}
|
||||
|
||||
@@ -364,12 +450,28 @@ function AiChatPage() {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
router.push(`/chat?id=${conversation.id}`);
|
||||
setConversation((prev) => prev ? { ...prev, mode: "HUMAN" } : prev);
|
||||
} catch {
|
||||
setError("Could not connect to live support. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloseConversation() {
|
||||
if (!conversation || conversation.status === "CLOSED") return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${conversation.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ status: "CLOSED" }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
setConversation(updated);
|
||||
await fetchConversations();
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || loadingConv) {
|
||||
return (
|
||||
<main style={s.page}>
|
||||
@@ -382,6 +484,8 @@ function AiChatPage() {
|
||||
|
||||
const isEscalated = conversation?.mode === "HUMAN";
|
||||
const isClosed = conversation?.status === "CLOSED";
|
||||
const hasStaff = !!conversation?.staffId;
|
||||
const hasStaffMessage = messages.some((m) => m.senderId !== user?.id);
|
||||
|
||||
return (
|
||||
<main style={s.page}>
|
||||
@@ -402,7 +506,7 @@ function AiChatPage() {
|
||||
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
||||
)}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{conversations.map((conv) => (
|
||||
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
@@ -410,9 +514,7 @@ function AiChatPage() {
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
||||
{conv.status}
|
||||
</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
@@ -421,6 +523,30 @@ function AiChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{conversations.some(c => c.status === "CLOSED") && (
|
||||
<>
|
||||
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
|
||||
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
|
||||
<span>{closedExpanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
onClick={() => switchConversation(conv.id)}
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button style={s.newConvSidebarBtn} onClick={handleNewConversation}>
|
||||
+ New Conversation
|
||||
</button>
|
||||
@@ -444,41 +570,44 @@ function AiChatPage() {
|
||||
<div style={s.chatCard}>
|
||||
<div style={s.chatHeader}>
|
||||
<div style={s.chatHeaderLeft}>
|
||||
<div style={s.aiAvatar}>🐾</div>
|
||||
<div style={isEscalated ? s.agentAvatar : s.aiAvatar}>{isEscalated ? "👤" : "🐾"}</div>
|
||||
<div>
|
||||
<div style={s.chatHeaderTitle}>Leon's Pet Assistant</div>
|
||||
<div style={s.chatHeaderStatus}>
|
||||
<span style={s.statusDot} /> Online
|
||||
<div style={s.chatHeaderTitle}>
|
||||
{isEscalated ? (hasStaff ? "Support Agent" : "Leon's Pet Store Support") : "Leon's Pet Assistant"}
|
||||
</div>
|
||||
<div style={{ ...s.chatHeaderStatus, color: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }}>
|
||||
<span style={{ ...s.statusDot, background: isClosed ? "#999" : isEscalated && hasStaff ? "#4CAF50" : isEscalated ? "#ff8c00" : undefined }} />
|
||||
{isClosed ? "Conversation closed" : isEscalated && hasStaff ? "Support agent connected" : isEscalated ? "Waiting for a support agent..." : "Online"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{!isEscalated && !isClosed && (
|
||||
<button
|
||||
style={s.humanBtn}
|
||||
onClick={handleSwitchToHuman}
|
||||
title="Connect with a human support agent"
|
||||
>
|
||||
<button style={s.humanBtn} onClick={handleSwitchToHuman} title="Connect with a human support agent">
|
||||
Chat with a Real Person
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={s.liveBtn}
|
||||
onClick={() => router.push("/chat")}
|
||||
title="Go to Live Support"
|
||||
>
|
||||
Live Support
|
||||
</button>
|
||||
{!isClosed && (
|
||||
<button style={s.closeConvBtn} onClick={handleCloseConversation} title="Close this conversation">
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEscalated && !hasStaff && !hasStaffMessage && !isClosed && (
|
||||
<div style={s.waitingBanner}>
|
||||
<span style={s.waitingSpinner} />
|
||||
A support agent will be with you shortly. You can send messages while you wait.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={s.messagesArea} ref={messagesAreaRef}>
|
||||
{messages.length === 0 && (
|
||||
<div style={s.emptyState}>
|
||||
<div style={s.emptyIcon}>🐾</div>
|
||||
<div style={s.emptyIcon}>{isEscalated ? "💬" : "🐾"}</div>
|
||||
<p style={s.emptyText}>
|
||||
Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant.
|
||||
Ask me about pet recommendations, care tips, supplies, or anything pet-related!
|
||||
{isEscalated ? "Your conversation has started. A support agent will join soon." : `Hello${user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. Ask me about pet recommendations, care tips, supplies, or anything pet-related!`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -493,7 +622,7 @@ function AiChatPage() {
|
||||
...(isOwn ? s.messageRowUser : s.messageRowAgent),
|
||||
}}
|
||||
>
|
||||
{!isOwn && <div style={s.aiAvatarSmall}>🐾</div>}
|
||||
{!isOwn && <div style={isEscalated ? s.agentAvatarSmall : s.aiAvatarSmall}>{isEscalated ? "👤" : "🐾"}</div>}
|
||||
<div
|
||||
style={{
|
||||
...s.messageBubble,
|
||||
@@ -507,16 +636,7 @@ function AiChatPage() {
|
||||
</span>
|
||||
))}
|
||||
{msg.attachmentUrl && (
|
||||
<div style={s.attachment}>
|
||||
<a
|
||||
href={msg.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={s.attachmentLink}
|
||||
>
|
||||
📎 {msg.attachmentName || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
|
||||
)}
|
||||
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||
@@ -530,6 +650,24 @@ function AiChatPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{botTyping && !isEscalated && (
|
||||
<div style={{ ...s.messageRow, ...s.messageRowAgent }}>
|
||||
<div style={s.aiAvatarSmall}>🐾</div>
|
||||
<div style={{ ...s.messageBubble, ...s.bubbleAgent, display: "flex", alignItems: "center", gap: "4px", padding: "0.6rem 0.9rem" }}>
|
||||
<span className="fc-dot" />
|
||||
<span className="fc-dot" style={{ animationDelay: "0.2s" }} />
|
||||
<span className="fc-dot" style={{ animationDelay: "0.4s" }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{switchingConv && (
|
||||
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
|
||||
Loading messages…
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -726,6 +864,21 @@ const s = {
|
||||
},
|
||||
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
||||
convStatusClosed: { background: "#f0f0f0", color: "#888" },
|
||||
convItemClosed: { opacity: 0.7 },
|
||||
closedSectionToggle: {
|
||||
width: "100%",
|
||||
background: "#f5f5f5",
|
||||
border: "none",
|
||||
borderTop: "1px solid #e8e8e8",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
newConvSidebarBtn: {
|
||||
margin: "0.65rem 1rem",
|
||||
background: "#333",
|
||||
@@ -815,6 +968,60 @@ const s = {
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
agentAvatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, #444, #666)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "1.3rem",
|
||||
flexShrink: 0,
|
||||
},
|
||||
agentAvatarSmall: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: "50%",
|
||||
background: "#e0e0e0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.9rem",
|
||||
flexShrink: 0,
|
||||
},
|
||||
closeConvBtn: {
|
||||
background: "white",
|
||||
border: "2px solid #c0392b",
|
||||
color: "#c0392b",
|
||||
borderRadius: 8,
|
||||
padding: "0.45rem 0.9rem",
|
||||
fontSize: "0.82rem",
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
waitingBanner: {
|
||||
background: "#fff8f0",
|
||||
borderBottom: "1px solid #ffe0b2",
|
||||
padding: "0.6rem 1.25rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "#7c4a00",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
flexShrink: 0,
|
||||
},
|
||||
waitingSpinner: {
|
||||
display: "inline-block",
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: "50%",
|
||||
border: "2px solid #ff8c00",
|
||||
borderTopColor: "transparent",
|
||||
animation: "spin 0.8s linear infinite",
|
||||
flexShrink: 0,
|
||||
},
|
||||
noConvCard: {
|
||||
background: "white",
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -20,20 +20,22 @@ const SPECIES_BREEDS = {
|
||||
Other: ["Other"],
|
||||
};
|
||||
|
||||
// Explicit allowlists for species with restricted service availability.
|
||||
// Species not listed here may use all services.
|
||||
const SPECIES_SERVICE_ALLOWLIST = {
|
||||
const SPECIES_EXCLUSIVE_SERVICES = {
|
||||
Bird: ["wing clipping", "beak and nail"],
|
||||
Fish: ["aquarium health"],
|
||||
};
|
||||
|
||||
function getAvailableServices(services, species) {
|
||||
if (!species) return services;
|
||||
const allowlist = SPECIES_SERVICE_ALLOWLIST[species];
|
||||
if (!allowlist) return services;
|
||||
return services.filter((s) =>
|
||||
allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw))
|
||||
);
|
||||
return services.filter((s) => {
|
||||
const name = s.serviceName.toLowerCase();
|
||||
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
|
||||
if (exclusiveSpecies !== species && keywords.some((kw) => name.includes(kw))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
@@ -381,6 +383,10 @@ function AppointmentsPage() {
|
||||
|
||||
const [showAddPetModal, setShowAddPetModal] = useState(false);
|
||||
const [cancellingId, setCancellingId] = useState(null);
|
||||
const [apptSearch, setApptSearch] = useState("");
|
||||
const [adoptionSearch, setAdoptionSearch] = useState("");
|
||||
const [showPastAppts, setShowPastAppts] = useState(false);
|
||||
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
|
||||
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
@@ -964,79 +970,164 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{loadingAppointments ? (
|
||||
<p className="appt-loading">Loading appointments...</p>
|
||||
) : appointments.length === 0 ? (
|
||||
<p className="appt-empty">No appointments yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{appointments.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
) : (() => {
|
||||
const activeAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() === "booked");
|
||||
const pastAppts = appointments.filter((a) => a.appointmentStatus?.toLowerCase() !== "booked");
|
||||
const q = apptSearch.toLowerCase();
|
||||
const filteredActive = activeAppts.filter((a) =>
|
||||
!q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
type="text"
|
||||
placeholder="Search appointments…"
|
||||
value={apptSearch}
|
||||
onChange={(e) => setApptSearch(e.target.value)}
|
||||
/>
|
||||
{filteredActive.length === 0 ? (
|
||||
<p className="appt-empty">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="appt-card-pets">Pet: {a.petName}</div>
|
||||
)}
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
)}
|
||||
{pastAppts.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAppts((v) => !v)}>
|
||||
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
|
||||
</button>
|
||||
{showPastAppts && (
|
||||
<div className="appt-list appt-list--past">
|
||||
{pastAppts.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
{a.appointmentStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.storeName}</span>
|
||||
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="appt-card-pets">Pet: {a.petName}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{a.petName && (
|
||||
<div className="appt-card-pets">
|
||||
Pet: {a.petName}
|
||||
</div>
|
||||
)}
|
||||
{a.appointmentStatus?.toLowerCase() === "booked" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.appointmentId}
|
||||
onClick={() => handleCancelAppointment(a.appointmentId)}
|
||||
>
|
||||
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
<h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
|
||||
{loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
) : adoptions.length === 0 ? (
|
||||
<p className="appt-empty">No adoption requests yet.</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{adoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
) : (() => {
|
||||
const activeAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() === "pending");
|
||||
const pastAdoptions = adoptions.filter((a) => a.adoptionStatus?.toLowerCase() !== "pending");
|
||||
const q = adoptionSearch.toLowerCase();
|
||||
const filteredActive = activeAdoptions.filter((a) =>
|
||||
!q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
type="text"
|
||||
placeholder="Search adoptions…"
|
||||
value={adoptionSearch}
|
||||
onChange={(e) => setAdoptionSearch(e.target.value)}
|
||||
/>
|
||||
{filteredActive.length === 0 ? (
|
||||
<p className="appt-empty">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
{filteredActive.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
)}
|
||||
{pastAdoptions.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAdoptions((v) => !v)}>
|
||||
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
|
||||
</button>
|
||||
{showPastAdoptions && (
|
||||
<div className="appt-list appt-list--past">
|
||||
{pastAdoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
{a.adoptionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<div className="appt-card-details">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{a.adoptionStatus?.toLowerCase() === "pending" && (
|
||||
<div className="appt-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
disabled={cancellingId === a.adoptionId}
|
||||
onClick={() => handleCancelAdoption(a.adoptionId)}
|
||||
>
|
||||
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -57,6 +57,11 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
|
||||
<p className="cart-payment-total">
|
||||
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
|
||||
</p>
|
||||
<div className="cart-demo-banner">
|
||||
<strong>Demo mode</strong> — no real charge. Use test card:
|
||||
<span className="cart-demo-card">4242 4242 4242 4242</span>
|
||||
· any future date · any 3-digit CVC
|
||||
</div>
|
||||
<PaymentElement />
|
||||
{payError && <p className="cart-error-msg">{payError}</p>}
|
||||
<div className="cart-payment-actions">
|
||||
|
||||
@@ -4,9 +4,69 @@ import dynamic from "next/dynamic";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { createStompClient } from "@/lib/chatSocket";
|
||||
|
||||
const API_BASE = "";
|
||||
const POLL_INTERVAL = 2500;
|
||||
|
||||
function isImageFilename(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
|
||||
}
|
||||
|
||||
function AttachmentPreview({ url, name, token }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const isImage = isImageFilename(name);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url || !token) return;
|
||||
let objectUrl;
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((r) => (r.ok ? r.blob() : null))
|
||||
.then((blob) => {
|
||||
if (blob) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlobUrl(objectUrl);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
||||
}, [url, token]);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
{blobUrl ? (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={name || "attachment"}
|
||||
style={{ maxWidth: "220px", maxHeight: "220px", borderRadius: "8px", cursor: "pointer", display: "block" }}
|
||||
onClick={() => window.open(blobUrl, "_blank")}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: "0.8rem", opacity: 0.7 }}>📎 Loading image…</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "0.4rem" }}>
|
||||
<a
|
||||
href="#"
|
||||
style={{ color: "inherit", fontSize: "0.85rem", opacity: 0.85 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!blobUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = blobUrl;
|
||||
a.download = name || "attachment";
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
📎 {name || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPage() {
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
@@ -22,15 +82,18 @@ function ChatPage() {
|
||||
const [error, setError] = useState(null);
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [convsLoading, setConvsLoading] = useState(false);
|
||||
const [closedExpanded, setClosedExpanded] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [switchingConv, setSwitchingConv] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesAreaRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const pollRef = useRef(null);
|
||||
const stompRef = useRef(null);
|
||||
const lastMessageIdRef = useRef(null);
|
||||
const fileInputRef = useRef(null);
|
||||
const lastScrolledIdRef = useRef(null);
|
||||
const initialLoadDoneRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -42,17 +105,17 @@ function ChatPage() {
|
||||
if (messages.length === 0) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg.id === lastScrolledIdRef.current) return;
|
||||
if (!initialLoadDoneRef.current) return;
|
||||
lastScrolledIdRef.current = lastMsg.id;
|
||||
const area = messagesAreaRef.current;
|
||||
if (!area) return;
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 80;
|
||||
if (nearBottom) {
|
||||
area.scrollTop = area.scrollHeight;
|
||||
}
|
||||
const nearBottom = area.scrollHeight - area.scrollTop - area.clientHeight < 150;
|
||||
if (nearBottom) area.scrollTop = area.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = useCallback(async (convId) => {
|
||||
if (!token || !convId) return;
|
||||
initialLoadDoneRef.current = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -67,10 +130,16 @@ function ChatPage() {
|
||||
|
||||
if (data.length > 0) {
|
||||
lastMessageIdRef.current = data[data.length - 1].id;
|
||||
lastScrolledIdRef.current = data[data.length - 1].id;
|
||||
}
|
||||
setTimeout(() => {
|
||||
const area = messagesAreaRef.current;
|
||||
if (area) area.scrollTop = area.scrollHeight;
|
||||
initialLoadDoneRef.current = true;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
catch {
|
||||
setError("Failed to load messages.");
|
||||
}
|
||||
@@ -114,40 +183,34 @@ function ChatPage() {
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const startPolling = useCallback((convId) => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
if (!token || !convId) return;
|
||||
try {
|
||||
const [msgsRes, convRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
if (msgsRes.ok) {
|
||||
const data = await msgsRes.json();
|
||||
if (Array.isArray(data)) {
|
||||
const lastId = data.length > 0 ? data[data.length - 1].id : null;
|
||||
if (lastId !== lastMessageIdRef.current) {
|
||||
lastMessageIdRef.current = lastId;
|
||||
setMessages(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (convRes.ok) {
|
||||
const convData = await convRes.json();
|
||||
setConversation(convData);
|
||||
}
|
||||
}
|
||||
|
||||
catch {
|
||||
//Silent
|
||||
}
|
||||
}, POLL_INTERVAL);
|
||||
}, [token]);
|
||||
const connectStomp = useCallback((convId) => {
|
||||
if (stompRef.current) {
|
||||
stompRef.current.deactivate();
|
||||
stompRef.current = null;
|
||||
}
|
||||
const client = createStompClient(token);
|
||||
client.onConnect = () => {
|
||||
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
|
||||
try {
|
||||
const msg = JSON.parse(frame.body);
|
||||
setMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
|
||||
lastMessageIdRef.current = msg.id;
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
const convTopic = user?.role === "CUSTOMER"
|
||||
? `/user/queue/chat/conversations`
|
||||
: `/topic/chat/conversations`;
|
||||
client.subscribe(convTopic, (frame) => {
|
||||
try {
|
||||
const conv = JSON.parse(frame.body);
|
||||
if (conv.id === convId) setConversation(conv);
|
||||
setConversations((prev) => prev.map((c) => c.id === conv.id ? conv : c));
|
||||
} catch { /* silent */ }
|
||||
});
|
||||
};
|
||||
stompRef.current = client;
|
||||
client.activate();
|
||||
}, [token, user?.role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || authLoading) return;
|
||||
@@ -194,16 +257,16 @@ function ChatPage() {
|
||||
]);
|
||||
if (stale) return;
|
||||
setLoadingConv(false);
|
||||
startPolling(convId);
|
||||
connectStomp(convId);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
stale = true;
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
};
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, startPolling, fetchConversations]);
|
||||
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
|
||||
|
||||
async function handleSend(e) {
|
||||
e?.preventDefault();
|
||||
@@ -347,7 +410,7 @@ function ChatPage() {
|
||||
setConversation(conv);
|
||||
await Promise.all([fetchMessages(conv.id), fetchConversations()]);
|
||||
setLoadingConv(false);
|
||||
startPolling(conv.id);
|
||||
connectStomp(conv.id);
|
||||
router.replace(`/chat?id=${conv.id}`, { scroll: false });
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -356,7 +419,7 @@ function ChatPage() {
|
||||
}
|
||||
|
||||
async function switchConversation(convId) {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
router.replace(`/chat?id=${convId}`, { scroll: false });
|
||||
@@ -426,7 +489,7 @@ function ChatPage() {
|
||||
<p style={s.sidebarEmpty}>No conversations yet.</p>
|
||||
)}
|
||||
<div style={{ overflowY: "auto", flex: 1 }}>
|
||||
{conversations.map((conv) => (
|
||||
{conversations.filter(c => c.status !== "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
@@ -434,9 +497,7 @@ function ChatPage() {
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...(conv.status === "OPEN" ? s.convStatusOpen : s.convStatusClosed) }}>
|
||||
{conv.status}
|
||||
</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusOpen }}>{conv.status}</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
@@ -445,6 +506,30 @@ function ChatPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{conversations.some(c => c.status === "CLOSED") && (
|
||||
<>
|
||||
<button style={s.closedSectionToggle} onClick={() => setClosedExpanded(p => !p)}>
|
||||
<span>Closed ({conversations.filter(c => c.status === "CLOSED").length})</span>
|
||||
<span>{closedExpanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{closedExpanded && conversations.filter(c => c.status === "CLOSED").map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
style={{ ...s.convItem, ...s.convItemClosed, ...(conv.id === conversation?.id ? s.convItemActive : {}) }}
|
||||
onClick={() => switchConversation(conv.id)}
|
||||
>
|
||||
<div style={s.convItemTop}>
|
||||
<span style={s.convItemSubject}>{conv.subject || `Conversation #${conv.id}`}</span>
|
||||
<span style={{ ...s.convStatusBadge, ...s.convStatusClosed }}>CLOSED</span>
|
||||
</div>
|
||||
<div style={s.convItemBottom}>
|
||||
<span style={s.convItemMode}>{conv.mode === "HUMAN" ? "👤 Live" : "🤖 AI"}</span>
|
||||
<span style={s.convItemDate}>{conv.createdAt ? new Date(conv.createdAt).toLocaleDateString() : ""}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<button style={s.newConvSidebarBtn} onClick={() => handleNewConversation()}>
|
||||
+ New Conversation
|
||||
</button>
|
||||
@@ -488,13 +573,15 @@ function ChatPage() {
|
||||
Close Chat
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
{!isHuman && (
|
||||
<button
|
||||
style={s.aiBtn}
|
||||
onClick={() => router.push("/ai-chat")}
|
||||
title="Back to AI Assistant"
|
||||
>
|
||||
AI Assistant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,16 +627,7 @@ function ChatPage() {
|
||||
</span>
|
||||
))}
|
||||
{msg.attachmentUrl && (
|
||||
<div style={s.attachment}>
|
||||
<a
|
||||
href={msg.attachmentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={s.attachmentLink}
|
||||
>
|
||||
📎 {msg.attachmentName || "Attachment"}
|
||||
</a>
|
||||
</div>
|
||||
<AttachmentPreview url={msg.attachmentUrl} name={msg.attachmentName} token={token} />
|
||||
)}
|
||||
<div style={{ ...s.timestamp, ...(isOwn ? s.timestampUser : {}) }}>
|
||||
{msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""}
|
||||
@@ -563,6 +641,11 @@ function ChatPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{switchingConv && (
|
||||
<div style={{ textAlign: "center", padding: "1rem", color: "#aaa", fontSize: "0.85rem" }}>
|
||||
Loading messages…
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -1102,6 +1185,21 @@ const s = {
|
||||
},
|
||||
convStatusOpen: { background: "#e6f9ee", color: "#1a7a3c" },
|
||||
convStatusClosed: { background: "#f0f0f0", color: "#888" },
|
||||
convItemClosed: { opacity: 0.7 },
|
||||
closedSectionToggle: {
|
||||
width: "100%",
|
||||
background: "#f5f5f5",
|
||||
border: "none",
|
||||
borderTop: "1px solid #e8e8e8",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
color: "#666",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
newConvSidebarBtn: {
|
||||
margin: "0.65rem 1rem",
|
||||
background: "#333",
|
||||
|
||||
@@ -38,6 +38,10 @@ export default function ContactPage() {
|
||||
|
||||
async function handleSend(e) {
|
||||
e.preventDefault();
|
||||
if (!token) {
|
||||
setSendError("Please log in to send a message.");
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
@@ -50,7 +54,7 @@ export default function ContactPage() {
|
||||
setSendSuccess(true);
|
||||
setSubject("");
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setSendError("Failed to send message. Please try again.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
@@ -61,21 +65,19 @@ export default function ContactPage() {
|
||||
<main className="info-page">
|
||||
<section className="info-hero">
|
||||
<h1 className="info-title">Contact Us</h1>
|
||||
<p className="info-subtitle">Reach the team, find a location, or connect with store personnel.</p>
|
||||
<p className="info-subtitle">Reach the team, find a location, or send us a message.</p>
|
||||
<div className="title-decoration"></div>
|
||||
</section>
|
||||
|
||||
<section className="info-content">
|
||||
<section className="contact-layout">
|
||||
<div className="info-card">
|
||||
<h2>General Contact</h2>
|
||||
<h2>Get in Touch</h2>
|
||||
<p>Email: hello@leonspetstore.com.au</p>
|
||||
<p>Phone: (03) 9000 0000</p>
|
||||
<p>Hours: Mon–Sat, 9:00 AM – 6:00 PM</p>
|
||||
</div>
|
||||
|
||||
{token && (
|
||||
<div className="info-card">
|
||||
<h2>Send Us a Message</h2>
|
||||
<div className="contact-form-section">
|
||||
<h3>Send Us a Message</h3>
|
||||
{sendSuccess ? (
|
||||
<p className="contact-success">Your message has been sent. We'll be in touch soon.</p>
|
||||
) : (
|
||||
@@ -100,7 +102,7 @@ export default function ContactPage() {
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
required
|
||||
maxLength={2000}
|
||||
rows={6}
|
||||
rows={5}
|
||||
/>
|
||||
</label>
|
||||
{sendError && <p className="contact-error">{sendError}</p>}
|
||||
@@ -110,18 +112,14 @@ export default function ContactPage() {
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="info-card">
|
||||
<h2>Store Locations</h2>
|
||||
|
||||
{loading && <p>Loading locations...</p>}
|
||||
|
||||
{error && <p style={{ color: "red" }}>Failed to load locations: {error}</p>}
|
||||
|
||||
{!loading && !error && locations.length === 0 && (
|
||||
<p>No store locations found.</p>
|
||||
)}
|
||||
{!loading && !error && locations.length === 0 && <p>No store locations found.</p>}
|
||||
|
||||
{!loading && !error && locations.length > 0 && (
|
||||
<div className="info-card-grid">
|
||||
|
||||
@@ -154,7 +154,7 @@ body {
|
||||
|
||||
.image-links-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
@@ -685,33 +685,39 @@ body {
|
||||
}
|
||||
|
||||
.info-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
|
||||
}
|
||||
|
||||
.info-hero {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem 3rem;
|
||||
padding: 2.5rem 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 3rem;
|
||||
font-size: 1.6rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.info-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: #666;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
max-width: 520px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem 4rem;
|
||||
padding: 0 2rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -733,6 +739,7 @@ body {
|
||||
padding-left: 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.info-card-grid {
|
||||
@@ -886,11 +893,15 @@ body {
|
||||
.slideshow-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
|
||||
.image-links-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 2rem;
|
||||
@@ -1028,8 +1039,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
justify-self: end;
|
||||
padding-left: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nav-greeting {
|
||||
@@ -1706,6 +1716,49 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.appt-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.appt-search:focus {
|
||||
outline: none;
|
||||
border-color: #e68672;
|
||||
}
|
||||
|
||||
.appt-past-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.appt-past-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.appt-past-toggle:hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.appt-list--past {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.appt-card--past {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
/* Adoption Pet Selection */
|
||||
|
||||
.appt-adopt-grid {
|
||||
@@ -2024,6 +2077,68 @@ body {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.profile-orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.profile-order-card {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.profile-order-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.profile-order-date {
|
||||
font-size: 0.85rem;
|
||||
color: #555;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-order-total {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.profile-order-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.78rem;
|
||||
color: #999;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-order-items {
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
border-top: 1px solid #ececec;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-order-items li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.82rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.profile-order-item-price {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Store Selector */
|
||||
|
||||
.nav-store-select {
|
||||
@@ -2037,6 +2152,7 @@ body {
|
||||
margin-right: 0.5rem;
|
||||
outline: none;
|
||||
transition: background 0.2s ease;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.nav-store-select option {
|
||||
@@ -2639,6 +2755,25 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cart-demo-banner {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
color: #78350f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-demo-card {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
margin: 0 0.25rem;
|
||||
background: #fef3c7;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cart-payment-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2827,7 +2962,10 @@ body {
|
||||
|
||||
.nav-auth {
|
||||
gap: 0.35rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-greeting {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2893,7 +3031,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.nav-links,
|
||||
@@ -3022,13 +3161,20 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-form {
|
||||
.adopt-controls-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.adopt-search-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adopt-search-btn,
|
||||
@@ -3094,6 +3240,10 @@ img, video, iframe {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.contact-layout { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; max-width: 1200px; margin: 0 auto; padding: 0 2rem 3rem; }
|
||||
@media (max-width: 768px) { .contact-layout { grid-template-columns: 1fr; } }
|
||||
.contact-form-section { margin-top: 1.5rem; border-top: 1px solid #f0f0f0; padding-top: 1.5rem; }
|
||||
.contact-form-section h3 { margin: 0 0 1rem; font-size: 1rem; color: #333; }
|
||||
.contact-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.contact-label { display: flex; flex-direction: column; gap: 0.4rem; font-weight: 500; color: #333; font-size: 0.95rem; }
|
||||
.contact-input, .contact-textarea { border: 1px solid #ddd; border-radius: 8px; padding: 0.6rem 0.8rem; font-size: 0.95rem; font-family: inherit; resize: vertical; }
|
||||
@@ -3103,7 +3253,43 @@ img, video, iframe {
|
||||
.contact-submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.contact-error { color: #c0392b; font-size: 0.9rem; }
|
||||
.contact-success { color: #166534; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px; padding: 0.75rem 1rem; }
|
||||
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1.5rem 1rem; }
|
||||
.pagination-btn { background: #333; color: white; border: none; border-radius: 8px; padding: 0.5rem 1.2rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
|
||||
.pagination-btn:disabled { background: #ccc; cursor: not-allowed; }
|
||||
.pagination-controls { display: flex; align-items: center; justify-content: center; gap: 0.4rem; padding: 1.5rem 1rem; flex-wrap: wrap; }
|
||||
.pagination-btn { background: #e8e8e8; color: #333; border: none; border-radius: 8px; padding: 0.5rem 0.9rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||
.pagination-btn:hover:not(:disabled) { background: #d0d0d0; }
|
||||
.pagination-btn:disabled { background: #f0f0f0; color: #aaa; cursor: not-allowed; }
|
||||
.pagination-btn--active { background: #e68672; color: white; }
|
||||
.pagination-btn--active:hover { background: #d4705e; }
|
||||
.pagination-ellipsis { padding: 0.5rem 0.25rem; color: #888; font-weight: 600; }
|
||||
.pagination-info { font-size: 0.9rem; color: #555; font-weight: 500; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.info-subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.info-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.info-subtitle {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.image-links-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.adopt-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.adopt-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export default function Home() {
|
||||
{/* About Us */}
|
||||
<section className="info-page">
|
||||
<div className="info-hero">
|
||||
<h2 className="info-title">About Leon's Pet Store</h2>
|
||||
<p className="info-subtitle">Your trusted local destination for pet care, adoption, and supplies — built on a love for animals and community.</p>
|
||||
<h2 className="info-title">About Us</h2>
|
||||
<p className="info-subtitle">A full-service pet store built on a love for animals and community.</p>
|
||||
<div className="title-decoration"></div>
|
||||
</div>
|
||||
<div className="info-content">
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProfilePage() {
|
||||
|
||||
const [pets, setPets] = useState([]);
|
||||
const [loadingPets, setLoadingPets] = useState(false);
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingPet, setEditingPet] = useState(null);
|
||||
const [petName, setPetName] = useState("");
|
||||
@@ -120,11 +122,27 @@ export default function ProfilePage() {
|
||||
};
|
||||
}, [clearPetImageObjectUrls]);
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingOrders(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/sales/my?size=20&sort=saleDate,desc`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setOrders(data.content ?? []);
|
||||
} catch { } finally {
|
||||
setLoadingOrders(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role === "CUSTOMER" || user?.role === "ADMIN") {
|
||||
loadPets();
|
||||
loadOrders();
|
||||
}
|
||||
}, [user, loadPets]);
|
||||
}, [user, loadPets, loadOrders]);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = null;
|
||||
@@ -642,6 +660,46 @@ export default function ProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(user.role === "CUSTOMER" || user.role === "ADMIN") && (
|
||||
<div className="profile-pets-section">
|
||||
<div className="profile-pets-header">
|
||||
<h2 className="profile-pets-title">Order History</h2>
|
||||
</div>
|
||||
{loadingOrders ? (
|
||||
<p className="appt-loading">Loading orders...</p>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="profile-pets-empty">No orders yet.</p>
|
||||
) : (
|
||||
<div className="profile-orders-list">
|
||||
{orders.map((order) => (
|
||||
<div key={order.saleId} className="profile-order-card">
|
||||
<div className="profile-order-header">
|
||||
<span className="profile-order-date">
|
||||
{new Date(order.saleDate).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<span className="profile-order-total">${Number(order.totalAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="profile-order-meta">
|
||||
<span>{order.storeName}</span>
|
||||
{order.paymentMethod && <span>{order.paymentMethod}</span>}
|
||||
</div>
|
||||
{order.items?.length > 0 && (
|
||||
<ul className="profile-order-items">
|
||||
{order.items.map((item) => (
|
||||
<li key={item.saleItemId}>
|
||||
<span>{item.productName} × {item.quantity}</span>
|
||||
<span className="profile-order-item-price">${(Number(item.unitPrice) * item.quantity).toFixed(2)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user