Appointments, account stuff, adopt a pet changes
This commit is contained in:
640
web/app/appointments/page.js
Normal file
640
web/app/appointments/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user