Merge branch 'web-index'

This commit is contained in:
augmentedpotato
2026-03-29 17:47:33 -06:00
16 changed files with 1565 additions and 45 deletions

View File

@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import PetProfile from "@/components/PetProfile";
const API_BASE = "";
export default function PetDetailPage() {
const { id } = useParams();
const [pet, setPet] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) return;
setLoading(true);
setError(null);
fetch(`${API_BASE}/api/v1/pets/${id}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return res.json();
})
.then((data) => setPet(data))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
return (
<main className="pet-detail-page">
<div className="pet-detail-container">
<Link href="/adopt" className="pet-detail-back"> Back to Pets</Link>
{loading && <p className="adopt-status-msg">Loading pet details...</p>}
{error && <p className="adopt-status-msg adopt-error">{error}</p>}
{!loading && !error && pet && (
<PetProfile
petName={pet.petName}
petSpecies={pet.petSpecies}
petBreed={pet.petBreed}
petAge={pet.petAge}
petStatus={pet.petStatus}
petPrice={pet.petPrice}
/>
)}
</div>
</main>
);
}

149
web/app/adopt/page.js Normal file
View File

@@ -0,0 +1,149 @@
"use client";
import { useState, useEffect } from "react";
import PetCard from "@/components/PetCard";
const API_BASE = "";
export default function AdoptPage() {
const [pets, setPets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [health, setHealth] = useState(null);
const [search, setSearch] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const PAGE_SIZE = 12;
useEffect(() => {
fetch(`${API_BASE}/api/v1/health`)
.then((res) => (res.ok ? setHealth("online") : setHealth("error")))
.catch(() => setHealth("offline"));
}, []);
useEffect(() => {
setLoading(true);
setError(null);
const params = new URLSearchParams({ page, size: PAGE_SIZE, sort: "id,asc" });
if (query) params.set("q", query);
fetch(`${API_BASE}/api/v1/pets?${params}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
return res.json();
})
.then((data) => {
setPets(data.content ?? []);
setTotalPages(data.totalPages ?? 0);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [page, query]);
function handleSearch(e) {
e.preventDefault();
setPage(0);
setQuery(search.trim());
}
return (
<main className="adopt-page">
<section className="adopt-hero">
<h1 className="adopt-hero-title">Find Your Perfect Companion</h1>
<p className="adopt-hero-subtitle">Give a loving pet their forever home</p>
<div className="title-decoration"></div>
</section>
<section className="adopt-controls">
<div className="adopt-controls-row">
<form className="adopt-search-form" onSubmit={handleSearch}>
<input
className="adopt-search-input"
type="text"
placeholder="Search by name or species..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button className="adopt-search-btn" type="submit">Search</button>
{query && (
<button
className="adopt-clear-btn"
type="button"
onClick={() => { setSearch(""); setQuery(""); setPage(0); }}
>
Clear
</button>
)}
</form>
<span
className={`backend-status backend-status--${health ?? "checking"}`}
title={
health === "online" ? "Backend online" :
health === "offline" ? "Backend offline" :
health === "error" ? "Backend error" : "Checking…"
}
/>
</div>
</section>
<section className="adopt-grid-section">
{loading && <p className="adopt-status-msg">Loading pets...</p>}
{error && (
<div className="adopt-error-box">
<p className="adopt-error-title">Failed to load pets</p>
<code className="adopt-error-detail">{error}</code>
<p className="adopt-error-hint">
{health === "offline"
? "The Spring Boot backend is not reachable. Make sure it is running in IntelliJ on port 8080."
: health === "error"
? "The backend responded with an error. Check the IntelliJ Run console for stack traces."
: "The backend is reachable but the /pets endpoint failed. Check the IntelliJ Run console."}
</p>
</div>
)}
{!loading && !error && pets.length === 0 && (
<p className="adopt-status-msg">No pets found.</p>
)}
{!loading && !error && pets.length > 0 && (
<div className="adopt-grid">
{pets.map((pet) => (
<PetCard
key={pet.petId}
petId={pet.petId}
petName={pet.petName}
petSpecies={pet.petSpecies}
petStatus={pet.petStatus}
/>
))}
</div>
)}
{!loading && totalPages > 1 && (
<div className="adopt-pagination">
<button
className="pagination-btn"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Prev
</button>
<span className="pagination-info">Page {page + 1} of {totalPages}</span>
<button
className="pagination-btn"
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</section>
</main>
);
}

View File

@@ -232,7 +232,424 @@ body {
border-radius: 2px;
}
/* ─── Adopt Page ─────────────────────────────────────────────── */
.adopt-page {
min-height: 100vh;
}
.adopt-hero {
text-align: center;
padding: 4rem 2rem 3rem;
background: linear-gradient(to bottom, #f9f9f9, #ffffff);
}
.adopt-hero-title {
font-size: 3rem;
color: #333;
margin-bottom: 1rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.adopt-hero-subtitle {
font-size: 1.5rem;
color: #666;
margin-bottom: 2rem;
font-weight: 300;
}
.adopt-controls {
max-width: 1200px;
margin: 0 auto 1.5rem;
padding: 0 2rem;
}
.adopt-search-form {
display: flex;
gap: 0.75rem;
align-items: center;
}
.adopt-search-input {
flex: 1;
max-width: 400px;
padding: 0.6rem 1rem;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
font-family: Arial, sans-serif;
transition: border-color 0.2s ease;
outline: none;
}
.adopt-search-input:focus {
border-color: orange;
}
.adopt-search-btn {
padding: 0.6rem 1.4rem;
background: orange;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-family: Arial, sans-serif;
cursor: pointer;
transition: background 0.2s ease;
}
.adopt-search-btn:hover {
background: #e69500;
}
.adopt-clear-btn {
padding: 0.6rem 1rem;
background: transparent;
color: #666;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
font-family: Arial, sans-serif;
cursor: pointer;
transition: all 0.2s ease;
}
.adopt-clear-btn:hover {
border-color: #aaa;
color: #333;
}
.adopt-grid-section {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem 4rem;
}
.adopt-status-msg {
text-align: center;
color: #666;
font-size: 1.1rem;
padding: 3rem 0;
}
.adopt-error {
color: #c0392b;
}
.adopt-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.75rem;
}
.pet-card {
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease, box-shadow 0.3s ease;
background: #fff;
}
.pet-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13);
}
.pet-card-image-wrapper {
background: #fff8ee;
display: flex;
align-items: center;
justify-content: center;
height: 160px;
}
.pet-card-emoji {
font-size: 5rem;
line-height: 1;
}
.pet-card-body {
padding: 1rem 1.25rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pet-card-name {
font-size: 1.2rem;
font-weight: 700;
color: #222;
margin: 0;
}
.pet-card-species {
font-size: 0.95rem;
color: #666;
margin: 0;
}
.pet-card-status {
display: inline-block;
margin-top: 0.4rem;
padding: 0.2rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: capitalize;
width: fit-content;
}
.status-available {
background: #e6f9ee;
color: #1a7a3c;
}
.status-adopted {
background: #ffe8e8;
color: #c0392b;
}
.status-pending {
background: #fff4e0;
color: #b36b00;
}
.status-other {
background: #f0f0f0;
color: #555;
}
.adopt-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
margin-top: 3rem;
}
.pagination-btn {
padding: 0.5rem 1.2rem;
background: orange;
color: white;
border: none;
border-radius: 6px;
font-size: 0.95rem;
font-family: Arial, sans-serif;
cursor: pointer;
transition: background 0.2s ease;
}
.pagination-btn:hover:not(:disabled) {
background: #e69500;
}
.pagination-btn:disabled {
background: #ddd;
color: #aaa;
cursor: default;
}
.pagination-info {
font-size: 0.95rem;
color: #555;
}
/* ─── Pet Detail Page ─────────────────────────────────────────── */
.pet-detail-page {
min-height: 100vh;
padding: 3rem 2rem 5rem;
}
.pet-detail-container {
max-width: 860px;
margin: 0 auto;
}
.pet-detail-back {
display: inline-block;
margin-bottom: 2rem;
color: orange;
text-decoration: none;
font-size: 1rem;
font-weight: 600;
transition: color 0.2s ease;
}
.pet-detail-back:hover {
color: #e69500;
}
.pet-detail-card {
display: flex;
gap: 3rem;
background: #fff;
border-radius: 20px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.pet-detail-image-wrapper {
flex-shrink: 0;
width: 280px;
background: #fff8ee;
display: flex;
align-items: center;
justify-content: center;
}
.pet-detail-emoji {
font-size: 8rem;
line-height: 1;
}
.pet-detail-info {
flex: 1;
padding: 2.5rem 2.5rem 2.5rem 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.pet-detail-header {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.pet-detail-name {
font-size: 2.2rem;
font-weight: 700;
color: #222;
margin: 0;
}
.pet-detail-fields {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid #eee;
border-radius: 10px;
overflow: hidden;
}
.pet-detail-row {
display: flex;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid #eee;
}
.pet-detail-row:last-child {
border-bottom: none;
}
.pet-detail-label {
width: 140px;
font-size: 0.9rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.pet-detail-value {
font-size: 1rem;
color: #333;
}
.pet-detail-price {
font-weight: 700;
color: #1a7a3c;
font-size: 1.1rem;
}
.pet-detail-cta {
background: #fff8ee;
border-radius: 12px;
padding: 1.25rem 1.5rem;
}
.pet-detail-cta-text {
font-size: 0.95rem;
color: #555;
margin: 0 0 1rem;
}
.pet-detail-cta-btn {
display: inline-block;
padding: 0.65rem 1.5rem;
background: orange;
color: white;
text-decoration: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
transition: background 0.2s ease;
}
.pet-detail-cta-btn:hover {
background: #e69500;
}
/* ─── Responsive Design ──────────────────────────────────────── */
/* Responsive Design */
@media (max-width: 1024px) {
.adopt-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.adopt-hero-title {
font-size: 2rem;
}
.adopt-hero-subtitle {
font-size: 1.2rem;
}
.adopt-grid {
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
}
.pet-detail-card {
flex-direction: column;
gap: 0;
}
.pet-detail-image-wrapper {
width: 100%;
height: 200px;
}
.pet-detail-info {
padding: 1.75rem;
}
}
@media (max-width: 480px) {
.adopt-grid {
grid-template-columns: 1fr;
}
.adopt-hero-title {
font-size: 1.6rem;
}
.pet-detail-name {
font-size: 1.7rem;
}
}
@media (max-width: 1024px) {
.slideshow-container {
height: 400px;
@@ -287,4 +704,319 @@ body {
.centered-title-section {
padding: 2rem 1rem;
}
}
}
/* ─── Adopt diagnostic additions ────────────────────────────── */
.adopt-controls-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.backend-status {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.backend-status--online {
background: #1a7a3c;
}
.backend-status--offline {
background: #c0392b;
}
.backend-status--error {
background: #e67e00;
}
.backend-status--checking {
background: #bbb;
}
.adopt-error-box {
max-width: 640px;
margin: 3rem auto;
padding: 1.5rem 2rem;
background: #fff8f8;
border: 2px solid #f5c6c6;
border-radius: 12px;
}
.adopt-error-title {
font-size: 1.1rem;
font-weight: 700;
color: #c0392b;
margin: 0 0 0.6rem;
}
.adopt-error-detail {
display: block;
font-family: monospace;
font-size: 0.9rem;
background: #fff0f0;
border: 1px solid #f5c6c6;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.9rem;
word-break: break-all;
}
.adopt-error-hint {
font-size: 0.9rem;
color: #555;
margin: 0;
line-height: 1.5;
}
/* Auth/nav */
.navbar {
justify-content: space-between;
}
.nav-auth {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
padding-left: 1.5rem;
flex-shrink: 0;
}
.nav-greeting {
font-weight: 600;
white-space: nowrap;
}
.nav-register-btn {
background: white;
color: orange !important;
font-weight: 600;
border-radius: 20px;
padding: 0.4rem 1rem !important;
}
.nav-register-btn:hover {
background: #fff3e0 !important;
}
.nav-logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 20px;
padding: 0.35rem 1rem;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
}
.nav-logout-btn:hover {
background: rgba(255, 255, 255, 0.35);
}
/* Login/Register */
.auth-page {
min-height: calc(100vh - 70px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background: #fafafa;
}
.auth-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
padding: 2.5rem;
width: 100%;
max-width: 440px;
}
.auth-title {
font-size: 1.75rem;
font-weight: 700;
color: #222;
margin: 0 0 1.5rem;
text-align: center;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth-label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
font-weight: 600;
color: #444;
}
.auth-input {
padding: 0.6rem 0.85rem;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
}
.auth-input:focus {
border-color: orange;
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.2);
}
.auth-submit-btn {
margin-top: 0.5rem;
padding: 0.75rem;
background: orange;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
}
.auth-submit-btn:hover:not(:disabled) {
background: #e69500;
}
.auth-submit-btn:active:not(:disabled) {
transform: scale(0.98);
}
.auth-submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-error {
background: #fff0f0;
border: 1px solid #f5c6c6;
color: #c0392b;
border-radius: 8px;
padding: 0.65rem 1rem;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.auth-switch {
text-align: center;
font-size: 0.9rem;
color: #666;
margin-top: 1.25rem;
}
.auth-switch-link {
color: orange;
font-weight: 600;
text-decoration: none;
}
.auth-switch-link:hover {
text-decoration: underline;
}
/* User Profile Page */
.profile-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
padding: 2.5rem;
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.profile-avatar-circle {
width: 80px;
height: 80px;
border-radius: 50%;
background: orange;
color: white;
font-size: 2rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.25rem;
}
.profile-name {
font-size: 1.5rem;
font-weight: 700;
color: #222;
margin: 0;
}
.profile-role-badge {
display: inline-block;
background: #fff3e0;
color: #e67e00;
border: 1px solid #ffd180;
border-radius: 20px;
padding: 0.2rem 0.85rem;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.profile-fields {
width: 100%;
margin: 0.75rem 0 0;
border-top: 1px solid #eee;
padding-top: 1rem;
}
.profile-field-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.55rem 0;
border-bottom: 1px solid #f0f0f0;
gap: 1rem;
}
.profile-field-label {
font-size: 0.85rem;
font-weight: 600;
color: #888;
flex-shrink: 0;
}
.profile-field-value {
font-size: 0.95rem;
color: #222;
text-align: right;
word-break: break-word;
}
.profile-logout-btn {
width: 100%;
margin-top: 1rem;
}
.profile-loading {
color: #888;
font-size: 1rem;
}

View File

@@ -1,18 +1,21 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import DisplayNav from "@/components/Navigation";
import ClientProviders from "@/components/ClientProviders";
export const metadata = {
title: "Leon's Pet Store",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
export default function RootLayout({children}) {
return (
<html lang="en">
<body>
<DisplayNav />
{children}
<ClientProviders>
<DisplayNav />
{children}
</ClientProviders>
</body>
</html>
);

75
web/app/login/page.js Normal file
View File

@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function LoginPage() {
const {login} = useAuth();
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(username, password);
router.push("/");
}
catch (err) {
setError(err.message);
}
finally {
setLoading(false);
}
}
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Log In</h1>
{error && <p className="auth-error">{error}</p>}
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-label">
Username
<input className="auth-input"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"/>
</label>
<label className="auth-label">
Password
<input className="auth-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"/>
</label>
<button className="auth-submit-btn" type="submit" disabled={loading}>
{loading ? "Logging in…" : "Log In"}
</button>
</form>
<p className="auth-switch">
Don&apos;t have an account?{" "}
<a href="/register" className="auth-switch-link">Register here</a>
</p>
</div>
</main>
);
}

View File

@@ -5,17 +5,17 @@ import Link from "next/link";
import { useState, useEffect } from "react";
export default function Home() {
// Slideshow images array
//Slideshow images array
const slideshowImages = [
{ src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets" },
{ src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies" },
{ src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming" },
{ src: "/images/home/slideshow/pet4.jpg", alt: "Pet food" },
{src: "/images/home/slideshow/pet1.jpg", alt: "Happy pets"},
{src: "/images/home/slideshow/pet2.jpg", alt: "Pet supplies"},
{src: "/images/home/slideshow/pet3.jpg", alt: "Pet grooming"},
{src: "/images/home/slideshow/pet4.jpg", alt: "Pet food"},
];
const [currentSlide, setCurrentSlide] = useState(0);
// Auto-advance slideshow
//Auto-advance slideshow
useEffect(() => {
//Change slide every 7.5 seconds
const timer = setInterval(() => {setCurrentSlide((prev) => (prev + 1) % slideshowImages.length);}, 7500);
@@ -23,17 +23,17 @@ export default function Home() {
return () => clearInterval(timer);
}, [slideshowImages.length]);
// Four images that link to other pages
//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" },
{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 (
<main className="home-page">
{/* Slideshow Section */}
{/* Slideshow */}
<section className="slideshow-container">
{slideshowImages.map((image, index) => (
<div
@@ -52,14 +52,14 @@ export default function Home() {
))}
</section>
{/* Centered Title Section */}
{/* Title Section */}
<section className="centered-title-section">
<h1 className="main-title">Welcome to Leon's Pet Store</h1>
<p className="subtitle">Your One-Stop Shop for All Things Pets</p>
<div className="title-decoration"></div>
</section>
{/* Four Image Links Section */}
{/* Image Hyperlinks */}
<section className="image-links-section">
<div className="image-links-container">
{navImages.map((item, index) => (

61
web/app/profile/page.js Normal file
View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function ProfilePage() {
const { user, loading, logout } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.replace("/login");
}
}, [user, loading, router]);
function handleLogout() {
logout();
router.push("/");
}
if (loading || !user) {
return <main className="auth-page"><p className="profile-loading">Loading</p></main>;
}
const fields = [
{label: "Full Name", value: user.fullName},
{label: "Username", value: user.username},
{label: "Email", value: user.email},
{label: "Phone", value: user.phone || "—"},
{label: "Role", value: user.role},
...(user.storeName ? [{label: "Store", value: user.storeName}] : []),
];
return (
<main className="auth-page">
<div className="profile-card">
<div className="profile-avatar-circle">
{(user.fullName || user.username).charAt(0).toUpperCase()}
</div>
<h1 className="profile-name">{user.fullName || user.username}</h1>
<span className="profile-role-badge">{user.role}</span>
<dl className="profile-fields">
{fields.map(({ label, value }) => (
<div key={label} className="profile-field-row">
<dt className="profile-field-label">{label}</dt>
<dd className="profile-field-value">{value}</dd>
</div>
))}
</dl>
<button className="auth-submit-btn profile-logout-btn" onClick={handleLogout}>
Log Out
</button>
</div>
</main>
);
}

151
web/app/register/page.js Normal file
View File

@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function RegisterPage() {
const {register} = useAuth();
const router = useRouter();
const [form, setForm] = useState({
fullName: "",
username: "",
email: "",
phone: "",
password: "",
confirmPassword: "",});
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
function handleChange(e) {
setForm((prev) => ({ ...prev, [e.target.name]: e.target.value }));
}
async function handleSubmit(e) {
e.preventDefault();
setError("");
if (form.password !== form.confirmPassword) {
setError("Passwords do not match.");
return;
}
setLoading(true);
try {
await register({fullName: form.fullName,
username: form.username,
email: form.email,
phone: form.phone,
password: form.password,
});
router.push("/");
}
catch (err) {
setError(err.message);
}
finally {
setLoading(false);
}
}
return (
<main className="auth-page">
<div className="auth-card">
<h1 className="auth-title">Create Account</h1>
{error && <p className="auth-error">{error}</p>}
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-label">
Full Name
<input
className="auth-input"
type="text"
name="fullName"
value={form.fullName}
onChange={handleChange}
required
/>
</label>
<label className="auth-label">
Username
<input
className="auth-input"
type="text"
name="username"
value={form.username}
onChange={handleChange}
required
minLength={3}
/>
</label>
<label className="auth-label">
Email
<input
className="auth-input"
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
/>
</label>
<label className="auth-label">
Phone
<input
className="auth-input"
type="tel"
name="phone"
value={form.phone}
onChange={handleChange}
required
/>
</label>
<label className="auth-label">
Password
<input
className="auth-input"
type="password"
name="password"
value={form.password}
onChange={handleChange}
required
minLength={6}
autoComplete="new-password"
/>
</label>
<label className="auth-label">
Confirm Password
<input
className="auth-input"
type="password"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required
autoComplete="new-password"
/>
</label>
<button className="auth-submit-btn" type="submit" disabled={loading}>
{loading ? "Creating account…" : "Register"}
</button>
</form>
<p className="auth-switch">
Already have an account?{" "}
<a href="/login" className="auth-switch-link">Log in here</a>
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { AuthProvider } from "@/context/AuthContext";
export default function ClientProviders({children}) {
return <AuthProvider>{children}</AuthProvider>;
}

View File

@@ -1,26 +1,53 @@
import Link from "next/link";
"use client";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function DisplayNav() {
const {user, logout, loading} = useAuth();
const router = useRouter();
function handleLogout() {
logout();
router.push("/");
}
return (
<nav className="navbar">
<Image
className="mx-3"
src="/logo_simple.png"
alt="store_logo"
width={50}
height={50}
id="logo"
/>
<div className="nav-links">
<a href="/" className="nav-link">Home</a>
<a href="/pets" className="nav-link">Adopt a Pet</a>
<a href="/" className="nav-link">Online Store</a>
<a href="/appointments" className="nav-link">Schedule an Appointment</a>
<a href="/contact" className="nav-link">Contact Us</a>
<a href="/aboutus" className="nav-link">About Us</a>
</div>
</nav>
<Image className="mx-3"
src="/logo_simple.png"
alt="store_logo"
width={50}
height={50}
id="logo"/>
<div className="nav-links">
<a href="/" className="nav-link">Home</a>
<a href="/adopt" className="nav-link">Adopt a Pet</a>
<a href="/" className="nav-link">Online Store</a>
<a href="/appointments" className="nav-link">Schedule an Appointment</a>
<a href="/contact" className="nav-link">Contact Us</a>
<a href="/aboutus" className="nav-link">About Us</a>
</div>
<div className="nav-auth">
{loading ? null : user ? (
<>
<a href="/profile" className="nav-link nav-greeting">
Hello, {user.fullName || user.username}
</a>
<button className="nav-logout-btn" onClick={handleLogout}>
Log Out
</button>
</>
) : (
<>
<a href="/login" className="nav-link">Log In</a>
<a href="/register" className="nav-link nav-register-btn">Register</a>
</>
)}
</div>
</nav>
);
}

21
web/components/PetCard.js Normal file
View File

@@ -0,0 +1,21 @@
//Pet cards (on adopt page)
import Link from "next/link";
import { getSpeciesEmoji, getStatusClass } from "@/components/petUtils";
export default function PetCard({petId, petName, petSpecies, petStatus}) {
return (
<Link href={`/adopt/${petId}`} className="pet-card">
<div className="pet-card-image-wrapper">
<span className="pet-card-emoji">{getSpeciesEmoji(petSpecies)}</span>
</div>
<div className="pet-card-body">
<h3 className="pet-card-name">{petName}</h3>
<p className="pet-card-species">{petSpecies}</p>
<span className={`pet-card-status ${getStatusClass(petStatus)}`}>
{petStatus}
</span>
</div>
</Link>
);
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link";
import { getSpeciesEmoji, getStatusClass } from "@/components/petUtils";
export default function PetProfile({ petName, petSpecies, petBreed, petAge, petStatus, petPrice }) {
return (
<div className="pet-detail-card">
<div className="pet-detail-image-wrapper">
<span className="pet-detail-emoji">{getSpeciesEmoji(petSpecies)}</span>
</div>
<div className="pet-detail-info">
<div className="pet-detail-header">
<h1 className="pet-detail-name">{petName}</h1>
<span className={`pet-card-status ${getStatusClass(petStatus)}`}>
{petStatus}
</span>
</div>
<div className="pet-detail-fields">
<div className="pet-detail-row">
<span className="pet-detail-label">Species</span>
<span className="pet-detail-value">{petSpecies ?? "—"}</span>
</div>
<div className="pet-detail-row">
<span className="pet-detail-label">Breed</span>
<span className="pet-detail-value">{petBreed ?? "—"}</span>
</div>
<div className="pet-detail-row">
<span className="pet-detail-label">Age</span>
<span className="pet-detail-value">
{petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "—"}
</span>
</div>
<div className="pet-detail-row">
<span className="pet-detail-label">Adoption Fee</span>
<span className="pet-detail-value pet-detail-price">
{petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "—"}
</span>
</div>
</div>
{/* Status */}
{petStatus?.toLowerCase() === "available" && (
<div className="pet-detail-cta">
<p className="pet-detail-cta-text">
Interested in adopting {petName}? Visit us in store or schedule an appointment.
</p>
<Link href="/appointments" className="pet-detail-cta-btn">
Schedule an Appointment
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
//Temporary, until image support is added
export const SPECIES_EMOJI = {
dog: "🐶",
cat: "🐱",
bird: "🐦",
rabbit: "🐰",
hamster: "🐹",
fish: "🐟",
turtle: "🐢",
snake: "🐍",
lizard: "🦎",
guinea: "🐹",
};
export function getSpeciesEmoji(species) {
if (!species) {
return "🐾";
}
const key = species.toLowerCase();
for (const [k, v] of Object.entries(SPECIES_EMOJI)) {
if (key.includes(k)) {
return v;
}
}
return "🐾";
}
export function getStatusClass(status) {
if (!status) {
return "";
}
const s = status.toLowerCase();
if (s === "available") {
return "status-available";
}
if (s === "adopted") {
return "status-adopted";
}
if (s === "pending") {
return "status-pending";
}
return "status-other";
}

107
web/context/AuthContext.js Normal file
View File

@@ -0,0 +1,107 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback } from "react";
const AuthContext = createContext(null);
const TOKEN_KEY = "auth_token";
async function fetchCurrentUser(token) {
const res = await fetch("/api/v1/auth/me", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
return res.json();
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem(TOKEN_KEY);
if (!stored) {
setLoading(false);
return;
}
fetchCurrentUser(stored)
.then((data) => {
if (data) {
setToken(stored);
setUser(data);
}
else {
localStorage.removeItem(TOKEN_KEY);
}
}).catch(() => localStorage.removeItem(TOKEN_KEY)).finally(() => setLoading(false));}, []);
const login = useCallback(async (username, password) => {
const res = await fetch("/api/v1/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Login failed");
}
const jwt = data.token;
localStorage.setItem(TOKEN_KEY, jwt);
setToken(jwt);
const userInfo = await fetchCurrentUser(jwt);
setUser(userInfo);
return userInfo;
}, []);
const register = useCallback(async ({ username, password, email, fullName, phone }) => {
const res = await fetch("/api/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, email, fullName, phone }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || "Registration failed");
}
const jwt = data.token;
localStorage.setItem(TOKEN_KEY, jwt);
setToken(jwt);
const userInfo = await fetchCurrentUser(jwt);
setUser(userInfo);
return userInfo;
}, []);
const logout = useCallback(() => {
localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);}, []);
return (
<AuthContext.Provider value={{ user, token, loading, login, logout, register }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}

View File

@@ -1,7 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
reactCompiler: true,
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8080/api/:path*",
},
];
},
};
export default nextConfig;