1146 lines
50 KiB
JavaScript
1146 lines
50 KiB
JavaScript
"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"
|
||
>
|
||
← 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 →
|
||
</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"
|
||
>
|
||
← 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 →
|
||
</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"
|
||
>
|
||
← 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 →
|
||
</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"
|
||
>
|
||
← 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 →
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default dynamic(() => Promise.resolve(AppointmentsPage), {
|
||
ssr: false,
|
||
});
|