Appointments, account stuff, adopt a pet changes

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

View File

@@ -38,12 +38,14 @@ export default function PetDetailPage() {
{!loading && !error && pet && (
<PetProfile
petId={pet.petId}
petName={pet.petName}
petSpecies={pet.petSpecies}
petBreed={pet.petBreed}
petAge={pet.petAge}
petStatus={pet.petStatus}
petPrice={pet.petPrice}
imageUrl={pet.imageUrl}
/>
)}
</div>

View File

@@ -119,6 +119,7 @@ export default function AdoptPage() {
petName={pet.petName}
petSpecies={pet.petSpecies}
petStatus={pet.petStatus}
imageUrl={pet.imageUrl}
/>
))}
</div>

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>
);
}

View File

@@ -232,7 +232,7 @@ body {
border-radius: 2px;
}
/* ─── Adopt Page ─────────────────────────────────────────────── */
/* Adopt page */
.adopt-page {
min-height: 100vh;
@@ -373,6 +373,12 @@ body {
line-height: 1;
}
.pet-card-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.pet-card-body {
padding: 1rem 1.25rem 1.25rem;
display: flex;
@@ -459,7 +465,7 @@ body {
color: #555;
}
/* ─── Pet Detail Page ─────────────────────────────────────────── */
/* Pet details */
.pet-detail-page {
min-height: 100vh;
@@ -508,6 +514,12 @@ body {
line-height: 1;
}
.pet-detail-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.pet-detail-info {
flex: 1;
padding: 2.5rem 2.5rem 2.5rem 0;
@@ -599,7 +611,41 @@ body {
background: #e69500;
}
/* ─── Responsive Design ──────────────────────────────────────── */
/* Products Page */
.products-page {
min-height: 100vh;
}
.products-hero {
text-align: center;
padding: 4rem 2rem 3rem;
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
}
.products-hero-title {
font-size: 3rem;
color: #333;
margin-bottom: 1rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.products-hero-subtitle {
font-size: 1.5rem;
color: #666;
margin-bottom: 2rem;
font-weight: 300;
}
.product-card-price {
display: inline-block;
margin-top: 0.4rem;
font-size: 1.05rem;
font-weight: 700;
color: #1a7a3c;
}
/* Responsive Design */
@media (max-width: 1024px) {
.adopt-grid {
@@ -608,11 +654,13 @@ body {
}
@media (max-width: 768px) {
.adopt-hero-title {
.adopt-hero-title,
.products-hero-title {
font-size: 2rem;
}
.adopt-hero-subtitle {
.adopt-hero-subtitle,
.products-hero-subtitle {
font-size: 1.2rem;
}
@@ -641,7 +689,8 @@ body {
grid-template-columns: 1fr;
}
.adopt-hero-title {
.adopt-hero-title,
.products-hero-title {
font-size: 1.6rem;
}
@@ -705,7 +754,7 @@ body {
padding: 2rem 1rem;
}
}
/* ─── Adopt diagnostic additions ────────────────────────────── */
/* Adopt diagnostics */
.adopt-controls-row {
display: flex;
@@ -1020,3 +1069,633 @@ body {
color: #888;
font-size: 1rem;
}
/* Appointments Page */
.appt-page {
min-height: 100vh;
}
.appt-hero {
text-align: center;
padding: 3rem 2rem 2rem;
background: linear-gradient(135deg, #fff8f0 0%, #fff3e0 100%);
}
.appt-hero-title {
font-size: 2.2rem;
font-weight: 800;
color: #222;
margin: 0 0 0.5rem;
}
.appt-hero-subtitle {
font-size: 1.1rem;
color: #666;
margin: 0 0 1rem;
}
.appt-content {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.appt-form {
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.appt-form-title {
font-size: 1.35rem;
font-weight: 700;
color: #222;
margin: 0;
}
.appt-label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
font-weight: 600;
color: #444;
}
.appt-select,
.appt-input {
padding: 0.6rem 0.85rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
background: white;
}
.appt-select:focus,
.appt-input:focus {
border-color: orange;
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2);
}
.appt-service-info {
background: #fff8f0;
border: 1px solid #ffd180;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 0.9rem;
color: #555;
}
.appt-service-info p {
margin: 0;
}
.appt-slots-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.appt-slot-btn {
padding: 0.45rem 0.9rem;
border: 1px solid #ddd;
border-radius: 20px;
background: white;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
}
.appt-slot-btn:hover {
border-color: orange;
background: #fff8f0;
}
.appt-slot-btn--selected {
background: orange;
color: white;
border-color: orange;
}
.appt-slot-btn--selected:hover {
background: #e69500;
}
.appt-slots-loading,
.appt-no-slots {
font-size: 0.9rem;
color: #888;
font-weight: 400;
margin: 0.25rem 0 0;
}
.appt-pets-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.25rem;
}
.appt-pet-chip {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.appt-pet-chip:hover {
border-color: orange;
background: #fff8f0;
}
.appt-pet-chip--selected {
background: #fff3e0;
border-color: orange;
color: #c47600;
}
.appt-pet-chip-species {
font-weight: 400;
color: #888;
}
.appt-pet-chip--selected .appt-pet-chip-species {
color: #c47600;
}
.appt-pet-checkbox {
accent-color: orange;
}
.appt-link {
color: orange;
font-weight: 600;
text-decoration: none;
}
.appt-link:hover {
text-decoration: underline;
}
.appt-submit-btn {
margin-top: 0.5rem;
padding: 0.75rem;
background: orange;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
}
.appt-submit-btn:hover:not(:disabled) {
background: #e69500;
}
.appt-submit-btn:active:not(:disabled) {
transform: scale(0.98);
}
.appt-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.appt-error {
background: #fff0f0;
border: 1px solid #f5c6c6;
color: #c0392b;
border-radius: 8px;
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
.appt-success {
background: #f0fff4;
border: 1px solid #b2dfdb;
color: #1a7a3c;
border-radius: 8px;
padding: 0.65rem 1rem;
font-size: 0.9rem;
}
.appt-loading,
.appt-empty {
text-align: center;
color: #888;
font-size: 0.95rem;
padding: 1rem 0;
}
.appt-history {
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2rem;
}
.appt-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.appt-card {
border: 1px solid #eee;
border-radius: 10px;
padding: 1rem 1.25rem;
transition: box-shadow 0.2s ease;
}
.appt-card:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.appt-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
}
.appt-card-service {
font-weight: 700;
font-size: 1rem;
color: #222;
}
.appt-card-status {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.2rem 0.7rem;
border-radius: 20px;
letter-spacing: 0.03em;
}
.appt-card-status--booked {
background: #e3f2fd;
color: #1565c0;
}
.appt-card-status--completed {
background: #e8f5e9;
color: #2e7d32;
}
.appt-card-status--cancelled {
background: #fce4ec;
color: #c62828;
}
.appt-card-details {
display: flex;
justify-content: space-between;
font-size: 0.88rem;
color: #666;
}
.appt-card-pets {
font-size: 0.85rem;
color: #888;
margin-top: 0.35rem;
}
/* Adoption Pet Selection */
.appt-adopt-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
margin-top: 0.25rem;
}
.appt-adopt-card {
display: flex;
align-items: center;
gap: 0.75rem;
border: 2px solid #eee;
border-radius: 12px;
padding: 0.65rem 0.85rem;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.appt-adopt-card:hover {
border-color: orange;
background: #fffaf5;
}
.appt-adopt-card--selected {
border-color: orange;
background: #fff3e0;
}
.appt-adopt-radio {
display: none;
}
.appt-adopt-img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.appt-adopt-img-placeholder {
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.appt-adopt-info {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.appt-adopt-name {
font-weight: 700;
font-size: 0.9rem;
color: #222;
}
.appt-adopt-detail {
font-size: 0.78rem;
color: #888;
}
@media (max-width: 640px) {
.appt-content {
padding: 1rem;
}
.appt-form,
.appt-history {
padding: 1.25rem;
}
.appt-hero-title {
font-size: 1.6rem;
}
.appt-card-details {
flex-direction: column;
gap: 0.15rem;
}
}
/* Profile Page Layout (with pets section) */
.profile-page-layout {
min-height: calc(100vh - 70px);
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
background: #fafafa;
gap: 2rem;
}
/* Profile Pets Section */
.profile-pets-section {
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
padding: 2rem;
width: 100%;
max-width: 640px;
}
.profile-pets-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.profile-pets-title {
font-size: 1.35rem;
font-weight: 700;
color: #222;
margin: 0;
}
.profile-pets-add-btn {
background: orange;
color: white;
border: none;
border-radius: 20px;
padding: 0.4rem 1rem;
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s ease;
}
.profile-pets-add-btn:hover {
background: #e69500;
}
.profile-pets-empty {
text-align: center;
color: #888;
font-size: 0.95rem;
padding: 1.5rem 0;
}
.profile-pets-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.profile-pet-card {
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid #eee;
border-radius: 12px;
padding: 0.75rem 1rem;
transition: box-shadow 0.2s ease;
}
.profile-pet-card:hover {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.profile-pet-card-img-area {
position: relative;
width: 56px;
height: 56px;
flex-shrink: 0;
}
.profile-pet-card-img {
width: 56px;
height: 56px;
border-radius: 50%;
object-fit: cover;
}
.profile-pet-card-placeholder {
width: 56px;
height: 56px;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.profile-pet-upload-label {
position: absolute;
bottom: -2px;
right: -2px;
width: 22px;
height: 22px;
background: white;
border: 1px solid #ddd;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
cursor: pointer;
transition: border-color 0.2s ease;
}
.profile-pet-upload-label:hover {
border-color: orange;
}
.profile-pet-upload-input {
display: none;
}
.profile-pet-card-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
flex: 1;
min-width: 0;
}
.profile-pet-card-name {
font-weight: 700;
font-size: 0.95rem;
color: #222;
}
.profile-pet-card-detail {
font-size: 0.82rem;
color: #888;
}
.profile-pet-card-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.profile-pet-edit-btn,
.profile-pet-delete-btn {
padding: 0.3rem 0.7rem;
border-radius: 6px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
border: 1px solid #ddd;
background: white;
transition: all 0.2s ease;
}
.profile-pet-edit-btn:hover {
border-color: orange;
color: orange;
}
.profile-pet-delete-btn:hover {
border-color: #c0392b;
color: #c0392b;
}
/* Pet Add/Edit Form */
.profile-pet-form {
background: #fafafa;
border: 1px solid #eee;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.profile-pet-form-title {
font-size: 1rem;
font-weight: 700;
color: #222;
margin: 0;
}
.profile-pet-form-actions {
display: flex;
gap: 0.75rem;
}
.profile-pet-form-actions .appt-submit-btn {
flex: 1;
margin-top: 0;
}
.profile-pet-cancel-btn {
flex: 1;
padding: 0.75rem;
background: white;
color: #666;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.profile-pet-cancel-btn:hover {
border-color: #999;
color: #333;
}

View File

@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import ProductProfile from "@/components/ProductProfile";
const API_BASE = "";
export default function ProductDetailPage() {
const { id } = useParams();
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) {
return;
}
setLoading(true);
setError(null);
fetch(`${API_BASE}/api/v1/products/${id}`)
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
})
.then((data) => setProduct(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
return (
<main className="pet-detail-page">
<div className="pet-detail-container">
<Link href="/products" className="pet-detail-back"> Back to Products</Link>
{loading && <p className="adopt-status-msg">Loading product details...</p>}
{error && <p className="adopt-status-msg adopt-error">{error}</p>}
{!loading && !error && product && (
<ProductProfile
prodName={product.prodName}
categoryName={product.categoryName}
prodDesc={product.prodDesc}
prodPrice={product.prodPrice}
imageUrl={product.imageUrl}
/>
)}
</div>
</main>
);
}

133
web/app/products/page.js Normal file
View File

@@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import ProductCard from "@/components/ProductCard";
const API_BASE = "";
export default function ProductsPage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const PAGE_SIZE = 12;
useEffect(() => {
setLoading(true);
setError(null);
const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "prodId,asc" });
if (query) {
params.set("q", query);
}
fetch(`${API_BASE}/api/v1/products?${params}`)
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
return res.json();
})
.then((data) => {
setProducts(data.content ?? []);
setTotalPages(data.totalPages ?? 0);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [page, query]);
function handleSearch(e) {
e.preventDefault();
setPage(0);
setQuery(search.trim());
}
return (
<main className="products-page">
<section className="products-hero">
<h1 className="products-hero-title">Shop Our Products</h1>
<p className="products-hero-subtitle">Everything your pet needs, all in one place</p>
<div className="title-decoration"></div>
</section>
<section className="adopt-controls">
<div className="adopt-controls-row">
<form className="adopt-search-form" onSubmit={handleSearch}>
<input
className="adopt-search-input"
type="text"
placeholder="Search by name or category..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button className="adopt-search-btn" type="submit">Search</button>
{query && (
<button
className="adopt-clear-btn"
type="button"
onClick={() => { setSearch(""); setQuery(""); setPage(0); }}
>
Clear
</button>
)}
</form>
</div>
</section>
<section className="adopt-grid-section">
{loading && <p className="adopt-status-msg">Loading products...</p>}
{error && (
<div className="adopt-error-box">
<p className="adopt-error-title">Failed to load products</p>
<code className="adopt-error-detail">{error}</code>
</div>
)}
{!loading && !error && products.length === 0 && (
<p className="adopt-status-msg">No products found.</p>
)}
{!loading && !error && products.length > 0 && (
<div className="adopt-grid">
{products.map((product) => (
<ProductCard
key={product.prodId}
prodId={product.prodId}
prodName={product.prodName}
categoryName={product.categoryName}
prodPrice={product.prodPrice}
imageUrl={product.imageUrl}
/>
))}
</div>
)}
{!loading && totalPages > 1 && (
<div className="adopt-pagination">
<button
className="pagination-btn"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span className="pagination-info">Page {page + 1} of {totalPages}</span>
<button
className="pagination-btn"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</section>
</main>
);
}

View File

@@ -1,25 +1,158 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE = "";
export default function ProfilePage() {
const { user, loading, logout } = useAuth();
const {user, token, loading, logout} = useAuth();
const router = useRouter();
const [pets, setPets] = useState([]);
const [loadingPets, setLoadingPets] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingPet, setEditingPet] = useState(null);
const [petName, setPetName] = useState("");
const [species, setSpecies] = useState("");
const [breed, setBreed] = useState("");
const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null);
useEffect(() => {
if (!loading && !user) {
router.replace("/login");
}
}, [user, loading, router]);
const loadPets = useCallback(() => {
if (!token) return;
setLoadingPets(true);
fetch(`${API_BASE}/api/v1/my-pets`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then(setPets)
.catch(() => {})
.finally(() => setLoadingPets(false));
}, [token]);
useEffect(() => {
if (user?.role === "CUSTOMER") {
loadPets();
}
}, [user, loadPets]);
function handleLogout() {
logout();
router.push("/");
}
function openAddForm() {
setEditingPet(null);
setPetName("");
setSpecies("");
setBreed("");
setPetError(null);
setShowForm(true);
}
function openEditForm(pet) {
setEditingPet(pet);
setPetName(pet.petName);
setSpecies(pet.species);
setBreed(pet.breed || "");
setPetError(null);
setShowForm(true);
}
function closeForm() {
setShowForm(false);
setEditingPet(null);
setPetError(null);
}
async function handlePetSubmit(e) {
e.preventDefault();
setPetError(null);
setSubmitting(true);
const url = editingPet
? `${API_BASE}/api/v1/my-pets/${editingPet.customerPetId}`
: `${API_BASE}/api/v1/my-pets`;
try {
const res = await fetch(url, {
method: editingPet ? "PUT" : "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})`);
}
closeForm();
loadPets();
}
catch (err) {
setPetError(err.message);
}
finally {
setSubmitting(false);
}
}
async function handleDeletePet(id) {
if (!confirm("Remove this pet profile?")) {
return;
}
try {
await fetch(`${API_BASE}/api/v1/my-pets/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
loadPets();
}
catch {
}
}
async function handleImageUpload(petId, file) {
const formData = new FormData();
formData.append("image", file);
try {
const res = await fetch(`${API_BASE}/api/v1/my-pets/${petId}/image`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const data = await res.json().catch(() => null);
alert(data?.message || "Failed to upload image");
return;
}
loadPets();
}
catch {
alert("Failed to upload image");
}
}
if (loading || !user) {
return <main className="auth-page"><p className="profile-loading">Loading</p></main>;
}
@@ -30,11 +163,11 @@ export default function ProfilePage() {
{label: "Email", value: user.email},
{label: "Phone", value: user.phone || "—"},
{label: "Role", value: user.role},
...(user.storeName ? [{label: "Store", value: user.storeName}] : []),
...(user.storeName ? [{ label: "Store", value: user.storeName }] : []),
];
return (
<main className="auth-page">
<main className="profile-page-layout">
<div className="profile-card">
<div className="profile-avatar-circle">
{(user.fullName || user.username).charAt(0).toUpperCase()}
@@ -56,6 +189,111 @@ export default function ProfilePage() {
Log Out
</button>
</div>
{user.role === "CUSTOMER" && (
<div className="profile-pets-section">
<div className="profile-pets-header">
<h2 className="profile-pets-title">My Pets</h2>
<button className="profile-pets-add-btn" onClick={openAddForm}>+ Add Pet</button>
</div>
{showForm && (
<form className="profile-pet-form" onSubmit={handlePetSubmit}>
<h3 className="profile-pet-form-title">
{editingPet ? "Edit Pet" : "Add a New Pet"}
</h3>
{petError && <div className="appt-error">{petError}</div>}
<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
<input
className="appt-input"
type="text"
value={species}
onChange={(e) => setSpecies(e.target.value)}
required
maxLength={50}
placeholder="e.g. Dog, Cat, Bird"
/>
</label>
<label className="appt-label">
Breed (optional)
<input
className="appt-input"
type="text"
value={breed}
onChange={(e) => setBreed(e.target.value)}
maxLength={50}
placeholder="e.g. Golden Retriever"
/>
</label>
<div className="profile-pet-form-actions">
<button type="submit" className="appt-submit-btn" disabled={submitting}>
{submitting ? "Saving..." : editingPet ? "Save Changes" : "Add Pet"}
</button>
<button type="button" className="profile-pet-cancel-btn" onClick={closeForm}>
Cancel
</button>
</div>
</form>
)}
{loadingPets ? (
<p className="appt-loading">Loading pets...</p>
) : pets.length === 0 && !showForm ? (
<p className="profile-pets-empty">No pet profiles yet. Add your first pet above!</p>
) : (
<div className="profile-pets-grid">
{pets.map((pet) => (
<div key={pet.customerPetId} className="profile-pet-card">
<div className="profile-pet-card-img-area">
{pet.imageUrl ? (
<img
src={pet.imageUrl}
alt={pet.petName}
className="profile-pet-card-img"
/>
) : (
<div className="profile-pet-card-placeholder">🐾</div>
)}
<label className="profile-pet-upload-label">
<input
type="file"
accept="image/jpeg,image/png,image/gif"
className="profile-pet-upload-input"
onChange={(e) => {
if (e.target.files[0]) handleImageUpload(pet.customerPetId, e.target.files[0]);
e.target.value = "";
}}
/>
📷
</label>
</div>
<div className="profile-pet-card-info">
<span className="profile-pet-card-name">{pet.petName}</span>
<span className="profile-pet-card-detail">{pet.species}</span>
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
</div>
<div className="profile-pet-card-actions">
<button className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
<button className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</main>
);
}