diff --git a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java index 00ce63f8..d26e3e84 100644 --- a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -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(); } } diff --git a/web/app/adopt/[id]/page.js b/web/app/adopt/[id]/page.js new file mode 100644 index 00000000..21a24aa4 --- /dev/null +++ b/web/app/adopt/[id]/page.js @@ -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 ( +
+
+ ← Back to Pets + + {loading &&

Loading pet details...

} + {error &&

{error}

} + + {!loading && !error && pet && ( + + )} +
+
+ ); +} diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js new file mode 100644 index 00000000..9a30f6ea --- /dev/null +++ b/web/app/adopt/page.js @@ -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 ( +
+
+

Find Your Perfect Companion

+

Give a loving pet their forever home

+
+
+ +
+
+
+ setSearch(e.target.value)} + /> + + {query && ( + + )} +
+ +
+
+ +
+ {loading &&

Loading pets...

} + + {error && ( +
+

Failed to load pets

+ {error} +

+ {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."} +

+
+ )} + + {!loading && !error && pets.length === 0 && ( +

No pets found.

+ )} + + {!loading && !error && pets.length > 0 && ( +
+ {pets.map((pet) => ( + + ))} +
+ )} + + {!loading && totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ )} +
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css index fd71a692..7e93dfb2 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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; } -} \ No newline at end of file +} +/* ─── 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; +} diff --git a/web/app/page.js b/web/app/page.js index bf0875e6..6aea5518 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -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 (
- {/* Slideshow Section */} + {/* Slideshow */}
{slideshowImages.map((image, index) => (
- {/* Centered Title Section */} + {/* Title Section */}

Welcome to Leon's Pet Store

Your One-Stop Shop for All Things Pets

- {/* Four Image Links Section */} + {/* Image Hyperlinks */}
{navImages.map((item, index) => ( diff --git a/web/components/Navigation.js b/web/components/Navigation.js index 4780df6c..f7de55e8 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -15,7 +15,7 @@ export default function DisplayNav() {
Home - Adopt a Pet + Adopt a Pet Online Store Schedule an Appointment Contact Us diff --git a/web/components/PetCard.js b/web/components/PetCard.js new file mode 100644 index 00000000..5ccb0219 --- /dev/null +++ b/web/components/PetCard.js @@ -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 ( + +
+ {getSpeciesEmoji(petSpecies)} +
+
+

{petName}

+

{petSpecies}

+ + {petStatus} + +
+ + ); +} diff --git a/web/components/PetProfile.js b/web/components/PetProfile.js new file mode 100644 index 00000000..87d4b059 --- /dev/null +++ b/web/components/PetProfile.js @@ -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 ( +
+
+ {getSpeciesEmoji(petSpecies)} +
+ +
+
+

{petName}

+ + {petStatus} + +
+ +
+
+ Species + {petSpecies ?? "—"} +
+
+ Breed + {petBreed ?? "—"} +
+
+ Age + + {petAge != null ? `${petAge} ${petAge === 1 ? "year" : "years"}` : "—"} + +
+
+ Adoption Fee + + {petPrice != null ? `$${parseFloat(petPrice).toFixed(2)}` : "—"} + +
+
+ + {/* Status */} + {petStatus?.toLowerCase() === "available" && ( +
+

+ Interested in adopting {petName}? Visit us in store or schedule an appointment. +

+ + Schedule an Appointment + +
+ )} +
+
+ ); +} diff --git a/web/components/petUtils.js b/web/components/petUtils.js new file mode 100644 index 00000000..4a07b30e --- /dev/null +++ b/web/components/petUtils.js @@ -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"; +} diff --git a/web/next.config.mjs b/web/next.config.mjs index 690d2d0d..b725bf69 100644 --- a/web/next.config.mjs +++ b/web/next.config.mjs @@ -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;