merge main into branch

This commit is contained in:
2026-04-16 08:12:46 -06:00
49 changed files with 2187 additions and 400 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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">

View File

@@ -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",

View File

@@ -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: MonSat, 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&apos;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">

View File

@@ -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;
}
}

View File

@@ -79,8 +79,8 @@ export default function Home() {
{/* About Us */}
<section className="info-page">
<div className="info-hero">
<h2 className="info-title">About Leon&apos;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">

View File

@@ -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>
);
}