From 23125418c377d29dee08338caf60a8ecb1322c7a Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Sun, 12 Apr 2026 13:44:48 -0600 Subject: [PATCH] Adopt page filter added --- .../controller/DropdownController.java | 20 +++ .../backend/controller/PetController.java | 3 +- .../backend/repository/PetRepository.java | 9 +- .../backend/security/SecurityConfig.java | 3 + .../petshop/backend/service/PetService.java | 11 +- web/app/adopt/page.js | 134 ++++++++++++++---- web/app/globals.css | 52 +++++++ web/components/Navigation.js | 2 +- 8 files changed, 197 insertions(+), 37 deletions(-) diff --git a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java index e391023f..5ca97986 100644 --- a/backend/src/main/java/com/petshop/backend/controller/DropdownController.java +++ b/backend/src/main/java/com/petshop/backend/controller/DropdownController.java @@ -7,6 +7,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.List; @@ -149,6 +150,25 @@ public class DropdownController { ); } + @GetMapping("/pet-breeds") + public ResponseEntity> getPetBreeds(@RequestParam(required = false) String species) { + if (species == null || species.isBlank()) { + + return ResponseEntity.ok(java.util.Collections.emptyList()); + } + + return ResponseEntity.ok( + petRepository.findAll().stream() + .filter(p -> species.equalsIgnoreCase(p.getPetSpecies())) + .map(p -> p.getPetBreed()) + .filter(breed -> breed != null && !breed.isBlank()) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .map(breed -> new DropdownOption(null, breed)) + .collect(Collectors.toList()) + ); + } + @GetMapping("/stores") public ResponseEntity> getStores() { return ResponseEntity.ok( diff --git a/backend/src/main/java/com/petshop/backend/controller/PetController.java b/backend/src/main/java/com/petshop/backend/controller/PetController.java index 3fcdcce4..a708a4db 100644 --- a/backend/src/main/java/com/petshop/backend/controller/PetController.java +++ b/backend/src/main/java/com/petshop/backend/controller/PetController.java @@ -26,11 +26,12 @@ public class PetController { public ResponseEntity> getAllPets( @RequestParam(required = false) String q, @RequestParam(required = false) String species, + @RequestParam(required = false) String breed, @RequestParam(required = false) String status, @RequestParam(required = false) Long storeId, @RequestParam(required = false) Long customerId, Pageable pageable) { - return ResponseEntity.ok(petService.getAllPets(q, species, status, storeId, customerId, pageable)); + return ResponseEntity.ok(petService.getAllPets(q, species, breed, status, storeId, customerId, pageable)); } @GetMapping("/{id}") diff --git a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java index 8197fc6f..f020c57e 100644 --- a/backend/src/main/java/com/petshop/backend/repository/PetRepository.java +++ b/backend/src/main/java/com/petshop/backend/repository/PetRepository.java @@ -40,21 +40,24 @@ public interface PetRepository extends JpaRepository { @Query("SELECT p FROM Pet p WHERE " + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status)) AND " + "(:storeId IS NULL OR p.store.storeId = :storeId) AND " + "(:customerId IS NULL OR p.owner.id = :customerId)") - Page searchPets(@Param("q") String query, @Param("species") String species, @Param("status") String status, @Param("storeId") Long storeId, @Param("customerId") Long customerId, Pageable pageable); + Page searchPets(@Param("q") String query, @Param("species") String species, @Param("breed") String breed, @Param("status") String status, @Param("storeId") Long storeId, @Param("customerId") Long customerId, Pageable pageable); @Query("SELECT p FROM Pet p WHERE LOWER(p.petStatus) = 'available' AND " + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:storeId IS NULL OR p.store.storeId = :storeId)") - Page searchPublicPets(@Param("q") String query, @Param("species") String species, @Param("storeId") Long storeId, Pageable pageable); + Page searchPublicPets(@Param("q") String query, @Param("species") String species, @Param("breed") String breed, @Param("storeId") Long storeId, Pageable pageable); @Query("SELECT DISTINCT p FROM Pet p LEFT JOIN Adoption a ON a.pet = p AND LOWER(a.adoptionStatus) = 'completed' WHERE " + "(LOWER(p.petStatus) = 'available' OR a.customer.id = :userId OR (LOWER(p.petStatus) = 'owned' AND p.owner.id = :userId)) AND " + "(:q IS NULL OR LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(p.petSpecies) LIKE LOWER(CONCAT('%', :q, '%')) OR LOWER(COALESCE(p.petBreed, '')) LIKE LOWER(CONCAT('%', :q, '%'))) AND " + "(:species IS NULL OR LOWER(p.petSpecies) = LOWER(:species)) AND " + + "(:breed IS NULL OR LOWER(COALESCE(p.petBreed, '')) = LOWER(:breed)) AND " + "(:status IS NULL OR LOWER(p.petStatus) = LOWER(:status))") - Page searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("status") String status, Pageable pageable); + Page searchCustomerVisiblePets(@Param("userId") Long userId, @Param("q") String query, @Param("species") String species, @Param("breed") String breed, @Param("status") String status, Pageable pageable); } 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 c197242f..1a6cedce 100644 --- a/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/backend/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -58,6 +58,9 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/services/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/categories/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-species").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/pet-breeds").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/dropdowns/stores").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/appointments/availability").permitAll() .anyRequest().authenticated() ) diff --git a/backend/src/main/java/com/petshop/backend/service/PetService.java b/backend/src/main/java/com/petshop/backend/service/PetService.java index 0051d893..ae414601 100644 --- a/backend/src/main/java/com/petshop/backend/service/PetService.java +++ b/backend/src/main/java/com/petshop/backend/service/PetService.java @@ -48,9 +48,10 @@ public class PetService { } @Transactional(readOnly = true) - public Page getAllPets(String query, String species, String status, Long storeId, Long customerId, Pageable pageable) { + public Page getAllPets(String query, String species, String breed, String status, Long storeId, Long customerId, Pageable pageable) { String normalizedQuery = normalizeFilter(query); String normalizedSpecies = normalizeFilter(species); + String normalizedBreed = normalizeFilter(breed); String normalizedStatus = normalizeFilter(status); CurrentViewer viewer = getCurrentViewer(); @@ -59,16 +60,16 @@ public class PetService { if (!isAllowedPublicStatus(normalizedStatus)) { return new PageImpl<>(java.util.List.of(), pageable, 0); } - pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, storeId, pageable); + pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, normalizedBreed, storeId, pageable); } else if (viewer.role() == User.Role.STAFF || viewer.role() == User.Role.ADMIN) { - pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedStatus, storeId, customerId, pageable); + pets = petRepository.searchPets(normalizedQuery, normalizedSpecies, normalizedBreed, normalizedStatus, storeId, customerId, pageable); } else if (viewer.role() == User.Role.CUSTOMER) { if (!isAllowedCustomerStatus(normalizedStatus)) { return new PageImpl<>(java.util.List.of(), pageable, 0); } - pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedStatus, pageable); + pets = petRepository.searchCustomerVisiblePets(viewer.userId(), normalizedQuery, normalizedSpecies, normalizedBreed, normalizedStatus, pageable); } else { - pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, storeId, pageable); + pets = petRepository.searchPublicPets(normalizedQuery, normalizedSpecies, normalizedBreed, storeId, pageable); } return pets diff --git a/web/app/adopt/page.js b/web/app/adopt/page.js index a6a2784a..2d4e7a18 100644 --- a/web/app/adopt/page.js +++ b/web/app/adopt/page.js @@ -1,31 +1,62 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import PetCard from "@/components/PetCard"; import { fetchAllPages } from "@/lib/fetchAllPages"; +import { useCart } from "@/context/CartContext"; const API_BASE = ""; +const PAGE_SIZE = 10000; export default function AdoptPage() { + const { selectedStoreId } = useCart(); + + // pets = everything returned by the server (filtered by species + store + text query) 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_SIZE = 100; + const [selectedSpecies, setSelectedSpecies] = useState(""); + const [selectedBreed, setSelectedBreed] = useState(""); + // Species options come from a dedicated fetch (only store-filtered, no species filter) + const [speciesOptions, setSpeciesOptions] = useState([]); + + // ---------- health check ---------- useEffect(() => { fetch(`${API_BASE}/api/v1/health`) .then((res) => (res.ok ? setHealth("online") : setHealth("error"))) .catch(() => setHealth("offline")); }, []); + useEffect(() => { + setSelectedSpecies(""); + const params = new URLSearchParams({ status: "Available", page: "0", size: String(PAGE_SIZE) }); + if (selectedStoreId) params.set("storeId", String(selectedStoreId)); + + fetch(`${API_BASE}/api/v1/pets?${params}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + const items = data?.content ?? []; + const species = [...new Set(items.map((p) => p.petSpecies).filter(Boolean))].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ); + setSpeciesOptions(species); + }) + .catch(() => setSpeciesOptions([])); + }, [selectedStoreId]); + + useEffect(() => { + setSelectedBreed(""); + }, [selectedSpecies]); + useEffect(() => { setLoading(true); setError(null); - fetchAllPages((page) => { const params = new URLSearchParams({ page: String(page), @@ -33,25 +64,44 @@ export default function AdoptPage() { sort: "id,asc", status: "Available", }); - if (query) { - params.set("q", query); - } + if (query) params.set("q", query); + if (selectedSpecies) params.set("species", selectedSpecies); + if (selectedStoreId) params.set("storeId", String(selectedStoreId)); + return `${API_BASE}/api/v1/pets?${params}`; }) - .then((allPets) => { - setPets(allPets); - }) + .then(setPets) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); - }, [query]); + }, [query, selectedSpecies, selectedStoreId]); + + const breedOptions = useMemo( + () => + [...new Set(pets.map((p) => p.petBreed).filter(Boolean))].sort((a, b) => + a.localeCompare(b, undefined, { sensitivity: "base" }) + ), + [pets] + ); + + const displayedPets = useMemo( + () => (selectedBreed ? pets.filter((p) => p.petBreed === selectedBreed) : pets), + [pets, selectedBreed] + ); function handleSearch(e) { e.preventDefault(); - setLoading(true); - setError(null); setQuery(search.trim()); } + function handleClearFilters() { + setSearch(""); + setQuery(""); + setSelectedSpecies(""); + setSelectedBreed(""); + } + + const hasActiveFilters = query || selectedSpecies || selectedBreed; + return (
@@ -61,26 +111,57 @@ export default function AdoptPage() {
+
+
+ + +
+ +
+ + +
+
+
setSearch(e.target.value)} /> - {query && ( - - )}
+ + {hasActiveFilters && ( + + )} + )} - {!loading && !error && pets.length === 0 && ( -

No pets found.

+ {!loading && !error && displayedPets.length === 0 && ( +

No pets found matching your filters.

)} - {!loading && !error && pets.length > 0 && ( + {!loading && !error && displayedPets.length > 0 && (
- {pets.map((pet) => ( + {displayedPets.map((pet) => ( )} -
); diff --git a/web/app/globals.css b/web/app/globals.css index b0e64ba5..c468269c 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -824,6 +824,58 @@ body { } /* Adopt diagnostics */ +.adopt-filters-row { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1rem; +} + +.adopt-filter-group { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 160px; +} + +.adopt-filter-label { + font-size: 0.8rem; + font-weight: 600; + color: #555; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.adopt-filter-select { + padding: 0.6rem 1rem; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 0.95rem; + font-family: Arial, sans-serif; + background: #fff; + color: #333; + cursor: pointer; + transition: border-color 0.2s ease; + outline: none; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23666' d='M0 0l6 8 6-8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.75rem center; + padding-right: 2rem; +} + +.adopt-filter-select:focus { + border-color: orange; +} + +.adopt-filter-select:disabled { + background-color: #f5f5f5; + color: #aaa; + cursor: not-allowed; + border-color: #e0e0e0; +} + .adopt-controls-row { display: flex; align-items: center; diff --git a/web/components/Navigation.js b/web/components/Navigation.js index 298af8ba..a6e04101 100644 --- a/web/components/Navigation.js +++ b/web/components/Navigation.js @@ -56,7 +56,7 @@ export default function DisplayNav() { value={selectedStoreId ?? ""} onChange={(e) => setStoreId(e.target.value || null)} > - + {stores.map((s) => (