Files
group-2-threaded-project-pe…/web/app/appointments/page.js
Harkamal d09b0a34f1 species service validation (#317)
* fix species-service validation

* add grooming for hamster, other

* expand reptile and other services
2026-04-15 16:25:14 -06:00

1051 lines
35 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"],
};
const SPECIES_EXCLUSIVE_SERVICES = {
Bird: ["wing clipping", "beak and nail"],
Fish: ["aquarium health"],
};
function getAvailableServices(services, species) {
if (!species) return services;
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"];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
];
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 });
}
const s = {
widget: {
border: "1px solid #ddd",
borderRadius: "10px",
overflow: "hidden",
background: "white",
userSelect: "none",
fontFamily: "inherit",
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
background: "orange",
padding: "0.55rem 0.75rem",
},
monthLabel: {
fontSize: "0.95rem",
fontWeight: 700,
color: "white",
},
nav: {
background: "none",
border: "none",
color: "white",
fontSize: "1.5rem",
lineHeight: 1,
cursor: "pointer",
padding: "0 0.4rem",
borderRadius: "4px",
},
grid: {
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: "3px",
padding: "0.6rem",
},
dayName: {
textAlign: "center",
fontSize: "0.7rem",
fontWeight: 700,
color: "#aaa",
padding: "0.25rem 0",
textTransform: "uppercase",
},
dayBase: {
display: "flex",
alignItems: "center",
justifyContent: "center",
aspectRatio: "1 / 1",
border: "none",
borderRadius: "6px",
background: "none",
fontSize: "0.875rem",
cursor: "pointer",
color: "#333",
fontFamily: "inherit",
padding: 0,
width: "100%",
},
daySelected: {
background: "orange",
color: "white",
fontWeight: 700,
},
dayDisabled: {
color: "#ccc",
cursor: "default",
},
selectedLabel: {
textAlign: "center",
fontSize: "0.82rem",
color: "#666",
padding: "0.35rem 0.5rem 0.5rem",
borderTop: "1px solid #f0f0f0",
},
};
return (
<div style={s.widget}>
<div style={s.header}>
<button type="button" style={s.nav} onClick={prevMonth} disabled={isPrevDisabled} aria-label="Previous month"></button>
<span style={s.monthLabel}>{MONTHS[viewMonth]} {viewYear}</span>
<button type="button" style={s.nav} onClick={nextMonth} aria-label="Next month"></button>
</div>
<div style={s.grid}>
{DAYS.map((d) => (
<span key={d} style={s.dayName}>{d}</span>
))}
{cells.map(({ key, day }) =>
day === null ? (
<span key={key} />
) : (
<button
key={key}
type="button"
style={{
...s.dayBase,
...(isSelected(day) ? s.daySelected : {}),
...(isDisabled(day) ? s.dayDisabled : {}),
}}
onClick={() => selectDay(day)}
disabled={isDisabled(day)}
aria-pressed={isSelected(day)}
>
{day}
</button>
)
)}
</div>
{parsed && (
<div style={s.selectedLabel}>
Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()}
</div>
)}
</div>
);
}
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="appt-modal-overlay" onClick={onClose}>
<div className="appt-modal" onClick={(e) => e.stopPropagation()}>
<h3 className="profile-pet-form-title">Add a New Pet</h3>
{petError && <div className="appt-error">{petError}</div>}
<form onSubmit={handleSubmit}>
<label className="appt-label">
Name
<input
className="appt-input"
type="text"
value={petName}
onChange={(e) => setPetName(e.target.value)}
required
maxLength={50}
/>
</label>
<label className="appt-label">
Species
<select
className="appt-select"
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="appt-label">
Breed
<select
className="appt-select"
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="profile-pet-form-actions">
<button type="submit" className="appt-submit-btn" disabled={submitting}>
{submitting ? "Saving..." : "Add Pet"}
</button>
<button type="button" className="profile-pet-cancel-btn" onClick={onClose}>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const preselectedPetId = searchParams.get("petId");
// Adoption mode — set when arriving from a pet detail page
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);
// Adoption-mode URL verification
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 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]);
// Verify the pet from the URL is real, available, and at the stated store
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]);
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) {
// Need both the store (so employees load) and a serviceId (so availability slots load)
if (adoptionStoreId && services.length > 0) {
setStoreId(adoptionStoreId);
// Prefer a service named "adopt", fall back to the first available service
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]);
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]);
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]);
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);
}
}
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 === "Owned" || p.petStatus === "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);
}
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([]);
}
}
}
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;
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 !== "Owned" && selectedPet.petStatus !== "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) {
// Submit an adoption request directly to the adoption table
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="appt-page">
<p className="appt-loading">Loading...</p>
</main>
);
}
if (!user) return null;
return (
<main className="appt-page">
{showAddPetModal && (
<AddPetModal
token={token}
onClose={() => setShowAddPetModal(false)}
onAdded={loadCustomerPets}
/>
)}
<section className="appt-hero">
<h1 className="appt-hero-title">{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}</h1>
<p className="appt-hero-subtitle">{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}</p>
<div className="title-decoration"></div>
</section>
<section className="appt-content">
{canBookAppointments ? (
<form className="appt-form" onSubmit={handleSubmit}>
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
{error && <div className="appt-error" ref={errorRef}>{error}</div>}
{adoptionMode && adoptionVerifyLoading && (
<p className="appt-loading">Verifying pet details</p>
)}
{adoptionMode && adoptionVerifyError && (
<div className="appt-error">{adoptionVerifyError}</div>
)}
{(!adoptionMode || adoptionVerified) && (<>
{/* ADOPTION MODE: locked pet + store */}
{adoptionMode && (
<label className="appt-label">
Pet
<div className="appt-locked-field">
{[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(" · ")}
</div>
</label>
)}
{/* STEP 1 (non-adoption): select a pet first */}
{!adoptionMode && (
<div className="appt-label">
<span>Select Your Pet</span>
{eligiblePets.length === 0 ? (
<p className="appt-no-slots">
You have no adopted pets available.{" "}
<Link href="/profile">Add a pet on your profile page.</Link>
</p>
) : (
<div className="appt-pets-grid">
{eligiblePets.map((p) => (
<label
key={p.customerPetId}
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
>
<input
type="radio"
name="customerPet"
checked={selectedPetIds.includes(p.customerPetId)}
onChange={() => handlePetSelect(p.customerPetId)}
className="appt-pet-checkbox"
/>
{p.petName}
<span className="appt-pet-chip-species">({p.species})</span>
</label>
))}
</div>
)}
</div>
)}
{/* Remaining fields — shown after pet selected (or always in adoption mode) */}
{(adoptionMode || selectedPetIds.length > 0) && (<>
<label className="appt-label">
Store Location
{adoptionMode ? (
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
) : (
<select
className="appt-select"
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="appt-label">
Service
{availableServices.length === 0 ? (
<p className="appt-no-slots">
No services are available for {selectedPet?.species || "this pet"}.
</p>
) : (
<select
className="appt-select"
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="appt-label">
Employee
<select
className="appt-select"
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="appt-service-info">
<p>{selectedService.serviceDesc}</p>
</div>
)}
<div className="appt-label">
Date
<DatePicker
value={appointmentDate}
minDate={getMinDate()}
onChange={setAppointmentDate}
/>
</div>
{!adoptionMode && storeId && serviceId && appointmentDate && (
<div className="appt-label">
<span>Available Time Slots</span>
{loadingSlots ? (
<p className="appt-slots-loading">Checking availability...</p>
) : availableSlots.length === 0 ? (
<p className="appt-no-slots">No available slots for this date. Please try another date.</p>
) : (
<div className="appt-slots-grid">
{availableSlots.map((slot) => (
<button
key={slot}
type="button"
className={`appt-slot-btn ${appointmentTime === slot ? "appt-slot-btn--selected" : ""}`}
onClick={() => setAppointmentTime(slot)}
>
{formatTime(slot)}
</button>
))}
</div>
)}
</div>
)}
</>)}
<button
type="submit"
className="appt-submit-btn"
disabled={!formValid || submitting}
>
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
</button>
{success && <div className="appt-success">{success}</div>}
</>)}
</form>
) : null}
<div className="appt-history" ref={historyRef}>
<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>
</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>
)}
{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>
</div>
<div className="appt-card-details">
<span>{a.sourceStoreName}</span>
<span>{a.adoptionDate}</span>
</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>
);
}
export default dynamic(() => Promise.resolve(AppointmentsPage), {
ssr: false,
});