diff --git a/app/App.test.tsx b/app/App.test.tsx index 2a75b834b..0a2751b24 100644 --- a/app/App.test.tsx +++ b/app/App.test.tsx @@ -34,7 +34,7 @@ describe("", () => { coords: { lat: 37.7749, lng: -122.4194 }, inSanFrancisco: false, }, - aroundUserLocationRadius: "all", + aroundUserLocationRadius: 1600, setAroundRadius: expect.any(Function), setAroundLatLng: expect.any(Function), }); diff --git a/app/App.tsx b/app/App.tsx index c3caa1c13..602a4f43d 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -22,9 +22,9 @@ export const App = () => { const location = useLocation(); const [userLocation, setUserLocation] = useState(null); const [aroundLatLng, setAroundLatLng] = useState(""); - const [aroundUserLocationRadius, setAroundRadius] = useState( - "all" as const - ); + const [aroundUserLocationRadius, setAroundRadius] = + useState(1600); + const [boundingBox, setBoundingBox] = useState(undefined); useEffect(() => { getLocation().then((userLocation) => { @@ -60,6 +60,8 @@ export const App = () => { setAroundLatLng, aroundUserLocationRadius, setAroundRadius, + boundingBox, + setBoundingBox, }; return ( diff --git a/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx b/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx index 6e68fb9a3..82dfafff1 100644 --- a/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx +++ b/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx @@ -31,14 +31,43 @@ export const SearchMap = ({ null ); const { userLocation, aroundLatLng } = useAppContext(); - const { setAroundLatLng } = useAppContextUpdater(); + const { setAroundLatLng, setAroundRadius, setBoundingBox } = + useAppContextUpdater(); + + // Dynamically calculate search radius based on zoom level function handleSearchThisAreaClick() { - const center = googleMapObject?.getCenter(); - if (center?.lat() && center?.lng()) { - setAroundLatLng(`${center.lat()}, ${center.lng()}`); + const map = googleMapObject; + if (map) { + // Get the visible bounds of the map + const bounds = map.getBounds(); + if (bounds) { + const ne = bounds.getNorthEast(); + const sw = bounds.getSouthWest(); + + // Format as Algolia expects: "lat1,lng1,lat2,lng2" + // Where (lat1, lng1) is the top-left (NW) corner and (lat2, lng2) is the bottom-right (SE) corner + const boundingBoxString = `${ne.lat()},${sw.lng()},${sw.lat()},${ne.lng()}`; + + // Update the bounding box for search + setBoundingBox(boundingBoxString); + + // Set aroundRadius to "all" to disable radius-based filtering + setAroundRadius("all"); + + // Keep center point updated for reference (used for map centering) + const center = map.getCenter(); + if (center) { + const centerStr = `${center.lat()}, ${center.lng()}`; + setAroundLatLng(centerStr); + } + + // Notify SearchResultsPage component to reset pagination + handleSearchMapAction(SearchMapActions.SearchThisArea); + } + } else { + handleSearchMapAction(SearchMapActions.SearchThisArea); } - handleSearchMapAction(SearchMapActions.SearchThisArea); } const aroundLatLngToMapCenter = { @@ -127,6 +156,23 @@ export const SearchMap = ({ // SetMapObject shares the Google Map object across parent/sibling components // so that they can adjustments to markers, coordinates, layout, etc., setMapObject(map); + + // Set initial bounding box when map is first loaded + const idleListener = map.addListener("idle", () => { + // Remove the listener so it only fires once + google.maps.event.removeListener(idleListener); + + const bounds = map.getBounds(); + if (bounds) { + const ne = bounds.getNorthEast(); + const sw = bounds.getSouthWest(); + const boundingBoxString = `${ne.lat()},${sw.lng()},${sw.lat()},${ne.lng()}`; + setBoundingBox(boundingBoxString); + + // Notify that map is initialized + handleSearchMapAction(SearchMapActions.MapInitialized); + } + }); }} options={createMapOptions} > diff --git a/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss b/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss index dded7dd64..76d276f5a 100644 --- a/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss +++ b/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss @@ -64,10 +64,10 @@ .searchResultsHeader { display: flex; justify-content: space-between; - align-items: center; + align-items: baseline; h2 { - font-size: 20px; + font-size: 16px; } @media screen and (max-width: $break-tablet-p) { @@ -80,7 +80,7 @@ border-bottom: 1px solid $border-gray; h2 { - font-size: 18px; + font-size: 14px; } a { diff --git a/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx b/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx index 83a9e996d..f7aefa451 100644 --- a/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx +++ b/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx @@ -14,9 +14,11 @@ import styles from "./SearchResults.module.scss"; import ClearSearchButton from "../Refinements/ClearSearchButton"; import { Loader } from "components/ui/Loader"; import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination"; +import { SearchResultsHeader } from "components/ui/SearchResultsHeader"; export enum SearchMapActions { SearchThisArea, + MapInitialized, } const SearchResults = ({ @@ -24,7 +26,8 @@ const SearchResults = ({ }: { mobileMapIsCollapsed: boolean; }) => { - const { refine: refinePagination } = usePagination(); + const { refine: refinePagination, currentRefinement: currentPage } = + usePagination(); const { // Results type is algoliasearchHelper.SearchResults results: searchResults, @@ -55,15 +58,6 @@ const SearchResults = ({ ); - const searchResultsHeader = () => { - return ( -
-

{searchResults.nbHits} results

- -
- ); - }; - const handleAction = (searchMapAction: SearchMapActions) => { switch (searchMapAction) { case SearchMapActions.SearchThisArea: @@ -83,7 +77,10 @@ const SearchResults = ({ ) : ( <> - {searchResultsHeader()} + {searchMapHitData.hits.map((hit: TransformedSearchHit, index) => ( , () => setFilterMenuVisible(false), filterMenuVisible ); diff --git a/app/components/ui/Navigation/MobileNavigation.tsx b/app/components/ui/Navigation/MobileNavigation.tsx index 5bae8f1e6..a04f651e2 100644 --- a/app/components/ui/Navigation/MobileNavigation.tsx +++ b/app/components/ui/Navigation/MobileNavigation.tsx @@ -36,7 +36,7 @@ export const MobileNavigation = ({ menuData }: MobileNavigationProps) => { const mobileNavTextDisplay = mobileNavigationIsOpen ? "Close" : "Menu"; useClickOutside( - filterMenuRef, + filterMenuRef as React.RefObject, (event: MouseEvent) => { // Prevents collison between handling the outside click and clicking on the // "Close" button diff --git a/app/components/ui/SearchResultsHeader.tsx b/app/components/ui/SearchResultsHeader.tsx index b851f7179..dc1e3b15b 100644 --- a/app/components/ui/SearchResultsHeader.tsx +++ b/app/components/ui/SearchResultsHeader.tsx @@ -1,10 +1,32 @@ -import React, { ReactNode } from "react"; +import React from "react"; import styles from "components/SearchAndBrowse/SearchResults/SearchResults.module.scss"; +import ClearSearchButton from "components/SearchAndBrowse/Refinements/ClearSearchButton"; +import { HITS_PER_PAGE } from "pages/SearchResultsPage/SearchResultsPage"; /** * Layout component for the header above the search results list that allows for * flexible composition of child components. */ -export const SearchResultsHeader = ({ children }: { children: ReactNode }) => ( -
{children}
-); +export const SearchResultsHeader = ({ + currentPage, + totalResults, +}: { + currentPage: number; + totalResults: number; +}) => { + const firstResultIndex = currentPage * HITS_PER_PAGE + 1; + const lastResultIndex = Math.min( + (currentPage + 1) * HITS_PER_PAGE, + totalResults + ); + return ( +
+

+ Showing {firstResultIndex} -{" "} + {lastResultIndex} of{" "} + {totalResults} results +

+ +
+ ); +}; diff --git a/app/pages/BrowseResultsPage/BrowseResultsPage.module.scss b/app/pages/BrowseResultsPage/BrowseResultsPage.module.scss index 0652a3a6e..044c85a95 100644 --- a/app/pages/BrowseResultsPage/BrowseResultsPage.module.scss +++ b/app/pages/BrowseResultsPage/BrowseResultsPage.module.scss @@ -1 +1,16 @@ @use "~components/SearchAndBrowse/SearchAndBrowseResultsPage.module.scss"; + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + + p { + margin-top: 1rem; + font-size: 1.1rem; + color: #666; + } +} diff --git a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx index d70939ce2..f3e5f1843 100644 --- a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx +++ b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx @@ -1,7 +1,11 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback } from "react"; import { useParams } from "react-router-dom"; import * as dataService from "utils/DataService"; -import { DEFAULT_AROUND_PRECISION, useAppContext } from "utils"; +import { + DEFAULT_AROUND_PRECISION, + useAppContext, + useAppContextUpdater, +} from "utils"; import { SearchMapActions } from "components/SearchAndBrowse/SearchResults/SearchResults"; import { Loader } from "components/ui/Loader"; import Sidebar from "components/SearchAndBrowse/Sidebar/Sidebar"; @@ -25,11 +29,15 @@ import { useInstantSearch, usePagination } from "react-instantsearch"; import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination"; import searchResultsStyles from "components/SearchAndBrowse/SearchResults/SearchResults.module.scss"; import { SearchResultsHeader } from "components/ui/SearchResultsHeader"; +import { NoSearchResultsDisplay } from "components/ui/NoSearchResultsDisplay"; import { our415SubcategoryNames } from "utils/refinementMappings"; +export const HITS_PER_PAGE = 40; + /** Wrapper component that handles state management, URL parsing, and external API requests. */ export const BrowseResultsPage = () => { const { categorySlug } = useParams(); + const category = CATEGORIES.find((c) => c.slug === categorySlug); if (category === undefined) { throw new Error(`Unknown category slug ${categorySlug}`); @@ -37,21 +45,54 @@ export const BrowseResultsPage = () => { const [parentCategory, setParentCategory] = useState( null ); + const [isMapCollapsed, setIsMapCollapsed] = useState(false); + const [isMapInitialized, setIsMapInitialized] = useState(false); const eligibilities = useEligibilitiesForCategory(category.id); const subcategories = useSubcategoriesForCategory(category.id); - const [isMapCollapsed, setIsMapCollapsed] = useState(false); const { userLocation } = useAppContext(); - const { aroundUserLocationRadius, aroundLatLng } = useAppContext(); + const { aroundUserLocationRadius, aroundLatLng, boundingBox } = + useAppContext(); + + const { setBoundingBox, setAroundLatLng, setAroundRadius } = + useAppContextUpdater(); const { // Results type is algoliasearchHelper.SearchResults results: searchResults, status, } = useInstantSearch(); - const { refine: refinePagination } = usePagination(); + const { refine: refinePagination, currentRefinement: currentPage } = + usePagination(); const { refine: clearRefinements } = useClearRefinements(); useEffect(() => window.scrollTo(0, 0), []); + // Reset map state when category changes + useEffect( + () => { + // Reset bounding box and location parameters to original user location + // so the new category search starts fresh + setBoundingBox(undefined); + setAroundLatLng( + `${userLocation?.coords.lat},${userLocation?.coords.lng}` + ); + setAroundRadius(1600); // Reset to default radius + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + category.id, + setBoundingBox, + setAroundLatLng, + setAroundRadius, + userLocation, + ] + ); + + const handleFirstResultFocus = useCallback((node: HTMLDivElement | null) => { + if (node) { + node.focus(); + } + }, []); + const subcategoryNames = subcategories ?.map((c) => c.name) .filter((name) => our415SubcategoryNames.has(name)); @@ -79,7 +120,13 @@ export const BrowseResultsPage = () => { const handleAction = (searchMapAction: SearchMapActions) => { switch (searchMapAction) { case SearchMapActions.SearchThisArea: + // Center and radius are already updated in the SearchMap component + // Just reset pagination to show the first page of results return refinePagination(0); + case SearchMapActions.MapInitialized: + // Map has initialized and bounding box is now available + setIsMapInitialized(true); + return; } }; @@ -100,12 +147,24 @@ export const BrowseResultsPage = () => {
- + + {/* Only render the Configure component (which triggers the search) when the map is initialized */} + {isMapInitialized && ( + + )}
{ }`} >

Search results

- <> - {/* This is browse not search */} - -

{searchResults.nbHits} results

-
- {searchMapHitData.hits.map( - (hit: TransformedSearchHit, index) => ( - - ) - )} -
-
- -
+ {!isMapInitialized ? ( +
+ +

Initializing map and loading results...

- + ) : hasNoResults ? ( + + ) : ( + <> + {/* This is browse not search */} + + {searchMapHitData.hits.map( + (hit: TransformedSearchHit, index) => ( + + ) + )} +
+
+ +
+
+ + )}
{ + // Import React inside the mock factory + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mockReact = require("react"); + + return { + SearchMap: ({ handleSearchMapAction }: any) => { + // Simulate map initialization - MapInitialized = 1 + mockReact.useEffect(() => { + handleSearchMapAction(1); + }, [handleSearchMapAction]); + + return mockReact.createElement( + "div", + { "data-testid": "search-map-mock" }, + "Search Map" + ); + }, + }; +}); + +// Test wrapper with AppProvider +const TestWrapper = ({ children }: { children: React.ReactNode }) => { + const [aroundLatLng, setAroundLatLng] = React.useState(""); + const [aroundRadius, setAroundRadius] = React.useState<"all" | number>(1600); + const [boundingBox, setBoundingBox] = React.useState(); + + return ( + + {children} + + ); +}; describe("SearchResultsPage", () => { test("renders the Clear Search button", async () => { const searchClient = createSearchClient(); render( - - - + + + + + ); await waitFor(() => { diff --git a/app/pages/SearchResultsPage/SearchResultsPage.tsx b/app/pages/SearchResultsPage/SearchResultsPage.tsx index a51e2590d..e2beef71d 100644 --- a/app/pages/SearchResultsPage/SearchResultsPage.tsx +++ b/app/pages/SearchResultsPage/SearchResultsPage.tsx @@ -17,13 +17,17 @@ import { Loader } from "components/ui/Loader"; import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination"; import { NoSearchResultsDisplay } from "components/ui/NoSearchResultsDisplay"; import { SearchResultsHeader } from "components/ui/SearchResultsHeader"; -import ClearSearchButton from "components/SearchAndBrowse/Refinements/ClearSearchButton"; + +export const HITS_PER_PAGE = 40; // NOTE: The .searchResultsPage is added plain so that it can be targeted by print-specific css export const SearchResultsPage = () => { const [isMapCollapsed, setIsMapCollapsed] = useState(false); - const { aroundUserLocationRadius, aroundLatLng } = useAppContext(); - const { refine: refinePagination } = usePagination(); + const [isMapInitialized, setIsMapInitialized] = useState(false); + const { aroundUserLocationRadius, aroundLatLng, boundingBox } = + useAppContext(); + const { refine: refinePagination, currentRefinement: currentPage } = + usePagination(); const { // Results type is algoliasearchHelper.SearchResults results: searchResults, @@ -48,18 +52,39 @@ export const SearchResultsPage = () => { const handleAction = (searchMapAction: SearchMapActions) => { switch (searchMapAction) { case SearchMapActions.SearchThisArea: + // Center and radius are already updated in the SearchMap component + // Just reset pagination to show the first page of results return refinePagination(0); + case SearchMapActions.MapInitialized: + // Map has initialized and bounding box is now available + setIsMapInitialized(true); + return; } }; + // Search parameters are now configured based on map initialization + return (
- + {/* Only render the Configure component (which triggers the search) when the map is initialized */} + {isMapInitialized && ( + + )}
{ }`} >

Search results

- {hasNoResults ? ( + {!isMapInitialized ? ( +
+ +

Initializing map and loading results...

+
+ ) : hasNoResults ? ( ) : ( <> - -

{searchResults.nbHits} results

- -
+ {searchMapHitData.hits.map( (hit: TransformedSearchHit, index) => ( >; setAroundLatLng: Dispatch>; + setBoundingBox: Dispatch>; } interface AppProviderProps { @@ -26,6 +28,8 @@ interface AppProviderProps { setAroundLatLng: Dispatch>; aroundUserLocationRadius: AroundRadius; setAroundRadius: Dispatch>; + boundingBox?: string; + setBoundingBox: Dispatch>; } export const AppContext = createContext({ @@ -33,13 +37,15 @@ export const AppContext = createContext({ coords: COORDS_MID_SAN_FRANCISCO, inSanFrancisco: false, }, - aroundUserLocationRadius: "all", + aroundUserLocationRadius: 1600, aroundLatLng: "", + boundingBox: undefined, }); export const AppContextUpdater = createContext({ - setAroundRadius: () => "all", + setAroundRadius: () => 1600, setAroundLatLng: () => "", + setBoundingBox: () => undefined, }); export const useAppContext = () => useContext(AppContext); @@ -52,6 +58,8 @@ export const AppProvider = ({ setAroundLatLng, aroundUserLocationRadius, setAroundRadius, + boundingBox, + setBoundingBox, }: AppProviderProps) => { // We have to use useMemo here to manage the contextValue to ensure that the user's authState // propagates downward after authentication. I couldn't find a way to get this to work with @@ -62,12 +70,15 @@ export const AppProvider = ({ userLocation, aroundUserLocationRadius, aroundLatLng, + boundingBox, }; - }, [userLocation, aroundUserLocationRadius, aroundLatLng]); + }, [userLocation, aroundUserLocationRadius, aroundLatLng, boundingBox]); return ( - + {children} diff --git a/webpack.config.js b/webpack.config.js index 56b183160..e4a4f0875 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -59,7 +59,7 @@ module.exports = { entry: ["@babel/polyfill", path.resolve(appRoot, "init.tsx")], output: { path: buildDir, - publicPath: "/dist/", + publicPath: process.env.NODE_ENV === "development" ? "/" : "/dist/", filename: "bundle.js", clean: true, }, @@ -190,7 +190,7 @@ module.exports = { proxy: [ { context: ["/api-docs"], - target: config.API_UR || "http://localhost:3000", + target: config.API_URL || "http://localhost:3000", secure: config.API_PROXY_SECURE || false, changeOrigin: config.API_PROXY_CHANGE_ORIGIN || false, },