Files
group-2-threaded-project-pe…/web/app/appointments/page.js
2026-04-20 19:19:30 -06:00

1146 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import dynamic from "next/dynamic";
import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
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"],
Bird: ["Canary", "Cockatiel", "Cockatoo", "Finch", "Lovebird", "Macaw", "Parakeet", "Parrot", "Other"],
Rabbit: ["Dutch", "Flemish Giant", "Holland Lop", "Lionhead", "Mini Rex", "Other"],
Hamster: ["Dwarf", "Roborovski", "Syrian", "Other"],
"Guinea Pig": ["Abyssinian", "American", "Peruvian", "Teddy", "Other"],
Reptile: ["Ball Python", "Bearded Dragon", "Blue-tongued Skink", "Corn Snake", "Leopard Gecko", "Other"],
Fish: ["Angelfish", "Betta", "Cichlid", "Clownfish", "Goldfish", "Guppy", "Tetra", "Other"],
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) {
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)) {
if (exclusiveSpecies !== species && keywords.some((kw) => name.includes(kw))) {
return false;
}
}
const banned = SPECIES_BANNED_SERVICES[species] ?? [];
if (banned.some((kw) => name.includes(kw))) {
return false;
}
return true;
});
}
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);
const min = minDate ? new Date(minDate + "T00:00:00") : today;
const parsed = value ? new Date(value + "T00:00:00") : null;
const [viewYear, setViewYear] = useState(parsed ? parsed.getFullYear() : min.getFullYear());
const [viewMonth, setViewMonth] = useState(parsed ? parsed.getMonth() : min.getMonth());
function prevMonth() {
if (viewMonth === 0) { setViewMonth(11); setViewYear((y) => y - 1); }
else { setViewMonth((m) => m - 1); }
}
function nextMonth() {
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
else { setViewMonth((m) => m + 1); }
}
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const minYear = min.getFullYear();
const minMonth = min.getMonth();
const isPrevDisabled = viewYear < minYear || (viewYear === minYear && viewMonth <= minMonth);
function selectDay(day) {
const d = new Date(viewYear, viewMonth, day);
if (d < min) return;
const iso = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
onChange(iso);
}
function isSelected(day) {
if (!parsed) return false;
return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day;
}
function isDisabled(day) {
return new Date(viewYear, viewMonth, day) < min;
}
const cells = [];
for (let i = 0; i < firstDay; i++) {
cells.push({ key: `empty-${viewYear}-${viewMonth}-${String(i)}`, day: null });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ key: `day-${viewYear}-${viewMonth}-${String(d)}`, day: d });
}
return (
<div className="border border-[#ddd] rounded-[10px] overflow-hidden bg-white select-none">
<div className="flex items-center justify-between bg-orange-500 px-3 py-[0.55rem]">
<button type="button" className="bg-transparent border-none text-white text-2xl leading-none cursor-pointer px-[0.4rem] rounded hover:bg-white/20 disabled:opacity-40" onClick={prevMonth} disabled={isPrevDisabled} aria-label="Previous month"></button>
<span className="text-[0.95rem] font-bold text-white">{MONTHS[viewMonth]} {viewYear}</span>
<button type="button" className="bg-transparent border-none text-white text-2xl leading-none cursor-pointer px-[0.4rem] rounded hover:bg-white/20" onClick={nextMonth} aria-label="Next month"></button>
</div>
<div className="grid grid-cols-7 gap-[3px] p-[0.6rem]">
{DAYS.map((d) => (
<span key={d} className="text-center text-[0.7rem] font-bold text-[#aaa] py-1 uppercase">{d}</span>
))}
{cells.map(({ key, day }) =>
day === null ? (
<span key={key} />
) : (
<button
key={key}
type="button"
className={`flex items-center justify-center aspect-square border-none rounded-md text-[0.875rem] cursor-pointer w-full p-0 transition-colors
${isSelected(day) ? "bg-orange-500 text-white font-bold" : isDisabled(day) ? "text-[#ccc] cursor-default bg-transparent" : "bg-transparent text-[#333] hover:bg-orange-100"}`}
onClick={() => selectDay(day)}
disabled={isDisabled(day)}
aria-pressed={isSelected(day)}
>
{day}
</button>
)
)}
</div>
{parsed && (
<div className="text-center text-[0.82rem] text-[#666] px-2 pb-2 pt-1 border-t border-[#f0f0f0]">
Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()}
</div>
)}
</div>
);
}
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 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";
//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("");
const [breed, setBreed] = useState("");
const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
setPetError(null);
setSubmitting(true);
try {
const res = await fetch(`${API_BASE}/api/v1/my-pets`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ petName, species, breed: breed || null }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Request failed (${res.status})`);
}
onAdded();
onClose();
}
catch (err) {
setPetError(err.message);
}
finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-[480px] flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<h3 className="text-[1.1rem] font-bold text-[#333] m-0">Add a New Pet</h3>
{petError && <div className={errorCls}>{petError}</div>}
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<label className={labelCls}>
Name
<input className={inputCls} type="text" value={petName} onChange={(e) => setPetName(e.target.value)} required maxLength={50} />
</label>
<label className={labelCls}>
Species
<select className={selectCls} value={species} onChange={(e) => { setSpecies(e.target.value); setBreed(""); }} required>
<option value="">Select a species...</option>
{Object.keys(SPECIES_BREEDS).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</label>
<label className={labelCls}>
Breed
<select className={`${selectCls} disabled:bg-[#f5f5f5] disabled:text-[#aaa] disabled:cursor-not-allowed`} value={breed} onChange={(e) => setBreed(e.target.value)} required disabled={!species}>
<option value="">{species ? "Select a breed..." : "Select a species first"}</option>
{(SPECIES_BREEDS[species] || []).map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
<div className="flex gap-3">
<button type="submit" className={submitBtnCls} disabled={submitting}>
{submitting ? "Saving..." : "Add Pet"}
</button>
<button type="button" className="px-4 py-2 border border-[#ddd] rounded-lg bg-white text-[#555] text-[0.9rem] cursor-pointer hover:border-[#aaa] transition-colors" onClick={onClose}>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
//Appointments page - book a service or adoption, and view past and active appointments
function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const preselectedPetId = searchParams.get("petId");
const adoptionMode = searchParams.get("adoptionMode") === "true";
const adoptionPetId = searchParams.get("petId");
const adoptionPetName = searchParams.get("petName") || "";
const adoptionPetSpecies = searchParams.get("petSpecies") || "";
const adoptionPetBreed = searchParams.get("petBreed") || "";
const adoptionStoreId = searchParams.get("storeId") || "";
const adoptionStoreName = searchParams.get("storeName") || "";
const didPreselectRef = useRef(false);
const errorRef = useRef(null);
const historyRef = useRef(null);
const [adoptionVerified, setAdoptionVerified] = useState(!adoptionMode);
const [adoptionVerifyError, setAdoptionVerifyError] = useState(null);
const [adoptionVerifyLoading, setAdoptionVerifyLoading] = useState(adoptionMode);
const [stores, setStores] = useState([]);
const [employees, setEmployees] = useState([]);
const [services, setServices] = useState([]);
const [allPets, setAllPets] = useState([]);
const [customerPets, setCustomerPets] = useState([]);
const [availableSlots, setAvailableSlots] = useState([]);
const [storeId, setStoreId] = useState("");
const [serviceId, setServiceId] = useState("");
const [employeeId, setEmployeeId] = useState("");
const [appointmentDate, setAppointmentDate] = useState("");
const [appointmentTime, setAppointmentTime] = useState("");
const [selectedPetIds, setSelectedPetIds] = useState([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
useEffect(() => {
if (error && errorRef.current) {
errorRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [error]);
const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false);
const [adoptions, setAdoptions] = useState([]);
const [loadingAdoptions, setLoadingAdoptions] = useState(false);
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);
//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(() => {
if (!authLoading && !user) {
const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments";
router.push(`/login?next=${encodeURIComponent(target)}`);
}
}, [authLoading, user, router, preselectedPetId]);
useEffect(() => {
if (!adoptionMode || !adoptionPetId) return;
setAdoptionVerifyLoading(true);
fetch(`${API_BASE}/api/v1/pets/${adoptionPetId}`)
.then((r) => {
if (!r.ok) throw new Error("Pet not found. This link may be invalid.");
return r.json();
})
.then((pet) => {
if (pet.petStatus?.toLowerCase() !== "available") {
throw new Error(`${pet.petName || "This pet"} is no longer available for adoption (status: ${pet.petStatus}).`);
}
if (adoptionStoreId && String(pet.storeId) !== String(adoptionStoreId)) {
throw new Error("Store mismatch: this pet is not located at the specified store.");
}
setAdoptionVerified(true);
})
.catch((err) => setAdoptionVerifyError(err.message))
.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`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setCustomerPets(Array.isArray(data) ? data : []))
.catch(() => {});
}, [token, canBookAppointments]);
useEffect(() => {
if (!token) return;
fetch(`${API_BASE}/api/v1/dropdowns/stores`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(setStores)
.catch(() => setError("Failed to load stores."));
fetch(`${API_BASE}/api/v1/services?size=100`)
.then((r) => r.json())
.then((data) => setServices(data.content ?? []))
.catch(() => setError("Failed to load services."));
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`)
.then((r) => r.json())
.then((data) => setAllPets(data.content ?? []))
.catch(() => {});
loadCustomerPets();
}, [token, loadCustomerPets]);
useEffect(() => {
if (didPreselectRef.current) return;
if (adoptionMode) {
if (adoptionStoreId && services.length > 0) {
setStoreId(adoptionStoreId);
const adoptionSvc =
services.find((s) => s.serviceName.toLowerCase().includes("adopt")) ||
services[0];
if (adoptionSvc) {
setServiceId(String(adoptionSvc.serviceId));
didPreselectRef.current = true;
}
}
return;
}
if (!preselectedPetId || services.length === 0 || allPets.length === 0) return;
const adoptionSvc = services.find((s) => s.serviceName.toLowerCase().includes("adopt"));
if (adoptionSvc) {
setServiceId(String(adoptionSvc.serviceId));
}
setSelectedPetIds([Number(preselectedPetId)]);
didPreselectRef.current = true;
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
//Fetches the user's booked appointments
const loadAppointments = useCallback(() => {
if (!token) return;
setLoadingAppointments(true);
fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setAppointments(data.content ?? []))
.catch(() => {})
.finally(() => setLoadingAppointments(false));
}, [token]);
//Fetches the user's adoption requests
const loadAdoptions = useCallback(() => {
if (!token) return;
setLoadingAdoptions(true);
fetch(`${API_BASE}/api/v1/adoptions?size=50&sort=adoptionDate,desc`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setAdoptions(data.content ?? []))
.catch(() => {})
.finally(() => setLoadingAdoptions(false));
}, [token]);
useEffect(() => {
loadAppointments();
loadAdoptions();
}, [loadAppointments, loadAdoptions]);
//Cancels an appointment after asking the user to confirm
async function handleCancelAppointment(appointmentId) {
if (!confirm("Cancel this appointment?")) return;
setCancellingId(appointmentId);
try {
const res = await fetch(`${API_BASE}/api/v1/appointments/${appointmentId}/cancel`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Failed to cancel appointment (${res.status})`);
}
loadAppointments();
} catch (err) {
alert(err.message);
} finally {
setCancellingId(null);
}
}
//Cancels an adoption request after asking the user to confirm
async function handleCancelAdoption(adoptionId) {
if (!confirm("Cancel this adoption request?")) return;
setCancellingId(adoptionId);
try {
const res = await fetch(`${API_BASE}/api/v1/adoptions/${adoptionId}/cancel`, {
method: "PATCH",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || `Failed to cancel adoption (${res.status})`);
}
loadAdoptions();
} catch (err) {
alert(err.message);
} finally {
setCancellingId(null);
}
}
useEffect(() => {
if (!token || !storeId) {
setEmployees([]);
setEmployeeId("");
return;
}
fetch(`${API_BASE}/api/v1/dropdowns/stores/${storeId}/employees`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setEmployees(Array.isArray(data) ? data : []))
.catch(() => setEmployees([]));
}, [token, storeId]);
useEffect(() => {
if (!employees.length) { setEmployeeId(""); return; }
const currentExists = employees.some((employee) => String(employee.id) === String(employeeId));
if (!currentExists) {
setEmployeeId(String(employees[0].id));
}
}, [employees, employeeId]);
useEffect(() => {
if (!storeId || !serviceId || !appointmentDate) {
setAvailableSlots([]);
setAppointmentTime("");
return;
}
setLoadingSlots(true);
setAppointmentTime("");
const params = new URLSearchParams({ storeId, serviceId, date: appointmentDate });
fetch(`${API_BASE}/api/v1/appointments/availability?${params}`)
.then((r) => {
if (!r.ok) throw new Error("Failed to check availability");
return r.json();
})
.then(setAvailableSlots)
.catch(() => setAvailableSlots([]))
.finally(() => setLoadingSlots(false));
}, [storeId, serviceId, appointmentDate]);
const eligiblePets = customerPets.filter(
(p) => p.petStatus?.toLowerCase() === "owned" || p.petStatus?.toLowerCase() === "adopted"
);
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
const selectedPet = !adoptionMode
? (eligiblePets.find((p) => p.customerPetId === selectedPetIds[0]) || null)
: null;
const availableServices = getAvailableServices(services, selectedPet?.species);
function handleServiceChange(newServiceId) {
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]);
if (serviceId && newPet) {
const newAvailable = getAvailableServices(services, newPet.species);
if (!newAvailable.some((s) => String(s.serviceId) === String(serviceId))) {
setServiceId("");
setAppointmentTime("");
setAvailableSlots([]);
}
}
}
//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);
const ampm = hour >= 12 ? "PM" : "AM";
const display = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${display}:${m} ${ampm}`;
}
function getMinDate() {
const d = new Date();
return d.toISOString().split("T")[0];
}
const formValid = adoptionMode
? 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);
setSuccess(null);
if (!canBookAppointments) {
setError("Only customer accounts can book appointments from the web app.");
return;
}
if (!adoptionMode && selectedPetIds.length === 0) {
setError("Please select a pet for your appointment.");
return;
}
if (!adoptionMode && selectedPet && selectedPet.petStatus?.toLowerCase() !== "owned" && selectedPet.petStatus?.toLowerCase() !== "adopted") {
setError("The selected pet is no longer eligible for appointments. Please refresh the page.");
return;
}
if (!adoptionMode && selectedPet && serviceId) {
const chosenService = services.find((s) => String(s.serviceId) === String(serviceId));
if (chosenService && getAvailableServices([chosenService], selectedPet.species).length === 0) {
setError(`"${chosenService.serviceName}" is not available for ${selectedPet.species}s. Please select a valid service.`);
return;
}
}
setSubmitting(true);
try {
if (adoptionMode) {
const body = {
petId: Number(adoptionPetId),
employeeId: employeeId ? Number(employeeId) : undefined,
sourceStoreId: adoptionStoreId ? Number(adoptionStoreId) : undefined,
adoptionDate: appointmentDate,
};
const res = await fetch(`${API_BASE}/api/v1/adoptions/request`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
}
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
setEmployeeId("");
loadAdoptions();
setTimeout(() => historyRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 300);
return;
}
const body = {
customerId: user.customerId || user.id,
storeId: Number(storeId),
serviceId: Number(serviceId),
employeeId: employeeId ? Number(employeeId) : undefined,
appointmentDate,
appointmentTime: appointmentTime + ":00",
appointmentStatus: "Booked",
};
if (selectedPetIds.length > 0) {
body.petId = selectedPetIds[0];
}
const res = await fetch(`${API_BASE}/api/v1/appointments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
}
setSuccess("Appointment booked successfully!");
setStoreId("");
setServiceId("");
setAppointmentDate("");
setAppointmentTime("");
setSelectedPetIds([]);
setAvailableSlots([]);
loadAppointments();
setTimeout(() => historyRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 300);
}
catch (err) {
setError(err.message);
}
finally {
setSubmitting(false);
}
}
if (authLoading) {
return (
<main className="min-h-screen">
<p className="text-center text-[#666] py-12">Loading...</p>
</main>
);
}
if (!user) return null;
return (
<main className="min-h-screen">
{showAddPetModal && (
<AddPetModal
token={token}
onClose={() => setShowAddPetModal(false)}
onAdded={loadCustomerPets}
/>
)}
<section className="text-center py-16 px-8 bg-gradient-to-b from-[#f9f9f9] to-white">
<h1 className="text-5xl font-bold text-[#333] mb-4 tracking-tight max-[768px]:text-3xl max-[480px]:text-[1.6rem]">
{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}
</h1>
<p className="text-2xl font-light text-[#666] mb-8 max-[768px]:text-[1.2rem]">
{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}
</p>
<div className="w-[100px] h-1 bg-[#e68672] mx-auto mt-8 rounded-sm"></div>
</section>
<section className="max-w-[1200px] mx-auto px-8 pb-16 flex gap-8 items-start max-[900px]:flex-col">
{canBookAppointments ? (
<form className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8 flex-1 flex flex-col gap-4 max-[900px]:w-full" onSubmit={handleSubmit}>
<h2 className="text-xl font-bold text-[#333] m-0">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
{error && <div className={errorCls} ref={errorRef}>{error}</div>}
{adoptionMode && adoptionVerifyLoading && (
<p className="text-[#666] text-center py-2">Verifying pet details</p>
)}
{adoptionMode && adoptionVerifyError && (
<div className={errorCls}>{adoptionVerifyError}</div>
)}
{(!adoptionMode || adoptionVerified) && (<>
{adoptionMode && (
<label className={labelCls}>
Pet
<div className="px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg bg-[#f5f5f5] text-[#555]">
{[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(" · ")}
</div>
</label>
)}
{!adoptionMode && (
<div className={labelCls}>
<span>Select Your Pet</span>
{eligiblePets.length === 0 ? (
<p className="text-[#888] text-[0.9rem] m-0">
You have no adopted pets available.{" "}
<Link href="/profile" className="text-[#e68672] hover:underline">Add a pet on your profile page.</Link>
</p>
) : (
<div className="flex flex-wrap gap-2">
{eligiblePets.map((p) => (
<label
key={p.customerPetId}
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-full border-2 text-[0.85rem] font-semibold transition-all
${selectedPetIds.includes(p.customerPetId)
? "border-[#e68672] bg-[#fff4f2] text-[#e68672]"
: "border-[#ddd] bg-white text-[#333] hover:border-[#e68672]"}`}
>
<input
type="radio"
name="customerPet"
checked={selectedPetIds.includes(p.customerPetId)}
onChange={() => handlePetSelect(p.customerPetId)}
className="hidden"
/>
{p.petName}
<span className="text-[#888] font-normal">({p.species})</span>
</label>
))}
</div>
)}
</div>
)}
{(adoptionMode || selectedPetIds.length > 0) && (<>
<label className={labelCls}>
Store Location
{adoptionMode ? (
<div className="px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg bg-[#f5f5f5] text-[#555]">{adoptionStoreName || "Pet's store"}</div>
) : (
<select className={selectCls} value={storeId} onChange={(e) => setStoreId(e.target.value)} required>
<option value="">Select a store...</option>
{stores.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option>
))}
</select>
)}
</label>
{!adoptionMode && (
<label className={labelCls}>
Service
{availableServices.length === 0 ? (
<p className="text-[#888] text-[0.9rem] m-0">
No services are available for {selectedPet?.species || "this pet"}.
</p>
) : (
<select className={selectCls} value={serviceId} onChange={(e) => handleServiceChange(e.target.value)} required>
<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)
</option>
))}
</select>
)}
</label>
)}
{employees.length > 0 && (
<label className={labelCls}>
Employee
<select className={selectCls} value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
{employees.map((employee) => (
<option key={employee.id} value={employee.id}>{employee.label}</option>
))}
</select>
</label>
)}
{!adoptionMode && selectedService && (
<div className="bg-[#f9f9f9] rounded-lg px-4 py-3 text-[0.85rem] text-[#555] border border-[#eee]">
<p className="m-0">{selectedService.serviceDesc}</p>
</div>
)}
<div className={labelCls}>
Date
<DatePicker
value={appointmentDate}
minDate={getMinDate()}
onChange={setAppointmentDate}
/>
</div>
{!adoptionMode && storeId && serviceId && appointmentDate && (
<div className={labelCls}>
<span>Available Time Slots</span>
{loadingSlots ? (
<p className="text-[#888] text-[0.9rem] m-0">Checking availability...</p>
) : availableSlots.length === 0 ? (
<p className="text-[#888] text-[0.9rem] m-0">No available slots for this date. Please try another date.</p>
) : (
<div className="flex flex-wrap gap-2">
{availableSlots.map((slot) => (
<button
key={slot}
type="button"
className={`px-3 py-2 rounded-lg border-2 text-[0.85rem] cursor-pointer transition-all
${appointmentTime === slot
? "border-[#e68672] bg-[#e68672] text-white"
: "border-[#ddd] bg-white text-[#333] hover:border-[#e68672]"}`}
onClick={() => setAppointmentTime(slot)}
>
{formatTime(slot)}
</button>
))}
</div>
)}
</div>
)}
</>)}
<button type="submit" className={submitBtnCls} disabled={!formValid || submitting}>
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
</button>
{success && <div className={successCls}>{success}</div>}
</>)}
</form>
) : null}
{/* History panel */}
<div className="bg-white rounded-2xl shadow-[0_4px_16px_rgba(0,0,0,0.08)] p-8 flex-1 flex flex-col gap-4 max-[900px]:w-full" ref={historyRef}>
<h2 className="text-xl font-bold text-[#333] m-0">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
{loadingAppointments ? (
<p className="text-[#666] text-center py-4">Loading appointments...</p>
) : (() => {
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))
);
//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
className="px-4 py-2 border border-[#ddd] rounded-lg text-[0.9rem] outline-none w-full focus:border-[#e68672] transition-colors"
type="text"
placeholder="Search appointments…"
value={apptSearch}
onChange={(e) => setApptSearch(e.target.value)}
/>
{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">
{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>
)}
</>
)}
{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); setPastApptPage(0); }}>
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
</button>
{showPastAppts && (
<>
<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>
{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>
)}
</>
);
})()}
<h2 className="text-xl font-bold text-[#333] m-0 mt-4">{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
{loadingAdoptions ? (
<p className="text-[#666] text-center py-4">Loading adoptions...</p>
) : (() => {
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))
);
//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
className="px-4 py-2 border border-[#ddd] rounded-lg text-[0.9rem] outline-none w-full focus:border-[#e68672] transition-colors"
type="text"
placeholder="Search adoptions…"
value={adoptionSearch}
onChange={(e) => setAdoptionSearch(e.target.value)}
/>
{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">
{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>
)}
</>
)}
{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); setPastAdoptionPage(0); }}>
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
</button>
{showPastAdoptions && (
<>
<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>
</section>
</main>
);
}
export default dynamic(() => Promise.resolve(AppointmentsPage), {
ssr: false,
});