Fixes for appointments and My Pets fields.

This commit is contained in:
augmentedpotato
2026-04-14 12:20:48 -06:00
parent 208372c782
commit c2f39c40f0
13 changed files with 570 additions and 193 deletions

View File

@@ -7,6 +7,34 @@ 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",
];
@@ -254,26 +282,32 @@ function AddPetModal({ token, onClose, onAdded }) {
</label>
<label className="appt-label">
Species
<input
className="appt-input"
type="text"
<select
className="appt-select"
value={species}
onChange={(e) => setSpecies(e.target.value)}
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
required
maxLength={50}
placeholder="e.g. Dog, Cat, Bird"
/>
>
<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 (optional)
<input
className="appt-input"
type="text"
Breed
<select
className="appt-select"
value={breed}
onChange={(e) => setBreed(e.target.value)}
maxLength={50}
placeholder="e.g. Golden Retriever"
/>
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}>
@@ -306,6 +340,11 @@ function AppointmentsPage() {
const didPreselectRef = useRef(false);
// 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([]);
@@ -328,7 +367,11 @@ function AppointmentsPage() {
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";
@@ -340,6 +383,28 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [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`, {
@@ -410,13 +475,10 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}, [adoptionMode, adoptionStoreId, preselectedPetId, services, allPets]);
const loadAppointments = useCallback(() => {
if (!token) {
return;
}
if (!token) return;
setLoadingAppointments(true);
fetch(`${API_BASE}/api/v1/appointments?size=50&sort=appointmentDate,desc`, {
headers: {Authorization: `Bearer ${token}`},
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setAppointments(data.content ?? []))
@@ -424,9 +486,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
.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();
}, [loadAppointments]);
if (adoptionMode) loadAdoptions();
else loadAppointments();
}, [adoptionMode, 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) {
@@ -478,30 +593,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
.finally(() => setLoadingSlots(false));
}, [storeId, serviceId, appointmentDate]);
const selectedService = services.find((s) => s.serviceId === Number(serviceId));
const isAdoptionService = selectedService ? selectedService.serviceName.toLowerCase().includes("adopt") : false;
const isCustomerPetService = !!selectedService && !isAdoptionService;
const adoptablePets = allPets.filter(
(p) => p.petStatus && p.petStatus.toLowerCase() === "available"
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);
setSelectedPetIds([]);
}
function togglePet(petId) {
if (isAdoptionService) {
setSelectedPetIds((prev) =>
prev.includes(petId) ? [] : [petId]
);
}
else {
setSelectedPetIds((prev) =>
prev.includes(petId) ? prev.filter((id) => id !== petId) : [...prev, petId]
);
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([]);
}
}
}
@@ -521,7 +636,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
}
const formValid = adoptionMode
? Boolean(employeeId && appointmentDate && appointmentTime)
? Boolean(employeeId && appointmentDate && adoptionVerified)
: storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
async function handleSubmit(e) {
@@ -531,16 +646,27 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
if (!canBookAppointments) {
setError("Only customer accounts can book appointments from the web app.");
return;
}
if (!adoptionMode && selectedPetIds.length === 0) {
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
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 {
@@ -550,6 +676,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
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`, {
@@ -568,6 +695,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
setSuccess(`Adoption request submitted! ${adoptionPetName} is now marked as Pending. We'll be in touch soon.`);
setEmployeeId("");
loadAdoptions();
return;
}
@@ -630,12 +758,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
if (!user) return null;
const petsToShow = isAdoptionService ? adoptablePets : isCustomerPetService ? customerPets : [];
const petSectionLabel = isAdoptionService ? "Select a Pet to Adopt" : "Select Pet(s)";
const noPetsMessage = isAdoptionService
? "No pets are currently available for adoption."
: "No pets found on your profile.";
return (
<main className="appt-page">
{showAddPetModal && (
@@ -647,18 +769,68 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
)}
<section className="appt-hero">
<h1 className="appt-hero-title">Schedule an Appointment</h1>
<p className="appt-hero-subtitle">Book a service for your pet or schedule a pet adoption visit</p>
<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">New Appointment</h2>
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
{error && <div className="appt-error">{error}</div>}
{success && <div className="appt-success">{success}</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 for appointments.</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
@@ -679,28 +851,30 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
)}
</label>
<label className="appt-label">
Service
{adoptionMode ? (
<div className="appt-locked-field">
Adopting a Pet ({[adoptionPetName, adoptionPetSpecies, adoptionPetBreed].filter(Boolean).join(", ")})
</div>
) : (
<select
className="appt-select"
value={serviceId}
onChange={(e) => handleServiceChange(e.target.value)}
required
>
<option value="">Select a service...</option>
{services.map((s) => (
<option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</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">
@@ -732,7 +906,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
/>
</div>
{storeId && serviceId && appointmentDate && (
{!adoptionMode && storeId && serviceId && appointmentDate && (
<div className="appt-label">
<span>Available Time Slots</span>
{loadingSlots ? (
@@ -756,83 +930,62 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
</div>
)}
{!adoptionMode && serviceId && (
<div className="appt-label">
<span>{petSectionLabel}</span>
{isCustomerPetService && (
<button
type="button"
className="appt-add-pet-btn"
onClick={() => setShowAddPetModal(true)}
>
+ Add New Pet
</button>
)}
{petsToShow.length === 0 ? (
<p className="appt-no-slots">{noPetsMessage}</p>
) : isAdoptionService ? (
<div className="appt-adopt-grid">
{petsToShow.map((p) => (
<label
key={p.petId}
className={`appt-adopt-card ${selectedPetIds.includes(p.petId) ? "appt-adopt-card--selected" : ""}`}
>
<input
type="radio"
name="adoptionPet"
value={p.petId}
checked={selectedPetIds.includes(p.petId)}
onChange={() => togglePet(p.petId)}
className="appt-adopt-radio"
/>
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.petName} className="appt-adopt-img" />
) : (
<div className="appt-adopt-img-placeholder">🐾</div>
)}
<div className="appt-adopt-info">
<span className="appt-adopt-name">{p.petName}</span>
<span className="appt-adopt-detail">{p.petSpecies} · {p.petBreed}</span>
<span className="appt-adopt-detail">Age: {p.petAge}</span>
</div>
</label>
))}
</div>
) : (
<div className="appt-pets-grid">
{petsToShow.map((p) => (
<label
key={p.customerPetId}
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
>
<input
type="checkbox"
checked={selectedPetIds.includes(p.customerPetId)}
onChange={() => togglePet(p.customerPetId)}
className="appt-pet-checkbox"
/>
{p.petName}
<span className="appt-pet-chip-species">({p.species})</span>
</label>
))}
</div>
)}
</div>
)}
<button
type="submit"
className="appt-submit-btn"
disabled={!formValid || submitting}
>
{submitting ? "Booking..." : adoptionMode ? "Schedule Appointment" : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
</button>
{success && <div className="appt-success">{success}</div>}
</>)}
</>)}
</form>
) : null}
<div className="appt-history">
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
{loadingAppointments ? (
<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>
@@ -860,6 +1013,18 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
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>