From 1c0f55fbe5f12dd9831aa2286134f26381f83e37 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Wed, 25 Mar 2026 08:19:44 -0600 Subject: [PATCH 1/2] Adopt page, minor adjustment to backend --- .../backend/security/SecurityConfig.java | 32 +- web/app/adopt/[id]/page.js | 52 ++ web/app/adopt/page.js | 149 ++++++ web/app/globals.css | 487 +++++++++++++++++- web/app/page.js | 12 +- web/components/Navigation.js | 2 +- web/components/PetCard.js | 21 + web/components/PetProfile.js | 56 ++ web/components/petUtils.js | 54 ++ web/next.config.mjs | 9 +- 10 files changed, 858 insertions(+), 16 deletions(-) create mode 100644 web/app/adopt/[id]/page.js create mode 100644 web/app/adopt/page.js create mode 100644 web/components/PetCard.js create mode 100644 web/components/PetProfile.js create mode 100644 web/components/petUtils.js 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; From c048c4bdc3ae6d094c24cab9bacdb5f4522aac6b Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Sun, 29 Mar 2026 17:43:31 -0600 Subject: [PATCH 2/2] Account login --- web/app/globals.css | 247 ++++++++++++++++++++++++++++++ web/app/layout.js | 9 +- web/app/login/page.js | 75 +++++++++ web/app/page.js | 16 +- web/app/profile/page.js | 61 ++++++++ web/app/register/page.js | 151 ++++++++++++++++++ web/components/ClientProviders.js | 7 + web/components/Navigation.js | 65 +++++--- web/context/AuthContext.js | 107 +++++++++++++ 9 files changed, 708 insertions(+), 30 deletions(-) create mode 100644 web/app/login/page.js create mode 100644 web/app/profile/page.js create mode 100644 web/app/register/page.js create mode 100644 web/components/ClientProviders.js create mode 100644 web/context/AuthContext.js diff --git a/web/app/globals.css b/web/app/globals.css index 7e93dfb2..465c429d 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -773,3 +773,250 @@ body { 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; +} diff --git a/web/app/layout.js b/web/app/layout.js index 86efbb81..0a50c8db 100644 --- a/web/app/layout.js +++ b/web/app/layout.js @@ -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 ( - - {children} + + + {children} + ); diff --git a/web/app/login/page.js b/web/app/login/page.js new file mode 100644 index 00000000..32c05c52 --- /dev/null +++ b/web/app/login/page.js @@ -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 ( +
+
+

Log In

+ + {error &&

{error}

} + +
+ + + + + +
+ +

+ Don't have an account?{" "} + Register here +

+
+
+ ); +} diff --git a/web/app/page.js b/web/app/page.js index 6aea5518..4819bd7d 100644 --- a/web/app/page.js +++ b/web/app/page.js @@ -7,10 +7,10 @@ import { useState, useEffect } from "react"; export default function Home() { //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); @@ -25,10 +25,10 @@ export default function Home() { //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 ( diff --git a/web/app/profile/page.js b/web/app/profile/page.js new file mode 100644 index 00000000..fcc903cc --- /dev/null +++ b/web/app/profile/page.js @@ -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

Loading…

; + } + + 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 ( +
+
+
+ {(user.fullName || user.username).charAt(0).toUpperCase()} +
+ +

{user.fullName || user.username}

+ {user.role} + +
+ {fields.map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + +
+
+ ); +} diff --git a/web/app/register/page.js b/web/app/register/page.js new file mode 100644 index 00000000..20dcd2c1 --- /dev/null +++ b/web/app/register/page.js @@ -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 ( +
+
+

Create Account

+ + {error &&

{error}

} + +
+ + + + + + + + + + + + + +
+ +

+ Already have an account?{" "} + Log in here +

+
+
+ ); +} diff --git a/web/components/ClientProviders.js b/web/components/ClientProviders.js new file mode 100644 index 00000000..64e8157a --- /dev/null +++ b/web/components/ClientProviders.js @@ -0,0 +1,7 @@ +"use client"; + +import { AuthProvider } from "@/context/AuthContext"; + +export default function ClientProviders({children}) { + return {children}; +} diff --git a/web/components/Navigation.js b/web/components/Navigation.js index f7de55e8..2e7f7cac 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -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 ( + + + + +
+ {loading ? null : user ? ( + <> + + Hello, {user.fullName || user.username} + + + + ) : ( + <> + Log In + Register + + )} +
+ ); } \ No newline at end of file diff --git a/web/context/AuthContext.js b/web/context/AuthContext.js new file mode 100644 index 00000000..5a9b0d08 --- /dev/null +++ b/web/context/AuthContext.js @@ -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 ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + + return ctx; +}