Merge pull request #108 from RecentRunner/web-more-fixes

Web more fixes
This commit was merged in pull request #108.
This commit is contained in:
2026-04-03 18:13:18 -06:00
committed by GitHub
19 changed files with 842 additions and 318 deletions

37
web/app/about/page.js Normal file
View File

@@ -0,0 +1,37 @@
export default function AboutPage() {
return (
<main className="info-page">
<section className="info-hero">
<h1 className="info-title">About Leon&apos;s Pet Store</h1>
<p className="info-subtitle">Pet care, adoption support, grooming, and everyday essentials in one place.</p>
<div className="title-decoration"></div>
</section>
<section className="info-content">
<div className="info-card">
<h2>What We Do</h2>
<p>
Leon&apos;s Pet Store connects families with adoptable pets, helpful services, and quality products for day-to-day pet care.
</p>
</div>
<div className="info-card">
<h2>Our Focus</h2>
<ul className="info-list">
<li>Support responsible pet adoption</li>
<li>Provide grooming and care services</li>
<li>Offer reliable pet supplies and essentials</li>
<li>Create a friendly experience for customers and staff</li>
</ul>
</div>
<div className="info-card">
<h2>Visit the Store</h2>
<p>
Browse adoptable pets, schedule appointments, shop products, or contact the team for help finding the right fit for a pet and household.
</p>
</div>
</section>
</main>
);
}

View File

