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:
37
web/app/about/page.js
Normal file
37
web/app/about/page.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export default function AboutPage() {
|
||||||
|
return (
|
||||||
|
<main className="info-page">
|
||||||
|
<section className="info-hero">
|
||||||
|
<h1 className="info-title">About Leon'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
@@ -637,4 +655,8 @@ export default function AppointmentsPage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default dynamic(() => Promise.resolve(AppointmentsPage), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|||||||
73
web/app/contact/page.js
Normal file
73
web/app/contact/page.js
Normal 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: Mon–Sat, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1698,4 +1804,4 @@ body {
|
|||||||
.profile-pet-cancel-btn:hover {
|
.profile-pet-cancel-btn:hover {
|
||||||
border-color: #999;
|
border-color: #999;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -64,12 +77,16 @@ export default function LoginPage() {
|
|||||||
{loading ? "Logging in…" : "Log In"}
|
{loading ? "Logging in…" : "Log In"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="auth-switch">
|
<p className="auth-switch">
|
||||||
Don't have an account?{" "}
|
Don'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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -80,4 +78,4 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ export default function ProductDetailPage() {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
fetch(`${API_BASE}/api/v1/products/${id}`)
|
fetch(`${API_BASE}/api/v1/products/${id}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -140,12 +153,16 @@ export default function RegisterPage() {
|
|||||||
{loading ? "Creating account…" : "Register"}
|
{loading ? "Creating account…" : "Register"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,31 +24,31 @@ 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>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
19
web/lib/fetchAllPages.js
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user