"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?.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);
}
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?.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) {
// 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 ? (
) : 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,
});