Comments, appointments adjustments, fixed some issues

This commit is contained in:
augmentedpotato
2026-04-20 19:19:30 -06:00
parent d3b9c51952
commit 2cb0a94bbb
34 changed files with 402 additions and 104 deletions

View File

@@ -1,3 +1,5 @@
//About page
//Three info cards covering what the store does, its focus, and how to visit
export default function AboutPage() {
return (
<main className="bg-gradient-to-b from-[#f9f9f9] to-white">

View File

@@ -7,6 +7,8 @@ import PetProfile from "@/components/PetProfile";
const API_BASE = "";
//Pet detail page
//Fetches a single pet by ID and passes it to PetProfile
export default function PetDetailPage() {
const { id } = useParams();
const [pet, setPet] = useState(null);

View File

@@ -5,6 +5,8 @@ import PetCard from "@/components/PetCard";
import { fetchAllPages } from "@/lib/fetchAllPages";
import { useCart } from "@/context/CartContext";
//Adopt page
//Browse available pets with species/breed filters and search
const API_BASE = "";
const PAGE_SIZE = 10000;
@@ -12,6 +14,8 @@ const inputCls = "px-4 py-[0.6rem] border-2 border-[#ddd] rounded-md text-base o
const btnPrimaryCls = "px-[1.4rem] py-[0.6rem] bg-[#e68672] text-white border-none rounded-md text-base cursor-pointer transition-colors hover:bg-[#d4705e]";
const btnOutlineCls = "px-4 py-[0.6rem] bg-transparent text-[#666] border-2 border-[#ddd] rounded-md text-base cursor-pointer transition-all hover:border-[#aaa] hover:text-[#333]";
//Pagination button
//Highlighted when it represents the current page
function PaginationBtn({ children, active, ...props }) {
return (
<button
@@ -23,6 +27,7 @@ function PaginationBtn({ children, active, ...props }) {
);
}
//Main adopt page component
export default function AdoptPage() {
const { selectedStoreId } = useCart();
@@ -35,6 +40,7 @@ export default function AdoptPage() {
const [selectedBreed, setSelectedBreed] = useState("");
const [speciesOptions, setSpeciesOptions] = useState([]);
//Loads the species list from the first page of pets whenever the store changes
useEffect(() => {
setSelectedSpecies("");
const params = new URLSearchParams({ page: "0", size: String(PAGE_SIZE) });
@@ -68,6 +74,7 @@ export default function AdoptPage() {
.finally(() => setLoading(false));
}, [query, selectedSpecies, selectedStoreId]);
//Builds the breed dropdown from the currently loaded pets
const breedOptions = useMemo(
() => [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" })
@@ -84,7 +91,10 @@ export default function AdoptPage() {
const totalPages = Math.ceil(filteredPets.length / ITEMS_PER_PAGE);
const displayedPets = filteredPets.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
//Submits search form
function handleSearch(e) { e.preventDefault(); setCurrentPage(0); setQuery(search.trim()); }
//Resets all active filters and search text
function handleClearFilters() { setSearch(""); setQuery(""); setSelectedSpecies(""); setSelectedBreed(""); setCurrentPage(0); }
const hasActiveFilters = query || selectedSpecies || selectedBreed;

View File

@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
const API_BASE = "";
//Checks if a filename looks like an image based on its extension
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
//Shows an image inline or a download link for other file types
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
@@ -68,6 +70,7 @@ function AttachmentPreview({ url, name, token }) {
);
}
//Returns true when the screen width is below 640px
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
@@ -79,6 +82,7 @@ function useIsMobile() {
return isMobile;
}
//AI chat page with a conversation sidebar, supports switching to a human agent and sending file attachments
function AiChatPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
@@ -137,6 +141,7 @@ function AiChatPage() {
if (nearBottom) area.scrollTop = area.scrollHeight;
}, [botTyping]);
//Loads all messages for a conversation and scrolls to the bottom
const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return;
initialLoadDoneRef.current = false;
@@ -162,6 +167,7 @@ function AiChatPage() {
}
}, [token]);
//Fetches a single conversation's details and stores it
const fetchConversation = useCallback(async (convId) => {
if (!token || !convId) return null;
try {
@@ -194,6 +200,7 @@ function AiChatPage() {
}
}, [token]);
//Connects the WebSocket and subscribes to new messages and conversation updates
const connectStomp = useCallback((convId) => {
if (stompRef.current) {
stompRef.current.deactivate();
@@ -301,6 +308,7 @@ function AiChatPage() {
};
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations, router]);
//Decides whether to send a text message or an attachment
async function handleSend(e) {
e?.preventDefault();
const text = input.trim();
@@ -312,6 +320,7 @@ function AiChatPage() {
}
}
//Sends a plain text message and shows a bot typing indicator
async function handleSendText(text) {
setInput("");
setSending(true);
@@ -356,6 +365,7 @@ function AiChatPage() {
}
}
//Uploads a file as an attachment, with an optional caption
async function handleSendAttachment(optionalText) {
setSending(true);
setError(null);
@@ -416,6 +426,7 @@ function AiChatPage() {
}
}
//Creates a new AI conversation and navigates to it
async function handleNewConversation() {
if (stompRef.current) { stompRef.current.deactivate(); stompRef.current = null; }
setError(null);
@@ -455,6 +466,7 @@ function AiChatPage() {
router.replace(`/ai-chat?id=${convId}`, { scroll: false });
}
//Requests a human agent for the current conversation
async function handleSwitchToHuman() {
if (!conversation) return;
try {
@@ -468,6 +480,7 @@ function AiChatPage() {
}
}
//Closes the current conversation so no more messages can be sent
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {

View File

@@ -1,5 +1,7 @@
//Backend URL, falls back to localhost for local development
const BACKEND = process.env.BACKEND_URL || 'http://localhost:8080'
//Forwards all incoming requests to the backend, preserving method, headers, and body
async function proxy(request, { params }) {
const path = (await params).path.join('/')
const { search } = new URL(request.url)
@@ -10,6 +12,8 @@ async function proxy(request, { params }) {
headers.delete('origin')
const init = { method: request.method, headers }
//Only attach a body for requests that can have one
if (!['GET', 'HEAD'].includes(request.method)) {
init.body = request.body
init.duplex = 'half'

View File

@@ -20,13 +20,31 @@ const SPECIES_BREEDS = {
Other: ["Other"],
};
//Services that only apply to specific species, keyed by species name
const SPECIES_EXCLUSIVE_SERVICES = {
Bird: ["wing clipping", "beak and nail"],
Fish: ["aquarium health"],
};
//Services that are banned for specific species, keyed by species name
const SPECIES_BANNED_SERVICES = {
Bird: ["teeth cleaning"],
};
//Filters out services that are exclusive to a different species, or banned for the selected species.
//When species is unknown, hides all species-exclusive and banned services to avoid invalid options appearing.
function getAvailableServices(services, species) {
if (!species) return services;
const exclusiveKeywords = Object.values(SPECIES_EXCLUSIVE_SERVICES).flat();
const allBannedKeywords = Object.values(SPECIES_BANNED_SERVICES).flat();
if (!species) {
return services.filter((s) => {
const name = s.serviceName.toLowerCase();
return !exclusiveKeywords.some((kw) => name.includes(kw)) &&
!allBannedKeywords.some((kw) => name.includes(kw));
});
}
return services.filter((s) => {
const name = s.serviceName.toLowerCase();
for (const [exclusiveSpecies, keywords] of Object.entries(SPECIES_EXCLUSIVE_SERVICES)) {
@@ -34,6 +52,10 @@ function getAvailableServices(services, species) {
return false;
}
}
const banned = SPECIES_BANNED_SERVICES[species] ?? [];
if (banned.some((kw) => name.includes(kw))) {
return false;
}
return true;
});
}
@@ -41,6 +63,7 @@ function getAvailableServices(services, species) {
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
//Custom calendar date picker that prevents selecting dates in the past
function DatePicker({ value, minDate, onChange }) {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -137,6 +160,7 @@ const errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]";
const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Modal dialog for quickly adding a new pet without leaving the appointments page
function AddPetModal({ token, onClose, onAdded }) {
const [petName, setPetName] = useState("");
const [species, setSpecies] = useState("");
@@ -219,6 +243,7 @@ function AddPetModal({ token, onClose, onAdded }) {
);
}
//Appointments page - book a service or adoption, and view past and active appointments
function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
@@ -279,6 +304,18 @@ function AppointmentsPage() {
const [showPastAppts, setShowPastAppts] = useState(false);
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
//Pagination state for each of the four history lists
const HISTORY_PAGE_SIZE = 5;
const [apptPage, setApptPage] = useState(0);
const [pastApptPage, setPastApptPage] = useState(0);
const [adoptionPage, setAdoptionPage] = useState(0);
const [pastAdoptionPage, setPastAdoptionPage] = useState(0);
//Reset appointment page to 0 when the search text changes
useEffect(() => { setApptPage(0); }, [apptSearch]);
//Reset adoption page to 0 when the search text changes
useEffect(() => { setAdoptionPage(0); }, [adoptionSearch]);
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
useEffect(() => {
@@ -309,6 +346,7 @@ function AppointmentsPage() {
.finally(() => setAdoptionVerifyLoading(false));
}, [adoptionMode, adoptionPetId, adoptionStoreId]);
//Loads the user's registered pets for the pet selector
const loadCustomerPets = useCallback(() => {
if (!token || !canBookAppointments) return;
fetch(`${API_BASE}/api/v1/my-pets`, {
@@ -369,6 +407,7 @@ function AppointmentsPage() {
didPreselectRef.current = true;
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
//Fetches the user's booked appointments
const loadAppointments = useCallback(() => {
if (!token) return;
setLoadingAppointments(true);
@@ -381,6 +420,7 @@ function AppointmentsPage() {
.finally(() => setLoadingAppointments(false));
}, [token]);
//Fetches the user's adoption requests
const loadAdoptions = useCallback(() => {
if (!token) return;
setLoadingAdoptions(true);
@@ -398,6 +438,7 @@ function AppointmentsPage() {
loadAdoptions();
}, [loadAppointments, loadAdoptions]);
//Cancels an appointment after asking the user to confirm
async function handleCancelAppointment(appointmentId) {
if (!confirm("Cancel this appointment?")) return;
setCancellingId(appointmentId);
@@ -418,6 +459,7 @@ function AppointmentsPage() {
}
}
//Cancels an adoption request after asking the user to confirm
async function handleCancelAdoption(adoptionId) {
if (!confirm("Cancel this adoption request?")) return;
setCancellingId(adoptionId);
@@ -494,6 +536,7 @@ function AppointmentsPage() {
setServiceId(newServiceId);
}
//Selects a pet and clears the chosen service if it is not valid for that species
function handlePetSelect(petId) {
const newPet = eligiblePets.find((p) => p.customerPetId === petId);
setSelectedPetIds([petId]);
@@ -507,6 +550,7 @@ function AppointmentsPage() {
}
}
//Converts a 24-hour time string to 12-hour AM/PM format
function formatTime(timeStr) {
const [h, m] = timeStr.split(":");
const hour = parseInt(h, 10);
@@ -524,6 +568,7 @@ function AppointmentsPage() {
? Boolean(employeeId && appointmentDate && adoptionVerified)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
//Submits either a new appointment or an adoption request depending on the current mode
async function handleSubmit(e) {
e.preventDefault();
setError(null);
@@ -749,7 +794,7 @@ function AppointmentsPage() {
<option value="">Select a service...</option>
{availableServices.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
{s.serviceName} - ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option>
))}
</select>
@@ -834,6 +879,12 @@ function AppointmentsPage() {
const filteredActive = activeAppts.filter((a) =>
!q || [a.serviceName, a.storeName, a.petName].some((v) => v?.toLowerCase().includes(q))
);
//Paginate active appointments
const apptTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const apptSlice = filteredActive.slice(apptPage * HISTORY_PAGE_SIZE, (apptPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past appointments
const pastApptTotalPages = Math.ceil(pastAppts.length / HISTORY_PAGE_SIZE);
const pastApptSlice = pastAppts.slice(pastApptPage * HISTORY_PAGE_SIZE, (pastApptPage + 1) * HISTORY_PAGE_SIZE);
return (
<>
<input
@@ -846,61 +897,107 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
) : (
<div className="flex flex-col gap-3">
{filteredActive.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
)}
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
<>
<div className="flex flex-col gap-3">
{apptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
)}
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.appointmentId}
onClick={() => handleCancelAppointment(a.appointmentId)}
>
{cancellingId === a.appointmentId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div>
{apptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p - 1)}
disabled={apptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {apptPage + 1} of {apptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setApptPage((p) => p + 1)}
disabled={apptPage >= apptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
{pastAppts.length > 0 && (
<div className="mt-2">
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAppts((v) => !v)}>
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAppts((v) => !v); setPastApptPage(0); }}>
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
</button>
{showPastAppts && (
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAppts.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
<>
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastApptSlice.map((a) => (
<div key={a.appointmentId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.serviceName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
{a.appointmentStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
)}
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.storeName}</span>
<span>{a.appointmentDate} at {formatTime(a.appointmentTime)}</span>
</div>
{a.petName && (
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
)}
))}
</div>
{pastApptTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p - 1)}
disabled={pastApptPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastApptPage + 1} of {pastApptTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastApptPage((p) => p + 1)}
disabled={pastApptPage >= pastApptTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}
@@ -918,6 +1015,12 @@ function AppointmentsPage() {
const filteredActive = activeAdoptions.filter((a) =>
!q || [a.petName, a.sourceStoreName].some((v) => v?.toLowerCase().includes(q))
);
//Paginate active adoptions
const adoptionTotalPages = Math.ceil(filteredActive.length / HISTORY_PAGE_SIZE);
const adoptionSlice = filteredActive.slice(adoptionPage * HISTORY_PAGE_SIZE, (adoptionPage + 1) * HISTORY_PAGE_SIZE);
//Paginate past adoptions
const pastAdoptionTotalPages = Math.ceil(pastAdoptions.length / HISTORY_PAGE_SIZE);
const pastAdoptionSlice = pastAdoptions.slice(pastAdoptionPage * HISTORY_PAGE_SIZE, (pastAdoptionPage + 1) * HISTORY_PAGE_SIZE);
return (
<>
<input
@@ -930,55 +1033,101 @@ function AppointmentsPage() {
{filteredActive.length === 0 ? (
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
) : (
<div className="flex flex-col gap-3">
{filteredActive.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
<>
<div className="flex flex-col gap-3">
{adoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] mb-2 flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1.5 rounded-lg border border-[#f5c6c6] bg-[#fff0f0] text-[#c0392b] text-[0.85rem] cursor-pointer hover:bg-[#ffd7d7] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={cancellingId === a.adoptionId}
onClick={() => handleCancelAdoption(a.adoptionId)}
>
{cancellingId === a.adoptionId ? "Cancelling..." : "Cancel"}
</button>
</div>
</div>
))}
</div>
{adoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-1 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p - 1)}
disabled={adoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {adoptionPage + 1} of {adoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setAdoptionPage((p) => p + 1)}
disabled={adoptionPage >= adoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
{pastAdoptions.length > 0 && (
<div className="mt-2">
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => setShowPastAdoptions((v) => !v)}>
<button className="text-[0.85rem] text-[#e68672] cursor-pointer bg-transparent border-none font-semibold hover:underline" onClick={() => { setShowPastAdoptions((v) => !v); setPastAdoptionPage(0); }}>
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
</button>
{showPastAdoptions && (
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAdoptions.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
<>
<div className="flex flex-col gap-3 mt-3 opacity-75">
{pastAdoptionSlice.map((a) => (
<div key={a.adoptionId} className="bg-[#f9f9f9] rounded-xl p-4 border border-[#eee]">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-[#333]">{a.petName}</span>
<span className={`text-xs font-semibold rounded-full px-2.5 py-1 appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
{a.adoptionStatus}
</span>
</div>
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</div>
</div>
))}
</div>
{pastAdoptionTotalPages > 1 && (
<div className="flex items-center justify-between gap-2 mt-2 flex-wrap">
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p - 1)}
disabled={pastAdoptionPage === 0}
type="button"
>
&larr; Prev
</button>
<span className="text-[0.82rem] text-[#888]">Page {pastAdoptionPage + 1} of {pastAdoptionTotalPages}</span>
<button
className="px-3 py-1.5 rounded-lg border border-[#ddd] bg-white text-[0.85rem] cursor-pointer hover:border-[#e68672] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
onClick={() => setPastAdoptionPage((p) => p + 1)}
disabled={pastAdoptionPage >= pastAdoptionTotalPages - 1}
type="button"
>
Next &rarr;
</button>
</div>
))}
</div>
)}
</>
)}
</div>
)}

View File

@@ -13,8 +13,10 @@ import {
} from "@stripe/react-stripe-js";
import { apiCompleteCheckout } from "@/lib/cartApi";
//Initializes Stripe with the publishable key
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "");
//Stripe payment form shown after the user clicks checkout
function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
const stripe = useStripe();
const elements = useElements();
@@ -22,6 +24,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
const [paying, setPaying] = useState(false);
const [payError, setPayError] = useState(null);
//Confirms the payment with Stripe, then tells the backend to complete the order
async function handlePay(e) {
e.preventDefault();
if (!stripe || !elements) return;
@@ -57,9 +60,6 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
<p className="text-[0.95rem] text-[#555] m-0">
Total to pay: <strong>${parseFloat(totalAmount).toFixed(2)}</strong>
</p>
<div className="bg-[#fffbeb] border border-[#fde68a] rounded-lg px-4 py-3 text-[0.82rem] text-[#854d0e] flex flex-col gap-1">
<div><strong>Demo mode</strong> no real charge. Use test card: <span className="font-mono font-bold">4242 4242 4242 4242</span> · any future date · any 3-digit CVC</div>
</div>
<PaymentElement />
{payError && <p className="bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem] m-0">{payError}</p>}
<div className="flex gap-3 mt-2">
@@ -79,6 +79,7 @@ function PaymentForm({ clientSecret, totalAmount, onSuccess, onCancel }) {
);
}
//Cart page - shows items, coupons, loyalty points, order summary, and checkout
export default function CartPage() {
const { user, loading: authLoading, refreshUser } = useAuth();
const {
@@ -114,12 +115,14 @@ export default function CartPage() {
const [localQuantities, setLocalQuantities] = useState({});
//Redirect unauthenticated users to login
useEffect(() => {
if (!authLoading && !user) {
router.push("/login");
}
}, [authLoading, user, router]);
//Sync local quantity inputs whenever the cart updates from the server
useEffect(() => {
if (cart?.items) {
const map = {};
@@ -129,12 +132,14 @@ export default function CartPage() {
setOptimisticPointsApplied(null);
}, [cart]);
//Cancel any leftover pending checkout if the page loads without a client secret
useEffect(() => {
if (cart?.checkoutPending && !clientSecret) {
cancelCheckout().catch(() => {});
}
}, [cart?.checkoutPending, clientSecret, cancelCheckout]);
//Updates item quantity and rolls back the change if the request fails
async function handleQuantityChange(cartItemId, newQty) {
if (newQty < 1) return;
setLocalQuantities((prev) => ({ ...prev, [cartItemId]: newQty }));
@@ -158,6 +163,7 @@ export default function CartPage() {
catch {}
}
//Applies the typed coupon code and shows the discount type and amount
async function handleApplyCoupon() {
if (!couponInput.trim()) return;
setCouponLoading(true);
@@ -212,6 +218,8 @@ export default function CartPage() {
}
}
//Starts checkout
//Either gets a Stripe client secret for payment or marks the order complete directly
async function handleCheckout() {
if (!cart?.items?.length) return;
setCheckoutLoading(true);
@@ -497,7 +505,7 @@ export default function CartPage() {
)}
{clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<Elements stripe={stripePromise} options={{ clientSecret, wallets: { link: "never" } }}>
<PaymentForm
clientSecret={clientSecret}
totalAmount={checkoutTotal ?? cart.totalAmount}

View File

@@ -8,10 +8,12 @@ import { createStompClient } from "@/lib/chatSocket";
const API_BASE = "";
//Checks if a filename looks like an image based on its extension
function isImageFilename(name) {
return /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name || "");
}
//Shows an image inline or a download link for other file types
function AttachmentPreview({ url, name, token }) {
const [blobUrl, setBlobUrl] = useState(null);
const isImage = isImageFilename(name);
@@ -68,6 +70,7 @@ function AttachmentPreview({ url, name, token }) {
);
}
//Live support chat page with a conversation sidebar and real-time messaging via WebSocket
function ChatPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
@@ -113,6 +116,7 @@ function ChatPage() {
if (nearBottom) area.scrollTop = area.scrollHeight;
}, [messages]);
//Loads all messages for a conversation and scrolls to the bottom
const fetchMessages = useCallback(async (convId) => {
if (!token || !convId) return;
initialLoadDoneRef.current = false;
@@ -145,6 +149,7 @@ function ChatPage() {
}
}, [token]);
//Fetches a single conversation's details and stores it in state
const fetchConversation = useCallback(async (convId) => {
if (!token || !convId) return null;
try {
@@ -183,6 +188,7 @@ function ChatPage() {
}
}, [token]);
//Connects the WebSocket and subscribes to new messages and conversation updates
const connectStomp = useCallback((convId) => {
if (stompRef.current) {
stompRef.current.deactivate();
@@ -268,6 +274,7 @@ function ChatPage() {
};
}, [token, authLoading, conversationIdParam, fetchConversation, fetchMessages, connectStomp, fetchConversations]);
//Decides whether to send a text message or an attachment
async function handleSend(e) {
e?.preventDefault();
const text = input.trim();
@@ -279,6 +286,7 @@ function ChatPage() {
}
}
//Sends a plain text message to the support agent
async function handleSendText(text) {
setInput("");
setSending(true);
@@ -321,6 +329,7 @@ function ChatPage() {
}
}
//Uploads a file as an attachment, with an optional caption
async function handleSendAttachment(optionalText) {
setSending(true);
setError(null);
@@ -381,6 +390,7 @@ function ChatPage() {
}
}
//Creates a new live support conversation and requests a human agent straight away
async function handleNewConversation() {
setError(null);
setLoadingConv(true);
@@ -425,6 +435,7 @@ function ChatPage() {
router.replace(`/chat?id=${convId}`, { scroll: false });
}
//Closes the current conversation so no more messages can be sent
async function handleCloseConversation() {
if (!conversation || conversation.status === "CLOSED") return;
try {

View File

@@ -7,6 +7,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
const inputCls = "px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const submitBtnCls = "mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Returns the image path for a store, guessing from the store name if no image is set
function getStoreImage(store) {
if (store.imageUrl) return store.imageUrl;
const name = store.storeName?.toLowerCase() ?? "";
@@ -16,6 +17,7 @@ function getStoreImage(store) {
return "/images/pet-placeholder.png";
}
//Contact page with a message form on the left and store location cards on the right
export default function ContactPage() {
const { token } = useAuth();
const [locations, setLocations] = useState([]);
@@ -28,6 +30,7 @@ export default function ContactPage() {
const [sendError, setSendError] = useState(null);
const [sendSuccess, setSendSuccess] = useState(false);
//Loads all store locations when the page first opens
useEffect(() => {
const params = new URLSearchParams({ page: "0", size: "100", sort: "storeName,asc" });
fetch(`/api/v1/stores?${params}`)
@@ -40,6 +43,7 @@ export default function ContactPage() {
.finally(() => setLoading(false));
}, []);
//Submits the contact form to the backend
async function handleSend(e) {
e.preventDefault();
if (!token) {

View File

@@ -8,6 +8,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
const inputCls = "px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const submitBtnCls = "mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Forgot password page, sends a reset link to the user's email
function ForgotPasswordPage() {
const [usernameOrEmail, setUsernameOrEmail] = useState("");
const [message, setMessage] = useState("");
@@ -15,6 +16,7 @@ function ForgotPasswordPage() {
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
//Sends the forgot password request and hides the form on success
async function handleSubmit(e) {
e.preventDefault();
setError("");

View File

@@ -4,11 +4,14 @@ import DisplayNav from "@/components/Navigation";
import Footer from "@/components/Footer";
import ClientProviders from "@/components/ClientProviders";
//Page title and description
export const metadata = {
title: "Leon's Pet Store",
description: "Generated by create next app",
};
//Root layout
//Wraps every page with the nav, footer, and all context providers
export default function RootLayout({children}) {
return (
<html lang="en">

View File

@@ -10,12 +10,15 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
const inputCls = "px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const submitBtnCls = "mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Returns the redirect path after login, blocking open redirects to external or auth pages
function resolveNextPath(candidate) {
if (!candidate || !candidate.startsWith("/")) return "/";
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) return "/";
return candidate;
}
//Login page
//Username and password form, redirects to the page the user was trying to visit
function LoginPage() {
const {login} = useAuth();
const router = useRouter();
@@ -26,6 +29,7 @@ function LoginPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
//Submits the login form and redirects on success
async function handleSubmit(e) {
e.preventDefault();
setError("");

View File

@@ -4,6 +4,7 @@ import Image from "next/image";
import Link from "next/link";
import { useState, useEffect } from "react";
//Images used in the auto-scrolling slideshow at the top of the home page
const slideshowImages = [
{id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
{id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"},
@@ -11,15 +12,18 @@ const slideshowImages = [
{id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
];
//Image cards linking to the three main sections of the site
const navImages = [
{id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"},
{id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"},
{id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"},
];
//Home page - slideshow, nav image links, and about us section
export default function Home() {
const [currentSlide, setCurrentSlide] = useState(0);
//Advances the slideshow every 7.5 seconds
useEffect(() => {
const timer = setInterval(() => { setCurrentSlide((prev) => (prev + 1) % slideshowImages.length); }, 7500);
return () => clearInterval(timer);

View File

@@ -7,6 +7,7 @@ import ProductProfile from "@/components/ProductProfile";
const API_BASE = "";
//Product detail page, fetches a single product by ID and passes it to ProductProfile
export default function ProductDetailPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);

View File

@@ -4,6 +4,8 @@ import { useState, useEffect } from "react";
import ProductCard from "@/components/ProductCard";
import { fetchAllPages } from "@/lib/fetchAllPages";
//Products page
//Searchable grid of all store products with pagination
const API_BASE = "";
export default function ProductsPage() {
@@ -34,6 +36,7 @@ export default function ProductsPage() {
const totalPages = Math.ceil(products.length / ITEMS_PER_PAGE);
const displayedProducts = products.slice(currentPage * ITEMS_PER_PAGE, (currentPage + 1) * ITEMS_PER_PAGE);
//Submits the search form
function handleSearch(e) {
e.preventDefault();
setLoading(true);

View File

@@ -6,6 +6,7 @@ import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
//Species and breed options for the add/edit pet form
const SPECIES_BREEDS = {
Dog: ["Beagle", "Boxer", "Bulldog", "Chihuahua", "Dachshund", "German Shepherd", "Golden Retriever", "Labrador Retriever", "Poodle", "Rottweiler", "Shih Tzu", "Siberian Husky", "Yorkshire Terrier", "Mixed / Other"],
Cat: ["Abyssinian", "Bengal", "British Shorthair", "Maine Coon", "Persian", "Ragdoll", "Scottish Fold", "Siamese", "Sphynx", "Mixed / Other"],
@@ -19,12 +20,13 @@ const SPECIES_BREEDS = {
};
const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[#444]";
const inputCls = "px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const inputCls = "px-[0.85rem] py-[0.6rem] bg-white border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const selectCls = `custom-select ${inputCls} bg-white cursor-pointer`;
const errorCls = "bg-[#fff0f0] border border-[#f5c6c6] text-[#c0392b] rounded-lg px-4 py-3 text-[0.9rem]";
const successCls = "bg-[#f0fdf4] border border-[#bbf7d0] text-[#16a34a] rounded-lg px-4 py-3 text-[0.9rem]";
const submitBtnCls = "py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Profile page - shows user info, edit form, avatar upload, owned pets, and order history
export default function ProfilePage() {
const {user, token, loading, logout, refreshUser} = useAuth();
const router = useRouter();
@@ -49,6 +51,7 @@ export default function ProfilePage() {
const [profileSuccess, setProfileSuccess] = useState(null);
const [avatarSubmitting, setAvatarSubmitting] = useState(false);
//Revokes all blob URLs created for pet images to free up memory
const clearPetImageObjectUrls = useCallback(() => {
for (const objectUrl of petImageObjectUrlsRef.current) {
URL.revokeObjectURL(objectUrl);
@@ -73,6 +76,7 @@ export default function ProfilePage() {
});
}, [user]);
//Fetches the user's owned pets and resolves their images into blob URLs
const loadPets = useCallback(async () => {
if (!token) return;
setLoadingPets(true);
@@ -128,6 +132,7 @@ export default function ProfilePage() {
};
}, [clearPetImageObjectUrls]);
//Fetches the user's recent order history
const loadOrders = useCallback(async () => {
if (!token) return;
setLoadingOrders(true);
@@ -176,11 +181,13 @@ export default function ProfilePage() {
};
}, [user?.avatarUrl, token]);
//Logs out and sends the user to the home page
function handleLogout() {
logout();
router.push("/");
}
//Saves changes to the user's name, email, phone, or password
async function handleProfileSubmit(e) {
e.preventDefault();
setProfileError(null);
@@ -232,6 +239,7 @@ export default function ProfilePage() {
}
}
//Uploads a new avatar image for the user
async function handleAvatarUpload(file) {
if (!file) return;
@@ -266,6 +274,7 @@ export default function ProfilePage() {
}
}
//Removes the user's avatar
async function handleAvatarDelete() {
setAvatarSubmitting(true);
setProfileError(null);
@@ -295,6 +304,7 @@ export default function ProfilePage() {
}
}
//Opens the add pet form with blank fields
function openAddForm() {
setEditingPet(null);
setPetName("");
@@ -305,6 +315,7 @@ export default function ProfilePage() {
setShowForm(true);
}
//Opens the edit pet form pre-filled with the selected pet's details
function openEditForm(pet) {
setEditingPet(pet);
setPetName(pet.petName);
@@ -321,6 +332,7 @@ export default function ProfilePage() {
setPetError(null);
}
//Creates a new pet or saves edits to an existing one
async function handlePetSubmit(e) {
e.preventDefault();
setPetError(null);
@@ -358,6 +370,7 @@ export default function ProfilePage() {
}
}
//Confirms with the user then removes the pet profile
async function handleDeletePet(id) {
if (!confirm("Remove this pet profile?")) return;
@@ -378,6 +391,7 @@ export default function ProfilePage() {
}
}
//Uploads a photo for a specific pet profile
async function handleImageUpload(petId, file) {
const formData = new FormData();
formData.append("image", file);

View File

@@ -20,6 +20,8 @@ function resolveNextPath(candidate) {
return candidate;
}
//Registration page
//Collects user details, checks passwords match, then creates an account
function RegisterPage() {
const {register} = useAuth();
const router = useRouter();
@@ -38,10 +40,12 @@ function RegisterPage() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
//Updates a single field in the form state
function handleChange(e) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
//Validates passwords match then submits the registration form
async function handleSubmit(e) {
e.preventDefault();
setError("");

View File

@@ -9,6 +9,7 @@ const labelCls = "flex flex-col gap-[0.35rem] text-[0.9rem] font-semibold text-[
const inputCls = "px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg text-base outline-none transition-all focus:border-[#e68672] focus:shadow-[0_0_0_3px_rgba(230,134,114,0.2)]";
const submitBtnCls = "mt-2 py-3 bg-[#e68672] text-white border-none rounded-lg text-base font-bold cursor-pointer transition-all hover:bg-[#d4705e] active:scale-[0.98] disabled:opacity-60 disabled:cursor-not-allowed";
//Reset password page, reads the token from the URL and lets the user set a new password
function ResetPasswordPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -36,6 +37,7 @@ function ResetPasswordPage() {
);
}
//Validates passwords match, submits the reset, then redirects to login after 3 seconds
async function handleSubmit(e) {
e.preventDefault();
setError("");