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

2
web/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.next

View File

@@ -1,4 +1,5 @@
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
# Backend URL for the API proxy — swap comments to switch between local and remote
BACKEND_URL=http://localhost:8080
#BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io

View File

@@ -3,8 +3,8 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51TK18lFQ95OLlFb7dNtKXlvhry8IOvHaWJWW7zUNFhicMgyJ2EgAFhiAocxsCyP95IKt7AeQg4cWe5iHF3qoheYl0034Cd4yij
ENV NEXT_PUBLIC_BACKEND_URL=https://petshop-backend.nicepond-c7280126.westus2.azurecontainerapps.io
RUN npm run build
FROM node:22-alpine

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

View File

@@ -1,6 +1,7 @@
"use client";
import { createContext, useContext, useState, useRef, useCallback, useEffect } from "react";
import { createStompClient } from "@/lib/chatSocket";
const ChatWidgetContext = createContext(null);
const API_BASE = "";
@@ -60,24 +61,30 @@ export function ChatWidgetProvider({ children }) {
const [liveSending, setLiveSending] = useState(false);
const [switchingToHuman, setSwitchingToHuman] = useState(false);
const pollRef = useRef(null);
const stompRef = useRef(null);
const activeConvIdRef = useRef(null);
const tokenRef = useRef(null); // FIX: store token so polling can restart
const tokenRef = useRef(null);
const stopPolling = useCallback(() => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
const disconnectStomp = useCallback(() => {
if (stompRef.current) {
stompRef.current.deactivate();
stompRef.current = null;
}
}, []);
const fetchLiveMessages = useCallback(async (convId, token) => {
if (!convId || !token) return;
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data)) setLiveMessages(data);
} catch { /* silent */ }
const subscribeToConversation = useCallback((client, convId) => {
client.subscribe(`/topic/chat/conversations/${convId}`, (frame) => {
try {
const msg = JSON.parse(frame.body);
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
} catch { /* silent */ }
});
client.subscribe(`/user/queue/chat/conversations`, (frame) => {
try {
const conv = JSON.parse(frame.body);
if (conv.id === convId) setActiveConv(conv);
} catch { /* silent */ }
});
}, []);
const loadConversations = useCallback(async (token) => {
@@ -97,21 +104,35 @@ export function ChatWidgetProvider({ children }) {
const openLiveConversation = useCallback(async (convId, token) => {
if (!convId || !token) return;
stopPolling();
tokenRef.current = token; // FIX: save token for polling restart
disconnectStomp();
tokenRef.current = token;
setActiveConvId(convId);
activeConvIdRef.current = convId;
setLiveMessages([]);
setView("live");
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) setActiveConv(await res.json());
} catch { /* silent */ }
await fetchLiveMessages(convId, token);
pollRef.current = setInterval(() => fetchLiveMessages(convId, token), 2500);
}, [stopPolling, fetchLiveMessages]);
try {
const res = await fetch(`${API_BASE}/api/v1/chat/conversations/${convId}/messages`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) setLiveMessages(data);
}
} catch { /* silent */ }
const client = createStompClient(token);
client.onConnect = () => subscribeToConversation(client, convId);
stompRef.current = client;
client.activate();
}, [disconnectStomp, subscribeToConversation]);
const sendLiveMessage = useCallback(async (text, token, convId) => {
if (!text.trim() || liveSending || !token || !convId) return;
@@ -122,11 +143,14 @@ export function ChatWidgetProvider({ children }) {
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
body: JSON.stringify({ content: text }),
});
if (res.ok) await fetchLiveMessages(convId, token);
if (res.ok) {
const msg = await res.json();
setLiveMessages((prev) => prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]);
}
} catch { /* silent */ } finally {
setLiveSending(false);
}
}, [liveSending, fetchLiveMessages]);
}, [liveSending]);
const startLiveChat = useCallback(async (token) => {
if (!token || switchingToHuman) return;
@@ -149,19 +173,22 @@ export function ChatWidgetProvider({ children }) {
}
}, [switchingToHuman, openLiveConversation]);
// FIX: Single effect that handles both stopping AND restarting polling
useEffect(() => {
if (!isOpen || view !== "live") {
stopPolling();
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current) {
stopPolling();
fetchLiveMessages(activeConvIdRef.current, tokenRef.current);
pollRef.current = setInterval(
() => fetchLiveMessages(activeConvIdRef.current, tokenRef.current),
2500
);
disconnectStomp();
} else if (isOpen && view === "live" && activeConvIdRef.current && tokenRef.current && !stompRef.current) {
const convId = activeConvIdRef.current;
const token = tokenRef.current;
const client = createStompClient(token);
client.onConnect = () => subscribeToConversation(client, convId);
stompRef.current = client;
client.activate();
}
}, [isOpen, view, stopPolling, fetchLiveMessages]);
}, [isOpen, view, disconnectStomp, subscribeToConversation]);
useEffect(() => {
return () => disconnectStomp();
}, [disconnectStomp]);
const toggleOpen = useCallback(() => setIsOpen((o) => !o), []);
const openView = useCallback((v) => setView(v), []);
@@ -185,4 +212,4 @@ export function useChatWidget() {
const ctx = useContext(ChatWidgetContext);
if (!ctx) throw new Error("useChatWidget must be used within ChatWidgetProvider");
return ctx;
}
}

16
web/lib/chatSocket.js Normal file
View File

@@ -0,0 +1,16 @@
import { Client } from "@stomp/stompjs";
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "";
export function createStompClient(token) {
return new Client({
webSocketFactory: () => {
const SockJS = require("sockjs-client");
return new SockJS(`${BACKEND_URL}/ws/chat-sockjs`);
},
connectHeaders: { Authorization: `Bearer ${token}` },
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
});
}

137
web/package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "threaded-pets",
"version": "0.1.0",
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1232,6 +1234,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stomp/stompjs": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz",
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
"license": "Apache-2.0"
},
"node_modules/@stripe/react-stripe-js": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.10.0.tgz",
@@ -3474,6 +3482,15 @@
"node": ">=0.10.0"
}
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3535,6 +3552,18 @@
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
"license": "Apache-2.0",
"dependencies": {
"websocket-driver": ">=0.5.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3920,6 +3949,12 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/http-parser-js": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3957,6 +3992,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4932,7 +4973,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5402,6 +5442,12 @@
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5494,6 +5540,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -5590,6 +5642,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -5847,6 +5919,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sockjs-client": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
"integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.7",
"eventsource": "^2.0.2",
"faye-websocket": "^0.11.4",
"inherits": "^2.0.4",
"url-parse": "^1.5.10"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://tidelift.com/funding/github/npm/sockjs-client"
}
},
"node_modules/sockjs-client/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6414,6 +6514,39 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",
"safe-buffer": ">=5.1.0",
"websocket-extensions": ">=0.1.1"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/websocket-extensions": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -9,11 +9,13 @@
"lint": "eslint"
},
"dependencies": {
"@stomp/stompjs": "^7.3.0",
"@stripe/react-stripe-js": "^3.1.1",
"@stripe/stripe-js": "^5.5.0",
"next": "^16.2.2",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"sockjs-client": "^1.6.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",