1055 lines
35 KiB
JavaScript
1055 lines
35 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"],
|
||
};
|
||
|
||
// 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 (
|
||
<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">
|
||
{adoptionMode ? "Your Adoptions" : canBookAppointments ? "Your Appointments" : "Appointments"}
|
||
</h2>
|
||
{adoptionMode ? (
|
||
loadingAdoptions ? (
|
||
<p className="appt-loading">Loading adoptions...</p>
|
||
) : adoptions.length === 0 ? (
|
||
<p className="appt-empty">No adoption appointments 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>
|
||
)
|
||
) : 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.petNames && a.petNames.length > 0 && (
|
||
<div className="appt-card-pets">
|
||
Pets: {a.petNames.join(", ")}
|
||
</div>
|
||
)}
|
||
{a.customerPetNames && a.customerPetNames.length > 0 && (
|
||
<div className="appt-card-pets">
|
||
Pets: {a.customerPetNames.join(", ")}
|
||
</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>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default dynamic(() => Promise.resolve(AppointmentsPage), {
|
||
ssr: false,
|
||
});
|