Styling refactor
This commit is contained in:
@@ -39,8 +39,7 @@ function getAvailableServices(services, species) {
|
||||
}
|
||||
|
||||
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
|
||||
];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
function DatePicker({ value, minDate, onChange }) {
|
||||
const today = new Date();
|
||||
@@ -53,24 +52,13 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear((y) => y + 1); }
|
||||
else { setViewMonth((m) => m + 1); }
|
||||
}
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
|
||||
@@ -82,11 +70,8 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
|
||||
function selectDay(day) {
|
||||
const d = new Date(viewYear, viewMonth, day);
|
||||
if (d < min) {
|
||||
return;
|
||||
}
|
||||
if (d < min) return;
|
||||
const iso = `${viewYear}-${String(viewMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
onChange(iso);
|
||||
}
|
||||
|
||||
@@ -107,94 +92,16 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
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 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 style={s.grid}>
|
||||
<div className="grid grid-cols-7 gap-[3px] p-[0.6rem]">
|
||||
{DAYS.map((d) => (
|
||||
<span key={d} style={s.dayName}>{d}</span>
|
||||
<span key={d} className="text-center text-[0.7rem] font-bold text-[#aaa] py-1 uppercase">{d}</span>
|
||||
))}
|
||||
{cells.map(({ key, day }) =>
|
||||
day === null ? (
|
||||
@@ -203,11 +110,8 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
style={{
|
||||
...s.dayBase,
|
||||
...(isSelected(day) ? s.daySelected : {}),
|
||||
...(isDisabled(day) ? s.dayDisabled : {}),
|
||||
}}
|
||||
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)}
|
||||
@@ -218,7 +122,7 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
)}
|
||||
</div>
|
||||
{parsed && (
|
||||
<div style={s.selectedLabel}>
|
||||
<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>
|
||||
)}
|
||||
@@ -226,6 +130,13 @@ function DatePicker({ value, minDate, onChange }) {
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function AddPetModal({ token, onClose, onAdded }) {
|
||||
const [petName, setPetName] = useState("");
|
||||
const [species, setSpecies] = useState("");
|
||||
@@ -267,56 +178,38 @@ function AddPetModal({ token, onClose, onAdded }) {
|
||||
}
|
||||
|
||||
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">
|
||||
<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="appt-input"
|
||||
type="text"
|
||||
value={petName}
|
||||
onChange={(e) => setPetName(e.target.value)}
|
||||
required
|
||||
maxLength={50}
|
||||
/>
|
||||
<input className={inputCls} type="text" value={petName} onChange={(e) => setPetName(e.target.value)} required maxLength={50} />
|
||||
</label>
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Species
|
||||
<select
|
||||
className="appt-select"
|
||||
value={species}
|
||||
onChange={(e) => { setSpecies(e.target.value); setBreed(""); }}
|
||||
required
|
||||
>
|
||||
<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="appt-label">
|
||||
<label className={labelCls}>
|
||||
Breed
|
||||
<select
|
||||
className="appt-select"
|
||||
value={breed}
|
||||
onChange={(e) => setBreed(e.target.value)}
|
||||
required
|
||||
disabled={!species}
|
||||
>
|
||||
<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="profile-pet-form-actions">
|
||||
<button type="submit" className="appt-submit-btn" disabled={submitting}>
|
||||
<div className="flex gap-3">
|
||||
<button type="submit" className={submitBtnCls} disabled={submitting}>
|
||||
{submitting ? "Saving..." : "Add Pet"}
|
||||
</button>
|
||||
<button type="button" className="profile-pet-cancel-btn" onClick={onClose}>
|
||||
<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>
|
||||
@@ -332,7 +225,6 @@ function AppointmentsPage() {
|
||||
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") || "";
|
||||
@@ -345,7 +237,6 @@ function AppointmentsPage() {
|
||||
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);
|
||||
@@ -388,17 +279,15 @@ function AppointmentsPage() {
|
||||
const [showPastAppts, setShowPastAppts] = useState(false);
|
||||
const [showPastAdoptions, setShowPastAdoptions] = useState(false);
|
||||
|
||||
const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
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);
|
||||
@@ -431,9 +320,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, canBookAppointments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (!token) return;
|
||||
|
||||
fetch(`${API_BASE}/api/v1/dropdowns/stores`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -459,10 +346,8 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
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];
|
||||
@@ -474,14 +359,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preselectedPetId || services.length === 0 || allPets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adoptionSvc = services.find((s) =>
|
||||
s.serviceName.toLowerCase().includes("adopt")
|
||||
);
|
||||
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));
|
||||
}
|
||||
@@ -574,11 +454,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
}, [token, storeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!employees.length) {
|
||||
setEmployeeId("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!employees.length) { setEmployeeId(""); return; }
|
||||
const currentExists = employees.some((employee) => String(employee.id) === String(employeeId));
|
||||
if (!currentExists) {
|
||||
setEmployeeId(String(employees[0].id));
|
||||
@@ -589,7 +465,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
if (!storeId || !serviceId || !appointmentDate) {
|
||||
setAvailableSlots([]);
|
||||
setAppointmentTime("");
|
||||
|
||||
return;
|
||||
}
|
||||
setLoadingSlots(true);
|
||||
@@ -597,10 +472,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
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");
|
||||
}
|
||||
|
||||
if (!r.ok) throw new Error("Failed to check availability");
|
||||
return r.json();
|
||||
})
|
||||
.then(setAvailableSlots)
|
||||
@@ -640,13 +512,11 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -686,7 +556,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
try {
|
||||
if (adoptionMode) {
|
||||
// Submit an adoption request directly to the adoption table
|
||||
const body = {
|
||||
petId: Number(adoptionPetId),
|
||||
employeeId: employeeId ? Number(employeeId) : undefined,
|
||||
@@ -740,7 +609,6 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
|
||||
throw new Error(data?.message || data?.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -753,22 +621,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
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 className="min-h-screen">
|
||||
<p className="text-center text-[#666] py-12">Loading...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -776,7 +643,7 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<main className="appt-page">
|
||||
<main className="min-h-screen">
|
||||
{showAddPetModal && (
|
||||
<AddPetModal
|
||||
token={token}
|
||||
@@ -785,63 +652,68 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 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="appt-content">
|
||||
<section className="max-w-[1200px] mx-auto px-8 pb-16 flex gap-8 items-start max-[900px]:flex-col">
|
||||
{canBookAppointments ? (
|
||||
<form className="appt-form" onSubmit={handleSubmit}>
|
||||
<h2 className="appt-form-title">{adoptionMode ? "New Adoption" : "New Appointment"}</h2>
|
||||
<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="appt-error" ref={errorRef}>{error}</div>}
|
||||
{error && <div className={errorCls} ref={errorRef}>{error}</div>}
|
||||
|
||||
{adoptionMode && adoptionVerifyLoading && (
|
||||
<p className="appt-loading">Verifying pet details…</p>
|
||||
<p className="text-[#666] text-center py-2">Verifying pet details…</p>
|
||||
)}
|
||||
{adoptionMode && adoptionVerifyError && (
|
||||
<div className="appt-error">{adoptionVerifyError}</div>
|
||||
<div className={errorCls}>{adoptionVerifyError}</div>
|
||||
)}
|
||||
|
||||
{(!adoptionMode || adoptionVerified) && (<>
|
||||
|
||||
{/* ADOPTION MODE: locked pet + store */}
|
||||
{adoptionMode && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Pet
|
||||
<div className="appt-locked-field">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* STEP 1 (non-adoption): select a pet first */}
|
||||
{!adoptionMode && (
|
||||
<div className="appt-label">
|
||||
<div className={labelCls}>
|
||||
<span>Select Your Pet</span>
|
||||
{eligiblePets.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
<p className="text-[#888] text-[0.9rem] m-0">
|
||||
You have no adopted pets available.{" "}
|
||||
<Link href="/profile">Add a pet on your profile page.</Link>
|
||||
<Link href="/profile" className="text-[#e68672] hover:underline">Add a pet on your profile page.</Link>
|
||||
</p>
|
||||
) : (
|
||||
<div className="appt-pets-grid">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{eligiblePets.map((p) => (
|
||||
<label
|
||||
key={p.customerPetId}
|
||||
className={`appt-pet-chip ${selectedPetIds.includes(p.customerPetId) ? "appt-pet-chip--selected" : ""}`}
|
||||
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="appt-pet-checkbox"
|
||||
className="hidden"
|
||||
/>
|
||||
{p.petName}
|
||||
<span className="appt-pet-chip-species">({p.species})</span>
|
||||
<span className="text-[#888] font-normal">({p.species})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -849,20 +721,14 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remaining fields — shown after pet selected (or always in adoption mode) */}
|
||||
{(adoptionMode || selectedPetIds.length > 0) && (<>
|
||||
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Store Location
|
||||
{adoptionMode ? (
|
||||
<div className="appt-locked-field">{adoptionStoreName || "Pet's store"}</div>
|
||||
<div className="px-[0.85rem] py-[0.6rem] border border-[#ddd] rounded-lg bg-[#f5f5f5] text-[#555]">{adoptionStoreName || "Pet's store"}</div>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={storeId}
|
||||
onChange={(e) => setStoreId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<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>
|
||||
@@ -872,19 +738,14 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</label>
|
||||
|
||||
{!adoptionMode && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Service
|
||||
{availableServices.length === 0 ? (
|
||||
<p className="appt-no-slots">
|
||||
<p className="text-[#888] text-[0.9rem] m-0">
|
||||
No services are available for {selectedPet?.species || "this pet"}.
|
||||
</p>
|
||||
) : (
|
||||
<select
|
||||
className="appt-select"
|
||||
value={serviceId}
|
||||
onChange={(e) => handleServiceChange(e.target.value)}
|
||||
required
|
||||
>
|
||||
<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}>
|
||||
@@ -897,13 +758,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
{employees.length > 0 && (
|
||||
<label className="appt-label">
|
||||
<label className={labelCls}>
|
||||
Employee
|
||||
<select
|
||||
className="appt-select"
|
||||
value={employeeId}
|
||||
onChange={(e) => setEmployeeId(e.target.value)}
|
||||
>
|
||||
<select className={selectCls} value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
{employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>{employee.label}</option>
|
||||
))}
|
||||
@@ -912,12 +769,12 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
)}
|
||||
|
||||
{!adoptionMode && selectedService && (
|
||||
<div className="appt-service-info">
|
||||
<p>{selectedService.serviceDesc}</p>
|
||||
<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="appt-label">
|
||||
<div className={labelCls}>
|
||||
Date
|
||||
<DatePicker
|
||||
value={appointmentDate}
|
||||
@@ -927,19 +784,22 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
|
||||
{!adoptionMode && storeId && serviceId && appointmentDate && (
|
||||
<div className="appt-label">
|
||||
<div className={labelCls}>
|
||||
<span>Available Time Slots</span>
|
||||
{loadingSlots ? (
|
||||
<p className="appt-slots-loading">Checking availability...</p>
|
||||
<p className="text-[#888] text-[0.9rem] m-0">Checking availability...</p>
|
||||
) : availableSlots.length === 0 ? (
|
||||
<p className="appt-no-slots">No available slots for this date. Please try another date.</p>
|
||||
<p className="text-[#888] text-[0.9rem] m-0">No available slots for this date. Please try another date.</p>
|
||||
) : (
|
||||
<div className="appt-slots-grid">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableSlots.map((slot) => (
|
||||
<button
|
||||
key={slot}
|
||||
type="button"
|
||||
className={`appt-slot-btn ${appointmentTime === slot ? "appt-slot-btn--selected" : ""}`}
|
||||
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)}
|
||||
@@ -952,24 +812,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
|
||||
</>)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="appt-submit-btn"
|
||||
disabled={!formValid || submitting}
|
||||
>
|
||||
<button type="submit" className={submitBtnCls} disabled={!formValid || submitting}>
|
||||
{submitting ? "Booking..." : adoptionMode ? "Schedule Adoption" : "Book Appointment"}
|
||||
</button>
|
||||
|
||||
{success && <div className="appt-success">{success}</div>}
|
||||
{success && <div className={successCls}>{success}</div>}
|
||||
|
||||
</>)}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
<div className="appt-history" ref={historyRef}>
|
||||
<h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
|
||||
{/* 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="appt-loading">Loading appointments...</p>
|
||||
<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");
|
||||
@@ -980,35 +837,35 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
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="appt-empty">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
|
||||
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAppts.length === 0 ? "No active appointments." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
<div className="flex flex-col gap-3">
|
||||
{filteredActive.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()}`}>
|
||||
<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="appt-card-details">
|
||||
<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="appt-card-pets">Pet: {a.petName}</div>
|
||||
<div className="text-[0.85rem] text-[#888] mb-2">Pet: {a.petName}</div>
|
||||
)}
|
||||
<div className="appt-card-actions">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
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)}
|
||||
>
|
||||
@@ -1020,26 +877,26 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
{pastAppts.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAppts((v) => !v)}>
|
||||
<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)}>
|
||||
{showPastAppts ? "Hide" : "Show"} past appointments ({pastAppts.length})
|
||||
</button>
|
||||
{showPastAppts && (
|
||||
<div className="appt-list appt-list--past">
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAppts.map((a) => (
|
||||
<div key={a.appointmentId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.serviceName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.appointmentStatus?.toLowerCase()}`}>
|
||||
<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="appt-card-details">
|
||||
<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="appt-card-pets">Pet: {a.petName}</div>
|
||||
<div className="text-[0.85rem] text-[#888] mt-1">Pet: {a.petName}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1051,9 +908,9 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
);
|
||||
})()}
|
||||
|
||||
<h2 className="appt-form-title" style={{ marginTop: "2rem" }}>{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
|
||||
<h2 className="text-xl font-bold text-[#333] m-0 mt-4">{canBookAppointments ? "Your Adoptions" : "Adoptions"}</h2>
|
||||
{loadingAdoptions ? (
|
||||
<p className="appt-loading">Loading adoptions...</p>
|
||||
<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");
|
||||
@@ -1064,32 +921,32 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
className="appt-search"
|
||||
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="appt-empty">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
|
||||
<p className="text-[#888] text-[0.9rem] py-4 m-0">{activeAdoptions.length === 0 ? "No active adoption requests." : "No results."}</p>
|
||||
) : (
|
||||
<div className="appt-list">
|
||||
<div className="flex flex-col gap-3">
|
||||
{filteredActive.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()}`}>
|
||||
<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="appt-card-details">
|
||||
<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="appt-card-actions">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="appt-cancel-btn"
|
||||
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)}
|
||||
>
|
||||
@@ -1101,21 +958,21 @@ const canBookAppointments = user?.role === "CUSTOMER" || user?.role === "ADMIN";
|
||||
</div>
|
||||
)}
|
||||
{pastAdoptions.length > 0 && (
|
||||
<div className="appt-past-section">
|
||||
<button className="appt-past-toggle" onClick={() => setShowPastAdoptions((v) => !v)}>
|
||||
<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)}>
|
||||
{showPastAdoptions ? "Hide" : "Show"} past adoptions ({pastAdoptions.length})
|
||||
</button>
|
||||
{showPastAdoptions && (
|
||||
<div className="appt-list appt-list--past">
|
||||
<div className="flex flex-col gap-3 mt-3 opacity-75">
|
||||
{pastAdoptions.map((a) => (
|
||||
<div key={a.adoptionId} className="appt-card appt-card--past">
|
||||
<div className="appt-card-header">
|
||||
<span className="appt-card-service">{a.petName}</span>
|
||||
<span className={`appt-card-status appt-card-status--${a.adoptionStatus?.toLowerCase()}`}>
|
||||
<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="appt-card-details">
|
||||
<div className="flex gap-4 text-[0.85rem] text-[#666] flex-wrap">
|
||||
<span>{a.sourceStoreName}</span>
|
||||
<span>{a.adoptionDate}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user