Appointments, account stuff, adopt a pet changes

This commit is contained in:
augmentedpotato
2026-03-30 05:38:15 -06:00
parent cce97b7509
commit 3b8b1e9b97
30 changed files with 2611 additions and 48 deletions

View File

@@ -0,0 +1,640 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
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(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(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((day, i) =>
day === null ? (
<span key={`empty-${i}`} />
) : (
<button
key={day}
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>
);
}
export default function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const preselectedPetId = searchParams.get("petId");
const didPreselectRef = useRef(false);
const [stores, setStores] = 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 [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);
const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false);
useEffect(() => {
if (!authLoading && !user) {
router.push("/login");
}
}, [authLoading, user, router]);
useEffect(() => {
if (!token) {
return;
}
fetch(`${API_BASE}/api/v1/dropdowns/stores`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(setStores)
.catch(() => {});
fetch(`${API_BASE}/api/v1/services?size=100`)
.then((r) => r.json())
.then((data) => setServices(data.content ?? []))
.catch(() => {});
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc`)
.then((r) => r.json())
.then((data) => setAllPets(data.content ?? []))
.catch(() => {});
fetch(`${API_BASE}/api/v1/my-pets`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((data) => setCustomerPets(Array.isArray(data) ? data : []))
.catch(() => {});
}, [token]);
useEffect(() => {
if (didPreselectRef.current) {
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;
}, [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]);
useEffect(() => {
loadAppointments();
}, [loadAppointments]);
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 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"
);
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 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();
d.setDate(d.getDate() + 1);
return d.toISOString().split("T")[0];
}
const formValid =
storeId && serviceId && appointmentDate && appointmentTime && selectedPetIds.length > 0;
async function handleSubmit(e) {
e.preventDefault();
setError(null);
setSuccess(null);
if (!user?.customerId) {
setError("Customer account not found. Please contact support.");
return;
}
if (selectedPetIds.length === 0) {
setError(isAdoptionService ? "Please select a pet to adopt." : "Please select at least one pet.");
return;
}
setSubmitting(true);
try {
const body = {
customerId: user.customerId,
storeId: Number(storeId),
serviceId: Number(serviceId),
appointmentDate,
appointmentTime: appointmentTime + ":00",
appointmentStatus: "Booked",
};
if (isCustomerPetService) {
body.customerPetIds = selectedPetIds;
}
else {
body.petIds = selectedPetIds;
}
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();
}
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;
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. Please add your pets in your profile before booking.";
return (
<main className="appt-page">
<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>
<div className="title-decoration"></div>
</section>
<section className="appt-content">
<form className="appt-form" onSubmit={handleSubmit}>
<h2 className="appt-form-title">New Appointment</h2>
{error && <div className="appt-error">{error}</div>}
{success && <div className="appt-success">{success}</div>}
<label className="appt-label">
Store Location
<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>
<label className="appt-label">
Service
<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>
{selectedService && (
<div className="appt-service-info">
<p>{selectedService.serviceDesc}</p>
</div>
)}
<div className="appt-label">
Date
<DatePicker
value={appointmentDate}
minDate={getMinDate()}
onChange={setAppointmentDate}
/>
</div>
{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>
)}
{serviceId && (
<div className="appt-label">
<span>{petSectionLabel}</span>
{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..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"}
</button>
</form>
<div className="appt-history">
<h2 className="appt-form-title">Your Appointments</h2>
{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>
)}
</div>
))}
</div>
)}
</div>
</section>
</main>
);
}