@@ -15,8 +15,6 @@ export default function PetDetailPage() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setLoading(true);
setError(null);
fetch(`${API_BASE}/api/v1/pets/${id}`) fetch(`${API_BASE}/api/v1/pets/${id}`)
.then((res) => { .then((res) => {

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import PetCard from "@/components/PetCard"; import PetCard from "@/components/PetCard";
import { fetchAllPages } from "@/lib/fetchAllPages";
const API_BASE = ""; const API_BASE = "";
@@ -12,10 +13,8 @@ export default function AdoptPage() {
const [health, setHealth] = useState(null); const [health, setHealth] = useState(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const PAGE_SIZE = 12; const PAGE_SIZE = 100;
useEffect(() => { useEffect(() => {
fetch(`${API_BASE}/api/v1/health`) fetch(`${API_BASE}/api/v1/health`)
@@ -27,25 +26,29 @@ export default function AdoptPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "id,asc" }); fetchAllPages((page) => {
if (query) params.set("q", query); const params = new URLSearchParams({
page: String(page),
fetch(`${API_BASE}/api/v1/pets?${params}`) size: String(PAGE_SIZE),
.then((res) => { sort: "id,asc",
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); status: "Available",
return res.json(); });
}) if (query) {
.then((data) => { params.set("q", query);
setPets(data.content ?? []); }
setTotalPages(data.totalPages ?? 0); return `${API_BASE}/api/v1/pets?${params}`;
})
.then((allPets) => {
setPets(allPets);
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [page, query]); }, [query]);
function handleSearch(e) { function handleSearch(e) {
e.preventDefault(); e.preventDefault();
setPage(0); setLoading(true);
setError(null);
setQuery(search.trim()); setQuery(search.trim());
} }
@@ -63,7 +66,7 @@ export default function AdoptPage() {
<input <input
className="adopt-search-input" className="adopt-search-input"
type="text" type="text"
placeholder="Search by name or species..." placeholder="Search by name, species, or breed..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
@@ -72,7 +75,7 @@ export default function AdoptPage() {
<button <button
className="adopt-clear-btn" className="adopt-clear-btn"
type="button" type="button"
onClick={() => { setSearch(""); setQuery(""); setPage(0); }} onClick={() => { setLoading(true); setError(null); setSearch(""); setQuery(""); }}
> >
Clear Clear
</button> </button>
@@ -125,25 +128,6 @@ export default function AdoptPage() {
</div> </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> </section>
</main> </main>
); );

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
@@ -68,8 +69,12 @@ function DatePicker({ value, minDate, onChange }) {
} }
const cells = []; const cells = [];
for (let i = 0; i < firstDay; i++) cells.push(null); for (let i = 0; i < firstDay; i++) {
for (let d = 1; d <= daysInMonth; d++) cells.push(d); cells.push({ key: `empty-${viewYear}-${viewMonth}-${String(i)}`, day: null });
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push({ key: `day-${viewYear}-${viewMonth}-${String(d)}`, day: d });
}
const s = { const s = {
widget: { widget: {
@@ -160,12 +165,12 @@ function DatePicker({ value, minDate, onChange }) {
{DAYS.map((d) => ( {DAYS.map((d) => (
<span key={d} style={s.dayName}>{d}</span> <span key={d} style={s.dayName}>{d}</span>
))} ))}
{cells.map((day, i) => {cells.map(({ key, day }) =>
day === null ? ( day === null ? (
<span key={`empty-${i}`} /> <span key={key} />
) : ( ) : (
<button <button
key={day} key={key}
type="button" type="button"
style={{ style={{
...s.dayBase, ...s.dayBase,
@@ -190,7 +195,7 @@ function DatePicker({ value, minDate, onChange }) {
); );
} }
export default function AppointmentsPage() { function AppointmentsPage() {
const { user, token, loading: authLoading } = useAuth(); const { user, token, loading: authLoading } = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -217,12 +222,15 @@ export default function AppointmentsPage() {
const [appointments, setAppointments] = useState([]); const [appointments, setAppointments] = useState([]);
const [loadingAppointments, setLoadingAppointments] = useState(false); const [loadingAppointments, setLoadingAppointments] = useState(false);
const canBookAppointments = user?.role === "CUSTOMER";
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
router.push("/login"); const target = preselectedPetId ? `/appointments?petId=${encodeURIComponent(preselectedPetId)}` : "/appointments";
router.push(`/login?next=${encodeURIComponent(target)}`);
} }
}, [authLoading, user, router]); }, [authLoading, user, router, preselectedPetId]);
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
@@ -241,18 +249,20 @@ export default function AppointmentsPage() {
.then((data) => setServices(data.content ?? [])) .then((data) => setServices(data.content ?? []))
.catch(() => {}); .catch(() => {});
fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc`) fetch(`${API_BASE}/api/v1/pets?size=200&sort=id,asc&status=Available`)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setAllPets(data.content ?? [])) .then((data) => setAllPets(data.content ?? []))
.catch(() => {}); .catch(() => {});
fetch(`${API_BASE}/api/v1/my-pets`, { if (canBookAppointments) {
headers: { Authorization: `Bearer ${token}` }, fetch(`${API_BASE}/api/v1/my-pets`, {
}) headers: { Authorization: `Bearer ${token}` },
.then((r) => r.json()) })
.then((data) => setCustomerPets(Array.isArray(data) ? data : [])) .then((r) => r.json())
.catch(() => {}); .then((data) => setCustomerPets(Array.isArray(data) ? data : []))
}, [token]); .catch(() => {});
}
}, [token, canBookAppointments]);
useEffect(() => { useEffect(() => {
if (didPreselectRef.current) { if (didPreselectRef.current) {
@@ -366,6 +376,12 @@ export default function AppointmentsPage() {
setError(null); setError(null);
setSuccess(null); setSuccess(null);
if (!canBookAppointments) {
setError("Only customer accounts can book appointments from the web app.");
return;
}
if (!user?.customerId) { if (!user?.customerId) {
setError("Customer account not found. Please contact support."); setError("Customer account not found. Please contact support.");
@@ -458,149 +474,151 @@ export default function AppointmentsPage() {
</section> </section>
<section className="appt-content"> <section className="appt-content">
<form className="appt-form" onSubmit={handleSubmit}> {canBookAppointments ? (
<h2 className="appt-form-title">New Appointment</h2> <form className="appt-form" onSubmit={handleSubmit}>
<h2 className="appt-form-title">New Appointment</h2>
{error && <div className="appt-error">{error}</div>} {error && <div className="appt-error">{error}</div>}
{success && <div className="appt-success">{success}</div>} {success && <div className="appt-success">{success}</div>}
<label className="appt-label"> <label className="appt-label">
Store Location Store Location
<select <select
className="appt-select" className="appt-select"
value={storeId} value={storeId}
onChange={(e) => setStoreId(e.target.value)} onChange={(e) => setStoreId(e.target.value)}
required required
> >
<option value="">Select a store...</option> <option value="">Select a store...</option>
{stores.map((s) => ( {stores.map((s) => (
<option key={s.id} value={s.id}>{s.label}</option> <option key={s.id} value={s.id}>{s.label}</option>
))} ))}
</select> </select>
</label> </label>
<label className="appt-label"> <label className="appt-label">
Service Service
<select <select
className="appt-select" className="appt-select"
value={serviceId} value={serviceId}
onChange={(e) => handleServiceChange(e.target.value)} onChange={(e) => handleServiceChange(e.target.value)}
required required
> >
<option value="">Select a service...</option> <option value="">Select a service...</option>
{services.map((s) => ( {services.map((s) => (
<option key={s.serviceId} value={s.serviceId}> <option key={s.serviceId} value={s.serviceId}>
{s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min) {s.serviceName} ${Number(s.servicePrice).toFixed(2)} ({s.serviceDuration} min)
</option> </option>
))} ))}
</select> </select>
</label> </label>
{selectedService && ( {selectedService && (
<div className="appt-service-info"> <div className="appt-service-info">
<p>{selectedService.serviceDesc}</p> <p>{selectedService.serviceDesc}</p>
</div> </div>
)} )}
<div className="appt-label">
Date
<DatePicker
value={appointmentDate}
minDate={getMinDate()}
onChange={setAppointmentDate}
/>
</div>
{storeId && serviceId && appointmentDate && (
<div className="appt-label"> <div className="appt-label">
<span>Available Time Slots</span> Date
{loadingSlots ? ( <DatePicker
<p className="appt-slots-loading">Checking availability...</p> value={appointmentDate}
) : availableSlots.length === 0 ? ( minDate={getMinDate()}
<p className="appt-no-slots">No available slots for this date. Please try another date.</p> onChange={setAppointmentDate}
) : ( />
<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> </div>
)}
{serviceId && ( {storeId && serviceId && appointmentDate && (
<div className="appt-label"> <div className="appt-label">
<span>{petSectionLabel}</span> <span>Available Time Slots</span>
{petsToShow.length === 0 ? ( {loadingSlots ? (
<p className="appt-no-slots">{noPetsMessage}</p> <p className="appt-slots-loading">Checking availability...</p>
) : isAdoptionService ? ( ) : availableSlots.length === 0 ? (
<div className="appt-adopt-grid"> <p className="appt-no-slots">No available slots for this date. Please try another date.</p>
{petsToShow.map((p) => ( ) : (
<label <div className="appt-slots-grid">
key={p.petId} {availableSlots.map((slot) => (
className={`appt-adopt-card ${selectedPetIds.includes(p.petId) ? "appt-adopt-card--selected" : ""}`} <button
> key={slot}
<input type="button"
type="radio" className={`appt-slot-btn ${appointmentTime === slot ? "appt-slot-btn--selected" : ""}`}
name="adoptionPet" onClick={() => setAppointmentTime(slot)}
value={p.petId} >
checked={selectedPetIds.includes(p.petId)} {formatTime(slot)}
onChange={() => togglePet(p.petId)} </button>
className="appt-adopt-radio" ))}
/> </div>
{p.imageUrl ? ( )}
<img src={p.imageUrl} alt={p.petName} className="appt-adopt-img" /> </div>
) : ( )}
<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 {serviceId && (
type="submit" <div className="appt-label">
className="appt-submit-btn" <span>{petSectionLabel}</span>
disabled={!formValid || submitting} {petsToShow.length === 0 ? (
> <p className="appt-no-slots">{noPetsMessage}</p>
{submitting ? "Booking..." : isAdoptionService ? "Schedule Adoption Visit" : "Book Appointment"} ) : isAdoptionService ? (
</button> <div className="appt-adopt-grid">
</form> {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>
) : null}
<div className="appt-history"> <div className="appt-history">
<h2 className="appt-form-title">Your Appointments</h2> <h2 className="appt-form-title">{canBookAppointments ? "Your Appointments" : "Appointments"}</h2>
{loadingAppointments ? ( {loadingAppointments ? (
<p className="appt-loading">Loading appointments...</p> <p className="appt-loading">Loading appointments...</p>
) : appointments.length === 0 ? ( ) : appointments.length === 0 ? (
@@ -638,3 +656,7 @@ export default function AppointmentsPage() {
</main> </main>
); );
} }
export default dynamic(() => Promise.resolve(AppointmentsPage), {
ssr: false,
});

73
web/app/contact/page.js Normal file
View File

@@ -0,0 +1,73 @@
const LOCATIONS = [
{
name: "Downtown Branch",
address: "123 Main St",
phone: "(123) 456-7890",
email: "downtown@petshop.com",
},
{
name: "North Branch",
address: "456 North Ave",
phone: "(987) 654-3210",
email: "north@petshop.com",
},
{
name: "West Side Store",
address: "789 West Blvd",
phone: "(555) 123-4567",
email: "westside@petshop.com",
},
];
const PERSONNEL = [
{ name: "John Doe", role: "Store Manager" },
{ name: "Sara Smith", role: "Staff" },
{ name: "Michael Johnson", role: "Grooming Team" },
];
export default function ContactPage() {
return (
<main className="info-page">
<section className="info-hero">
<h1 className="info-title">Contact Us</h1>
<p className="info-subtitle">Reach the team, find a location, or connect with store personnel.</p>
<div className="title-decoration"></div>
</section>
<section className="info-content">
<div className="info-card">
<h2>General Contact</h2>
<p>Email: support@petshop.com</p>
<p>Phone: (000) 000-0000</p>
<p>Hours: MonSat, 9:00 AM 6:00 PM</p>
</div>
<div className="info-card">
<h2>Store Locations</h2>
<div className="info-card-grid">
{LOCATIONS.map((location) => (
<article key={location.name} className="info-mini-card">
<h3>{location.name}</h3>
<p>{location.address}</p>
<p>{location.phone}</p>
<p>{location.email}</p>
</article>
))}
</div>
</div>
<div className="info-card">
<h2>Store Personnel</h2>
<div className="info-card-grid">
{PERSONNEL.map((person) => (
<article key={person.name} className="info-mini-card">
<h3>{person.name}</h3>
<p>{person.role}</p>
</article>
))}
</div>
</div>
</section>
</main>
);
}

View File

@@ -617,6 +617,74 @@ body {
min-height: 100vh; min-height: 100vh;
} }
.info-page {
min-height: 100vh;
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
}
.info-hero {
text-align: center;
padding: 4rem 2rem 3rem;
}
.info-title {
font-size: 3rem;
color: #333;
margin-bottom: 1rem;
font-weight: 700;
}
.info-subtitle {
font-size: 1.25rem;
color: #666;
margin-bottom: 1.5rem;
}
.info-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem 4rem;
display: grid;
gap: 1.5rem;
}
.info-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
}
.info-card h2 {
margin-top: 0;
margin-bottom: 1rem;
color: #222;
}
.info-list {
margin: 0;
padding-left: 1.2rem;
display: grid;
gap: 0.5rem;
}
.info-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.info-mini-card {
background: #fff8ee;
border-radius: 12px;
padding: 1rem;
}
.info-mini-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.products-hero { .products-hero {
text-align: center; text-align: center;
padding: 4rem 2rem 3rem; padding: 4rem 2rem 3rem;
@@ -1008,6 +1076,13 @@ body {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
overflow: hidden;
}
.profile-avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
} }
.profile-name { .profile-name {
@@ -1037,6 +1112,37 @@ body {
padding-top: 1rem; padding-top: 1rem;
} }
.profile-update-form {
width: 100%;
display: grid;
gap: 0.9rem;
}
.profile-update-title {
margin: 0.25rem 0 0;
font-size: 1.1rem;
color: #222;
}
.profile-avatar-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.profile-avatar-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.65rem 1rem;
border-radius: 8px;
background: #fff3e0;
color: #a65c00;
font-weight: 600;
cursor: pointer;
}
.profile-field-row { .profile-field-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -1,12 +1,25 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
export default function LoginPage() { function resolveNextPath(candidate) {
if (!candidate || !candidate.startsWith("/")) {
return "/";
}
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) {
return "/";
}
return candidate;
}
function LoginPage() {
const {login} = useAuth(); const {login} = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -20,7 +33,7 @@ export default function LoginPage() {
try { try {
await login(username, password); await login(username, password);
router.push("/"); router.push(resolveNextPath(searchParams.get("next")));
} }
catch (err) { catch (err) {
@@ -67,9 +80,13 @@ export default function LoginPage() {
<p className="auth-switch"> <p className="auth-switch">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<a href="/register" className="auth-switch-link">Register here</a> <Link href={searchParams.get("next") ? `/register?next=${encodeURIComponent(searchParams.get("next"))}` : "/register"} className="auth-switch-link">Register here</Link>
</p> </p>
</div> </div>
</main> </main>
); );
} }
export default dynamic(() => Promise.resolve(LoginPage), {
ssr: false,
});

View File

@@ -4,15 +4,21 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export default function Home() { const slideshowImages = [
//Slideshow images array {id: "slide-1", src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
const slideshowImages = [ {id: "slide-2", src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"},
{src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"}, {id: "slide-3", src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"},
{src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"}, {id: "slide-4", src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
{src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"}, ];
{src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
];
const navImages = [
{id: "nav-adopt", src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"},
{id: "nav-products", src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/products", title: "Online Store"},
{id: "nav-appointments", src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"},
{id: "nav-about", src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us"},
];
export default function Home() {
const [currentSlide, setCurrentSlide] = useState(0); const [currentSlide, setCurrentSlide] = useState(0);
//Auto-advance slideshow //Auto-advance slideshow
@@ -21,15 +27,7 @@ export default function Home() {
const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500); const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [slideshowImages.length]); }, []);
//Hyperlinks to other pages
const navImages = [
{src: "/images/home/navimages/adopt.jpg", alt: "Adopt a Pet", link: "/adopt", title: "Adopt a Pet"},
{src: "/images/home/navimages/store.jpg", alt: "Online Store", link: "/store", title: "Online Store"},
{src: "/images/home/navimages/appointments.jpg", alt: "Appointments", link: "/appointments", title: "Appointments"},
{src: "/images/home/navimages/about.jpg", alt: "About Us", link: "/about", title: "About Us"},
];
return ( return (
<main className="home-page"> <main className="home-page">
@@ -37,7 +35,7 @@ export default function Home() {
<section className="slideshow-container"> <section className="slideshow-container">
{slideshowImages.map((image, index) => ( {slideshowImages.map((image, index) => (
<div <div
key={index} key={image.id}
className={`slide ${index === currentSlide ? "active" : ""}`} className={`slide ${index === currentSlide ? "active" : ""}`}
> >
<Image <Image
@@ -63,7 +61,7 @@ export default function Home() {
<section className="image-links-section"> <section className="image-links-section">
<div className="image-links-container"> <div className="image-links-container">
{navImages.map((item, index) => ( {navImages.map((item, index) => (
<Link href={item.link} key={index} className="image-link-card"> <Link href={item.link} key={item.id} className="image-link-card">
<div className="image-wrapper"> <div className="image-wrapper">
<Image <Image
src={item.src} src={item.src}

View File

@@ -18,9 +18,6 @@ export default function ProductDetailPage() {
return; return;
} }
setLoading(true);
setError(null);
fetch(`${API_BASE}/api/v1/products/${id}`) fetch(`${API_BASE}/api/v1/products/${id}`)
.then((res) => { .then((res) => {
if (!res.ok) { if (!res.ok) {

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import ProductCard from "@/components/ProductCard"; import ProductCard from "@/components/ProductCard";
import { fetchAllPages } from "@/lib/fetchAllPages";
const API_BASE = ""; const API_BASE = "";
@@ -11,39 +12,35 @@ export default function ProductsPage() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const PAGE_SIZE = 12; const PAGE_SIZE = 100;
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setError(null);
const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "prodId,asc" }); fetchAllPages((page) => {
if (query) { const params = new URLSearchParams({
params.set("q", query); page: String(page),
} size: String(PAGE_SIZE),
sort: "prodId,asc",
fetch(`${API_BASE}/api/v1/products?${params}`) });
.then((res) => { if (query) {
if (!res.ok) { params.set("q", query);
throw new Error(`HTTP ${res.status} ${res.statusText}`); }
} return `${API_BASE}/api/v1/products?${params}`;
})
return res.json(); .then((allProducts) => {
}) setProducts(allProducts);
.then((data) => {
setProducts(data.content ?? []);
setTotalPages(data.totalPages ?? 0);
}) })
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [page, query]); }, [query]);
function handleSearch(e) { function handleSearch(e) {
e.preventDefault(); e.preventDefault();
setPage(0); setLoading(true);
setError(null);
setQuery(search.trim()); setQuery(search.trim());
} }
@@ -70,7 +67,7 @@ export default function ProductsPage() {
<button <button
className="adopt-clear-btn" className="adopt-clear-btn"
type="button" type="button"
onClick={() => { setSearch(""); setQuery(""); setPage(0); }} onClick={() => { setLoading(true); setError(null); setSearch(""); setQuery(""); }}
> >
Clear Clear
</button> </button>
@@ -108,25 +105,6 @@ export default function ProductsPage() {
</div> </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> </section>
</main> </main>
); );

View File

@@ -1,14 +1,15 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
const API_BASE = ""; const API_BASE = "";
export default function ProfilePage() { export default function ProfilePage() {
const {user, token, loading, logout} = useAuth(); const {user, token, loading, logout, refreshUser} = useAuth();
const router = useRouter(); const router = useRouter();
const petImageObjectUrlsRef = useRef([]);
const [pets, setPets] = useState([]); const [pets, setPets] = useState([]);
const [loadingPets, setLoadingPets] = useState(false); const [loadingPets, setLoadingPets] = useState(false);
@@ -19,25 +20,92 @@ export default function ProfilePage() {
const [breed, setBreed] = useState(""); const [breed, setBreed] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [petError, setPetError] = useState(null); const [petError, setPetError] = useState(null);
const [profileForm, setProfileForm] = useState({ fullName: "", email: "", phone: "" });
const [profileSubmitting, setProfileSubmitting] = useState(false);
const [profileError, setProfileError] = useState(null);
const [profileSuccess, setProfileSuccess] = useState(null);
const [avatarSubmitting, setAvatarSubmitting] = useState(false);
const clearPetImageObjectUrls = useCallback(() => {
for (const objectUrl of petImageObjectUrlsRef.current) {
URL.revokeObjectURL(objectUrl);
}
petImageObjectUrlsRef.current = [];
}, []);
useEffect(() => { useEffect(() => {
if (!loading && !user) { if (!loading && !user) {
router.replace("/login"); router.replace(`/login?next=${encodeURIComponent("/profile")}`);
} }
}, [user, loading, router]); }, [user, loading, router]);
const loadPets = useCallback(() => { useEffect(() => {
setProfileForm({
fullName: user?.fullName || "",
email: user?.email || "",
phone: user?.phone || "",
});
}, [user]);
const loadPets = useCallback(async () => {
if (!token) return; if (!token) return;
setLoadingPets(true); setLoadingPets(true);
fetch(`${API_BASE}/api/v1/my-pets`, {
headers: { Authorization: `Bearer ${token}` }, try {
}) const response = await fetch(`${API_BASE}/api/v1/my-pets`, {
.then((r) => r.json()) headers: { Authorization: `Bearer ${token}` },
.then(setPets) });
.catch(() => {})
.finally(() => setLoadingPets(false)); if (!response.ok) {
}, [token]); throw new Error(`Request failed (${response.status})`);
}
const petData = await response.json();
clearPetImageObjectUrls();
const petsWithResolvedImages = await Promise.all(
(Array.isArray(petData) ? petData : []).map(async (pet) => {
if (!pet.imageUrl) {
return pet;
}
try {
const imageResponse = await fetch(`${API_BASE}${pet.imageUrl}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!imageResponse.ok) {
return { ...pet, imageUrl: null };
}
const blob = await imageResponse.blob();
const objectUrl = URL.createObjectURL(blob);
petImageObjectUrlsRef.current.push(objectUrl);
return { ...pet, imageUrl: objectUrl };
} catch {
return { ...pet, imageUrl: null };
}
})
);
setPets(petsWithResolvedImages);
}
catch {
}
finally {
setLoadingPets(false);
}
}, [token, clearPetImageObjectUrls]);
useEffect(() => {
return () => {
clearPetImageObjectUrls();
};
}, [clearPetImageObjectUrls]);
useEffect(() => { useEffect(() => {
if (user?.role === "CUSTOMER") { if (user?.role === "CUSTOMER") {
@@ -50,6 +118,105 @@ export default function ProfilePage() {
router.push("/"); router.push("/");
} }
async function handleProfileSubmit(e) {
e.preventDefault();
setProfileSubmitting(true);
setProfileError(null);
setProfileSuccess(null);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(profileForm),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || `Request failed (${res.status})`);
}
await refreshUser();
setProfileSuccess("Profile updated successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setProfileSubmitting(false);
}
}
async function handleAvatarUpload(file) {
if (!file) {
return;
}
const formData = new FormData();
formData.append("avatar", file);
setAvatarSubmitting(true);
setProfileError(null);
setProfileSuccess(null);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || "Failed to upload avatar");
}
await refreshUser();
setProfileSuccess(data?.message || "Avatar updated successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setAvatarSubmitting(false);
}
}
async function handleAvatarDelete() {
setAvatarSubmitting(true);
setProfileError(null);
setProfileSuccess(null);
try {
const res = await fetch(`${API_BASE}/api/v1/auth/me/avatar`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(data?.message || "Failed to delete avatar");
}
await refreshUser();
setProfileSuccess(data?.message || "Avatar removed successfully.");
}
catch (err) {
setProfileError(err.message);
}
finally {
setAvatarSubmitting(false);
}
}
function openAddForm() { function openAddForm() {
setEditingPet(null); setEditingPet(null);
setPetName(""); setPetName("");
@@ -170,7 +337,11 @@ export default function ProfilePage() {
<main className="profile-page-layout"> <main className="profile-page-layout">
<div className="profile-card"> <div className="profile-card">
<div className="profile-avatar-circle"> <div className="profile-avatar-circle">
{(user.fullName || user.username).charAt(0).toUpperCase()} {user.avatarUrl ? (
<img src={user.avatarUrl} alt={user.fullName || user.username} className="profile-avatar-image" />
) : (
(user.fullName || user.username).charAt(0).toUpperCase()
)}
</div> </div>
<h1 className="profile-name">{user.fullName || user.username}</h1> <h1 className="profile-name">{user.fullName || user.username}</h1>
@@ -185,7 +356,65 @@ export default function ProfilePage() {
))} ))}
</dl> </dl>
<button className="auth-submit-btn profile-logout-btn" onClick={handleLogout}> <form className="profile-update-form" onSubmit={handleProfileSubmit}>
<h2 className="profile-update-title">Update Profile</h2>
{profileError && <div className="appt-error">{profileError}</div>}
{profileSuccess && <div className="appt-success">{profileSuccess}</div>}
<label className="appt-label">
Full Name
<input
className="appt-input"
type="text"
value={profileForm.fullName}
onChange={(e) => setProfileForm((current) => ({ ...current, fullName: e.target.value }))}
maxLength={100}
/>
</label>
<label className="appt-label">
Email
<input
className="appt-input"
type="email"
value={profileForm.email}
onChange={(e) => setProfileForm((current) => ({ ...current, email: e.target.value }))}
required
/>
</label>
<label className="appt-label">
Phone
<input
className="appt-input"
type="text"
value={profileForm.phone}
onChange={(e) => setProfileForm((current) => ({ ...current, phone: e.target.value }))}
maxLength={20}
/>
</label>
<div className="profile-avatar-actions">
<label className="profile-avatar-upload-btn">
<input
type="file"
accept="image/jpeg,image/png,image/gif"
className="profile-pet-upload-input"
onChange={(e) => {
handleAvatarUpload(e.target.files?.[0] || null);
e.target.value = "";
}}
/>
{avatarSubmitting ? "Working..." : user.avatarUrl ? "Change Avatar" : "Upload Avatar"}
</label>
{user.avatarUrl && (
<button type="button" className="profile-pet-delete-btn" onClick={handleAvatarDelete} disabled={avatarSubmitting}>
Remove Avatar
</button>
)}
</div>
<button type="submit" className="appt-submit-btn" disabled={profileSubmitting || avatarSubmitting}>
{profileSubmitting ? "Saving..." : "Save Profile"}
</button>
</form>
<button type="button" className="auth-submit-btn profile-logout-btn" onClick={handleLogout}>
Log Out Log Out
</button> </button>
</div> </div>
@@ -194,7 +423,7 @@ export default function ProfilePage() {
<div className="profile-pets-section"> <div className="profile-pets-section">
<div className="profile-pets-header"> <div className="profile-pets-header">
<h2 className="profile-pets-title">My Pets</h2> <h2 className="profile-pets-title">My Pets</h2>
<button className="profile-pets-add-btn" onClick={openAddForm}>+ Add Pet</button> <button type="button" className="profile-pets-add-btn" onClick={openAddForm}>+ Add Pet</button>
</div> </div>
{showForm && ( {showForm && (
@@ -285,11 +514,11 @@ export default function ProfilePage() {
{pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>} {pet.breed && <span className="profile-pet-card-detail">{pet.breed}</span>}
</div> </div>
<div className="profile-pet-card-actions"> <div className="profile-pet-card-actions">
<button className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button> <button type="button" className="profile-pet-edit-btn" onClick={() => openEditForm(pet)}>Edit</button>
<button className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button> <button type="button" className="profile-pet-delete-btn" onClick={() => handleDeletePet(pet.customerPetId)}>Remove</button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,12 +1,25 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
export default function RegisterPage() { function resolveNextPath(candidate) {
if (!candidate || !candidate.startsWith("/")) {
return "/";
}
if (candidate.startsWith("//") || candidate.startsWith("/login") || candidate.startsWith("/register")) {
return "/";
}
return candidate;
}
function RegisterPage() {
const {register} = useAuth(); const {register} = useAuth();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [form, setForm] = useState({ const [form, setForm] = useState({
fullName: "", fullName: "",
@@ -40,7 +53,7 @@ export default function RegisterPage() {
phone: form.phone, phone: form.phone,
password: form.password, password: form.password,
}); });
router.push("/"); router.push(resolveNextPath(searchParams.get("next")));
} }
catch (err) { catch (err) {
@@ -143,9 +156,13 @@ export default function RegisterPage() {
<p className="auth-switch"> <p className="auth-switch">
Already have an account?{" "} Already have an account?{" "}
<a href="/login" className="auth-switch-link">Log in here</a> <Link href={searchParams.get("next") ? `/login?next=${encodeURIComponent(searchParams.get("next"))}` : "/login"} className="auth-switch-link">Log in here</Link>
</p> </p>
</div> </div>
</main> </main>
); );
} }
export default dynamic(() => Promise.resolve(RegisterPage), {
ssr: false,
});

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
@@ -23,28 +24,28 @@ export default function DisplayNav() {
id="logo"/> id="logo"/>
<div className="nav-links"> <div className="nav-links">
<a href="/" className="nav-link">Home</a> <Link href="/" className="nav-link">Home</Link>
<a href="/adopt" className="nav-link">Adopt a Pet</a> <Link href="/adopt" className="nav-link">Adopt a Pet</Link>
<a href="/products" className="nav-link">Online Store</a> <Link href="/products" className="nav-link">Online Store</Link>
<a href="/appointments" className="nav-link">Schedule an Appointment</a> <Link href="/appointments" className="nav-link">Schedule an Appointment</Link>
<a href="/contact" className="nav-link">Contact Us</a> <Link href="/contact" className="nav-link">Contact Us</Link>
<a href="/aboutus" className="nav-link">About Us</a> <Link href="/about" className="nav-link">About Us</Link>
</div> </div>
<div className="nav-auth"> <div className="nav-auth">
{loading ? null : user ? ( {loading ? null : user ? (
<> <>
<a href="/profile" className="nav-link nav-greeting"> <Link href="/profile" className="nav-link nav-greeting">
Hello, {user.fullName || user.username} Hello, {user.fullName || user.username}
</a> </Link>
<button className="nav-logout-btn" onClick={handleLogout}> <button type="button" className="nav-logout-btn" onClick={handleLogout}>
Log Out Log Out
</button> </button>
</> </>
) : ( ) : (
<> <>
<a href="/login" className="nav-link">Log In</a> <Link href="/login" className="nav-link">Log In</Link>
<a href="/register" className="nav-link nav-register-btn">Register</a> <Link href="/register" className="nav-link nav-register-btn">Register</Link>
</> </>
)} )}
</div> </div>

View File

@@ -5,7 +5,15 @@ export default function PetCard({petId, petName, petSpecies, petStatus, imageUrl
return ( return (
<Link href={`/adopt/${petId}`} className="pet-card"> <Link href={`/adopt/${petId}`} className="pet-card">
<div className="pet-card-image-wrapper"> <div className="pet-card-image-wrapper">
<img src={imageUrl || "/images/pet-placeholder.png"} alt={petName} className="pet-card-image" /> <img
src={imageUrl || "/images/pet-placeholder.png"}
alt={petName}
className="pet-card-image"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
</div> </div>
<div className="pet-card-body"> <div className="pet-card-body">
<h3 className="pet-card-name">{petName}</h3> <h3 className="pet-card-name">{petName}</h3>

View File

@@ -5,7 +5,15 @@ export default function PetProfile({ petId, petName, petSpecies, petBreed, petAg
return ( return (
<div className="pet-detail-card"> <div className="pet-detail-card">
<div className="pet-detail-image-wrapper"> <div className="pet-detail-image-wrapper">
<img src={imageUrl || "/images/pet-placeholder.png"} alt={petName} className="pet-detail-image" /> <img
src={imageUrl || "/images/pet-placeholder.png"}
alt={petName}
className="pet-detail-image"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
</div> </div>
<div className="pet-detail-info"> <div className="pet-detail-info">

View File

@@ -4,7 +4,15 @@ export default function ProductCard({ prodId, prodName, categoryName, prodPrice,
return ( return (
<Link href={`/products/${prodId}`} className="pet-card"> <Link href={`/products/${prodId}`} className="pet-card">
<div className="pet-card-image-wrapper"> <div className="pet-card-image-wrapper">
<img src={imageUrl || "/images/product-placeholder.png"} alt={prodName} className="pet-card-image" /> <img
src={imageUrl || "/images/pet-placeholder.png"}
alt={prodName}
className="pet-card-image"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
</div> </div>
<div className="pet-card-body"> <div className="pet-card-body">
<h3 className="pet-card-name">{prodName}</h3> <h3 className="pet-card-name">{prodName}</h3>

View File

@@ -4,7 +4,15 @@ export default function ProductProfile({ prodName, categoryName, prodDesc, prodP
return ( return (
<div className="pet-detail-card"> <div className="pet-detail-card">
<div className="pet-detail-image-wrapper"> <div className="pet-detail-image-wrapper">
<img src={imageUrl || "/images/product-placeholder.png"} alt={prodName} className="pet-detail-image" /> <img
src={imageUrl || "/images/pet-placeholder.png"}
alt={prodName}
className="pet-detail-image"
onError={(e) => {
e.currentTarget.onerror = null;
e.currentTarget.src = "/images/pet-placeholder.png";
}}
/>
</div> </div>
<div className="pet-detail-info"> <div className="pet-detail-info">

View File

@@ -19,6 +19,28 @@ export function AuthProvider({ children }) {
const [token, setToken] = useState(null); const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const refreshUser = useCallback(async (providedToken) => {
const activeToken = providedToken ?? token;
if (!activeToken) {
setUser(null);
return null;
}
const userInfo = await fetchCurrentUser(activeToken);
if (!userInfo) {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
return null;
}
if (!token) {
setToken(activeToken);
}
setUser(userInfo);
return userInfo;
}, [token]);
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem(TOKEN_KEY); const stored = localStorage.getItem(TOKEN_KEY);
if (!stored) { if (!stored) {
@@ -26,17 +48,14 @@ export function AuthProvider({ children }) {
return; return;
} }
fetchCurrentUser(stored) refreshUser(stored)
.then((data) => { .catch(() => {
if (data) { localStorage.removeItem(TOKEN_KEY);
setToken(stored); setToken(null);
setUser(data); setUser(null);
} })
.finally(() => setLoading(false));
else { }, [refreshUser]);
localStorage.removeItem(TOKEN_KEY);
}
}).catch(() => localStorage.removeItem(TOKEN_KEY)).finally(() => setLoading(false));}, []);
const login = useCallback(async (username, password) => { const login = useCallback(async (username, password) => {
const res = await fetch("/api/v1/auth/login", { const res = await fetch("/api/v1/auth/login", {
@@ -55,12 +74,10 @@ export function AuthProvider({ children }) {
localStorage.setItem(TOKEN_KEY, jwt); localStorage.setItem(TOKEN_KEY, jwt);
setToken(jwt); setToken(jwt);
const userInfo = await fetchCurrentUser(jwt); const userInfo = await refreshUser(jwt);
setUser(userInfo);
return userInfo; return userInfo;
}, []); }, [refreshUser]);
const register = useCallback(async ({ username, password, email, fullName, phone }) => { const register = useCallback(async ({ username, password, email, fullName, phone }) => {
const res = await fetch("/api/v1/auth/register", { const res = await fetch("/api/v1/auth/register", {
@@ -79,11 +96,10 @@ export function AuthProvider({ children }) {
localStorage.setItem(TOKEN_KEY, jwt); localStorage.setItem(TOKEN_KEY, jwt);
setToken(jwt); setToken(jwt);
const userInfo = await fetchCurrentUser(jwt); const userInfo = await refreshUser(jwt);
setUser(userInfo);
return userInfo; return userInfo;
}, []); }, [refreshUser]);
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
@@ -91,7 +107,7 @@ export function AuthProvider({ children }) {
setUser(null);}, []); setUser(null);}, []);
return ( return (
<AuthContext.Provider value={{ user, token, loading, login, logout, register }}> <AuthContext.Provider value={{ user, token, loading, login, logout, register, refreshUser }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

19
web/lib/fetchAllPages.js Normal file
View File

@@ -0,0 +1,19 @@
export async function fetchAllPages(urlBuilder) {
const items = [];
let page = 0;
let totalPages = 1;
while (page < totalPages) {
const res = await fetch(urlBuilder(page));
if (!res.ok) {
throw new Error(`HTTP ${res.status} ${res.statusText}`);
}
const data = await res.json();
items.push(...(data.content ?? []));
totalPages = Math.max(data.totalPages ?? 1, 1);
page += 1;
}
return items;
}