"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"], }; // Explicit allowlists for species with restricted service availability. // Species not listed here may use all services. const SPECIES_SERVICE_ALLOWLIST = { Bird: ["wing clipping", "beak and nail"], Fish: ["aquarium health"], }; function getAvailableServices(services, species) { if (!species) return services; const allowlist = SPECIES_SERVICE_ALLOWLIST[species]; if (!allowlist) return services; return services.filter((s) => allowlist.some((kw) => s.serviceName.toLowerCase().includes(kw)) ); } 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 (
{MONTHS[viewMonth]} {viewYear}
{DAYS.map((d) => ( {d} ))} {cells.map(({ key, day }) => day === null ? ( ) : ( ) )}
{parsed && (
Selected: {MONTHS[parsed.getMonth()]} {parsed.getDate()}, {parsed.getFullYear()}
)}
); } 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 (
e.stopPropagation()}>

Add a New Pet

{petError &&
{petError}
}
); } 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 (

Loading...

); } if (!user) return null; return (
{showAddPetModal && ( setShowAddPetModal(false)} onAdded={loadCustomerPets} /> )}

{adoptionMode ? "Schedule an Adoption" : "Schedule an Appointment"}

{adoptionMode ? "Schedule a pet adoption visit" : "Book a service for your pet."}

{canBookAppointments ? (

{adoptionMode ? "New Adoption" : "New Appointment"}

{error &&
{error}
} {adoptionMode && adoptionVerifyLoading && (

Verifying pet details…

)} {adoptionMode && adoptionVerifyError && (
{adoptionVerifyError}
)} {(!adoptionMode || adoptionVerified) && (<> {/* ADOPTION MODE: locked pet + store */} {adoptionMode && ( )} {/* STEP 1 (non-adoption): select a pet first */} {!adoptionMode && (
Select Your Pet {eligiblePets.length === 0 ? (

You have no adopted pets available.{" "} Add a pet on your profile page.

) : (
{eligiblePets.map((p) => ( ))}
)}
)} {/* Remaining fields — shown after pet selected (or always in adoption mode) */} {(adoptionMode || selectedPetIds.length > 0) && (<> {!adoptionMode && ( )} {employees.length > 0 && ( )} {!adoptionMode && selectedService && (

{selectedService.serviceDesc}

)}
Date
{!adoptionMode && storeId && serviceId && appointmentDate && (
Available Time Slots {loadingSlots ? (

Checking availability...

) : availableSlots.length === 0 ? (

No available slots for this date. Please try another date.

) : (
{availableSlots.map((slot) => ( ))}
)}
)} )} {success &&
{success}
} )}
) : null}

{canBookAppointments ? "Your Appointments" : "Appointments"}

{loadingAppointments ? (

Loading appointments...

) : appointments.length === 0 ? (

No appointments yet.

) : (
{appointments.map((a) => (
{a.serviceName} {a.appointmentStatus}
{a.storeName} {a.appointmentDate} at {formatTime(a.appointmentTime)}
{a.petName && (
Pet: {a.petName}
)} {a.appointmentStatus?.toLowerCase() === "booked" && (
)}
))}
)}

{canBookAppointments ? "Your Adoptions" : "Adoptions"}

{loadingAdoptions ? (

Loading adoptions...

) : adoptions.length === 0 ? (

No adoption requests yet.

) : (
{adoptions.map((a) => (
{a.petName} {a.adoptionStatus}
{a.sourceStoreName} {a.adoptionDate}
{a.adoptionStatus?.toLowerCase() === "pending" && (
)}
))}
)}
); } export default dynamic(() => Promise.resolve(AppointmentsPage), { ssr: false, });