Web index #59

Merged
RecentRunner merged 2 commits from web-index into main 2026-03-30 09:48:51 -06:00
10 changed files with 858 additions and 16 deletions
Showing only changes of commit 1c0f55fbe5 - Show all commits

View File

@@ -17,6 +17,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
@EnableWebSecurity
@@ -28,12 +33,10 @@ public class SecurityConfig {
private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
private final RestAccessDeniedHandler restAccessDeniedHandler;
public SecurityConfig(
JwtAuthenticationFilter jwtAuthFilter,
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
UserDetailsService userDetailsService,
RestAuthenticationEntryPoint restAuthenticationEntryPoint,
RestAccessDeniedHandler restAccessDeniedHandler
) {
RestAccessDeniedHandler restAccessDeniedHandler) {
this.jwtAuthFilter = jwtAuthFilter;
this.userDetailsService = userDetailsService;
this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
@@ -42,7 +45,7 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll()
@@ -56,8 +59,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/v1/appointments/availability").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(restAuthenticationEntryPoint)
.exceptionHandling(ex -> ex.authenticationEntryPoint(restAuthenticationEntryPoint)
.accessDeniedHandler(restAccessDeniedHandler)
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
@@ -70,16 +72,32 @@ public class SecurityConfig {
private DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return new ProviderManager(daoAuthenticationProvider());
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("http://localhost:*", "http://127.0.0.1:*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

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,72 @@ 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;
}

View File

@@ -5,7 +5,7 @@ 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" },
@@ -15,7 +15,7 @@ export default function Home() {
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,7 +23,7 @@ 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" },
@@ -33,7 +33,7 @@ export default function Home() {
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) => (

View File

@@ -15,7 +15,7 @@ export default function DisplayNav() {
<div className="nav-links">
<a href="/" className="nav-link">Home</a>
<a href="/pets" className="nav-link">Adopt a Pet</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>

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

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;