diff --git a/.github/workflows/deploy-development.yml b/.github/workflows/deploy-development.yml index 5167ad0b1..8b8d9d38b 100644 --- a/.github/workflows/deploy-development.yml +++ b/.github/workflows/deploy-development.yml @@ -2,7 +2,8 @@ name: Build and deploy to heroku staging. on: push: - branches: [development] + branches-ignore: + - main jobs: build_and_deploy_app: diff --git a/.gitignore b/.gitignore index 2109283d6..9a9473cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ public/sitemap.xml.gz # jest coverage + +docs/* diff --git a/app/App.tsx b/app/App.tsx index 9264c4bb4..e14ea177c 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -31,7 +31,15 @@ export const App = () => { useEffect(() => { getLocation().then((userLocation) => { setUserLocation(userLocation); - setAroundLatLng(`${userLocation.coords.lat},${userLocation.coords.lng}`); + const lat = userLocation.coords.lat; + const lng = userLocation.coords.lng; + setAroundLatLng(`${lat},${lng}`); + + // Set default bounding box to cover all of San Francisco + // This ensures users see all available resources, not just those in their immediate area + // Format: "northLat,westLng,southLat,eastLng" (NW corner, SE corner) + const SF_BOUNDING_BOX = "37.812,-122.527,37.708,-122.357"; + setBoundingBox(SF_BOUNDING_BOX); }); }, [location, setAroundLatLng]); diff --git a/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx b/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx index e285dc82a..4e37d73bf 100644 --- a/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx +++ b/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx @@ -1,6 +1,6 @@ import { Button } from "components/ui/inline/Button/Button"; import React from "react"; -import { useSearchBox } from "react-instantsearch-core"; +import { useSearchQuery } from "../../../search/hooks"; import styles from "./SearchHeaderSection.module.scss"; /** @@ -11,7 +11,7 @@ export const SearchHeaderSection = ({ }: { descriptionText: string; }) => { - const { query } = useSearchBox(); + const { query } = useSearchQuery(); return (
diff --git a/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx b/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx index 9fc6710dc..2c416d990 100644 --- a/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx +++ b/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button } from "components/ui/inline/Button/Button"; -import { useClearRefinements } from "react-instantsearch"; +import { useClearRefinements } from "../../../search/hooks"; import { useAppContextUpdater } from "utils"; /** @@ -9,13 +9,13 @@ import { useAppContextUpdater } from "utils"; */ const ClearAllFilter = () => { const { setAroundRadius } = useAppContextUpdater(); - const { refine } = useClearRefinements(); + const { clearAll } = useClearRefinements(); return (
- + ); }; diff --git a/app/components/ui/SiteSearchInput.tsx b/app/components/ui/SiteSearchInput.tsx index 82bc8f70d..5806cff7e 100644 --- a/app/components/ui/SiteSearchInput.tsx +++ b/app/components/ui/SiteSearchInput.tsx @@ -1,5 +1,5 @@ import React, { FormEvent, useEffect, useState } from "react"; -import { useClearRefinements, useSearchBox } from "react-instantsearch"; +import { useClearRefinements, useSearchQuery } from "../../search/hooks"; import classNames from "classnames"; import { useNavigate } from "react-router-dom"; import styles from "./SiteSearchInput.module.scss"; @@ -11,8 +11,8 @@ import styles from "./SiteSearchInput.module.scss"; * return a fresh set of results for the new query. */ export const SiteSearchInput = () => { - const { query, refine } = useSearchBox(); - const { refine: clearRefine } = useClearRefinements(); + const { query, setQuery: refine } = useSearchQuery(); + const { clearAll: clearRefine } = useClearRefinements(); const [inputValue, setInputValue] = useState(query); const navigate = useNavigate(); diff --git a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx index b0e6c8a01..49c235195 100644 --- a/app/pages/BrowseResultsPage/BrowseResultsPage.tsx +++ b/app/pages/BrowseResultsPage/BrowseResultsPage.tsx @@ -18,25 +18,36 @@ import { } from "hooks/APIHooks"; import { CATEGORIES, ServiceCategory } from "../constants"; import styles from "./BrowseResultsPage.module.scss"; -import { Configure, useClearRefinements } from "react-instantsearch-core"; +import { + useSearchResults, + useSearchPagination, + useClearRefinements, +} from "../../search/hooks"; import { SearchMap } from "components/SearchAndBrowse/SearchMap/SearchMap"; import { SearchResult } from "components/SearchAndBrowse/SearchResults/SearchResult"; import { TransformedSearchHit, transformSearchResults, } from "models/SearchHits"; -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"; +import { + SearchConfigProvider, + useSearchConfig, +} from "utils/SearchConfigContext"; export const HITS_PER_PAGE = 40; -/** Wrapper component that handles state management, URL parsing, and external API requests. */ -export const BrowseResultsPage = () => { +/** + * BrowseResultsPageContent - The main content component that uses search config + * This is separated so it can access the SearchConfigProvider context + */ +const BrowseResultsPageContent = () => { const { categorySlug } = useParams(); + const { updateConfig } = useSearchConfig(); const category = CATEGORIES.find((c) => c.slug === categorySlug); if (category === undefined) { @@ -55,14 +66,9 @@ export const BrowseResultsPage = () => { const { setBoundingBox, setAroundLatLng, setAroundRadius } = useAppContextUpdater(); - const { - // Results type is algoliasearchHelper.SearchResults - results: searchResults, - status, - } = useInstantSearch(); - const { refine: refinePagination, currentRefinement: currentPage } = - usePagination(); - const { refine: clearRefinements } = useClearRefinements(); + const { results: searchResults, isIdle } = useSearchResults(); + const { goToPage, currentPage } = useSearchPagination(); + const { clearAll: clearRefinements } = useClearRefinements(); useEffect(() => window.scrollTo(0, 0), []); @@ -113,16 +119,56 @@ export const BrowseResultsPage = () => { ? escapeApostrophes(parentCategory.name) : null; - const searchMapHitData = transformSearchResults(searchResults); + // Update search config when map is initialized and we have category name + useEffect(() => { + // Wait until map is initialized + if (!isMapInitialized) return; + + // Wait until we have category name + if (!algoliaCategoryName) return; + + // Wait until we have geographic data (boundingBox OR aroundLatLng) + if (!boundingBox && !aroundLatLng) return; + + const config = { + filters: `categories:'${algoliaCategoryName}'`, + hitsPerPage: HITS_PER_PAGE, + ...(boundingBox + ? { + insideBoundingBox: [boundingBox.split(",").map(Number)], + } + : { + aroundLatLng, + aroundRadius: aroundUserLocationRadius, + aroundPrecision: DEFAULT_AROUND_PRECISION, + minimumAroundRadius: 100, + }), + }; + updateConfig(config); + }, [ + isMapInitialized, + algoliaCategoryName, + boundingBox, + aroundLatLng, + aroundUserLocationRadius, + updateConfig, + ]); - const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle"; + // Transform search results for display + // TODO: Update transformSearchResults to work with provider-agnostic types + const searchMapHitData = searchResults + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformSearchResults(searchResults as any) + : { hits: [], nbHits: 0 }; + + const hasNoResults = searchMapHitData.nbHits === 0 && isIdle; 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); + return goToPage(0); case SearchMapActions.MapInitialized: // Map has initialized and bounding box is now available setIsMapInitialized(true); @@ -148,35 +194,20 @@ export const BrowseResultsPage = () => {
- {/* Only render the Configure component (which triggers the search) when the map is initialized */} - {isMapInitialized && ( - - )} -
- + {/* Only render Sidebar after map is initialized to prevent premature Algolia search */} + {isMapInitialized && ( + + )}
@@ -200,7 +231,7 @@ export const BrowseResultsPage = () => { {/* This is browse not search */} {searchMapHitData.hits.map( (hit: TransformedSearchHit, index) => ( @@ -235,3 +266,48 @@ export const BrowseResultsPage = () => { ); }; + +/** + * BrowseResultsPage - Wrapper that provides SearchConfigProvider + * This 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}`); + } + + const { boundingBox, aroundLatLng, aroundUserLocationRadius } = + useAppContext(); + + // Calculate initial config synchronously BEFORE rendering + // For browse pages, we don't have the category filter yet (loaded async) + // But we can set the geographic config to prevent searches without it + const initialConfig = React.useMemo(() => { + // Wait until we have geographic data before providing initial config + if (!boundingBox && !aroundLatLng) { + return {}; + } + + return boundingBox + ? { + insideBoundingBox: [boundingBox.split(",").map(Number)], + hitsPerPage: HITS_PER_PAGE, + } + : { + aroundLatLng, + aroundRadius: aroundUserLocationRadius, + aroundPrecision: DEFAULT_AROUND_PRECISION, + minimumAroundRadius: 100, + hitsPerPage: HITS_PER_PAGE, + }; + }, [boundingBox, aroundLatLng, aroundUserLocationRadius]); + + return ( + + + + ); +}; diff --git a/app/pages/SearchResultsPage/SearchResultsPage.tsx b/app/pages/SearchResultsPage/SearchResultsPage.tsx index e2beef71d..68484d4a1 100644 --- a/app/pages/SearchResultsPage/SearchResultsPage.tsx +++ b/app/pages/SearchResultsPage/SearchResultsPage.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useState, useEffect } from "react"; import { SearchMapActions } from "components/SearchAndBrowse/SearchResults/SearchResults"; import Sidebar from "components/SearchAndBrowse/Sidebar/Sidebar"; import styles from "./SearchResultsPage.module.scss"; -import { DEFAULT_AROUND_PRECISION, useAppContext } from "utils"; -import { Configure } from "react-instantsearch-core"; +import { useAppContext } from "utils"; +import { DEFAULT_AROUND_PRECISION } from "utils/location"; import classNames from "classnames"; import { SearchMap } from "components/SearchAndBrowse/SearchMap/SearchMap"; import { SearchResult } from "components/SearchAndBrowse/SearchResults/SearchResult"; @@ -11,50 +11,86 @@ import { TransformedSearchHit, transformSearchResults, } from "models/SearchHits"; -import { useInstantSearch, usePagination } from "react-instantsearch"; +import { useSearchResults, useSearchPagination } from "../../search/hooks"; import searchResultsStyles from "components/SearchAndBrowse/SearchResults/SearchResults.module.scss"; 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 { + SearchConfigProvider, + useSearchConfig, +} from "utils/SearchConfigContext"; 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 = () => { +/** + * SearchResultsPageContent - The main content component that uses search config + * This is separated so it can access the SearchConfigProvider context + */ +const SearchResultsPageContent = () => { const [isMapCollapsed, setIsMapCollapsed] = useState(false); const [isMapInitialized, setIsMapInitialized] = useState(false); - const { aroundUserLocationRadius, aroundLatLng, boundingBox } = + const { boundingBox, aroundLatLng, aroundUserLocationRadius } = useAppContext(); - const { refine: refinePagination, currentRefinement: currentPage } = - usePagination(); - const { - // Results type is algoliasearchHelper.SearchResults - results: searchResults, - status, - indexUiState: { query = null }, - } = useInstantSearch(); + const { updateConfig } = useSearchConfig(); + const { goToPage, currentPage } = useSearchPagination(); + const { results: searchResults, isIdle, query } = useSearchResults(); useEffect(() => window.scrollTo(0, 0), []); + // Update search config when geo parameters change (e.g., user pans the map) + useEffect(() => { + // Don't update on initial mount - initialConfig already handles that + if (!isMapInitialized) return; + + const config = boundingBox + ? { + insideBoundingBox: [boundingBox.split(",").map(Number)], + hitsPerPage: HITS_PER_PAGE, + aroundLatLng: undefined, + aroundRadius: undefined, + aroundPrecision: undefined, + minimumAroundRadius: undefined, + } + : { + aroundLatLng, + aroundRadius: aroundUserLocationRadius, + aroundPrecision: DEFAULT_AROUND_PRECISION, + minimumAroundRadius: 100, + hitsPerPage: HITS_PER_PAGE, + insideBoundingBox: undefined, + }; + updateConfig(config); + }, [ + isMapInitialized, + boundingBox, + aroundLatLng, + aroundUserLocationRadius, + updateConfig, + ]); + const handleFirstResultFocus = useCallback((node: HTMLDivElement | null) => { if (node) { node.focus(); } }, []); - const searchMapHitData = transformSearchResults(searchResults); + // Transform search results for display + // TODO: Update transformSearchResults to work with provider-agnostic types + const searchMapHitData = searchResults + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + transformSearchResults(searchResults as any) + : { hits: [], nbHits: 0 }; - const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle" && ( - - ); + const hasNoResults = searchMapHitData.nbHits === 0 && isIdle; 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); + return goToPage(0); case SearchMapActions.MapInitialized: // Map has initialized and bounding box is now available setIsMapInitialized(true); @@ -62,36 +98,18 @@ export const SearchResultsPage = () => { } }; - // 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 && ( - - )} -
- + {/* Only render Sidebar after map is initialized to prevent premature Algolia search */} + {isMapInitialized && ( + + )}
@@ -114,7 +132,7 @@ export const SearchResultsPage = () => { <> {searchMapHitData.hits.map( (hit: TransformedSearchHit, index) => ( @@ -149,3 +167,40 @@ export const SearchResultsPage = () => {
); }; + +/** + * SearchResultsPage - Wrapper that provides SearchConfigProvider + * NOTE: The .searchResultsPage is added plain so that it can be targeted by print-specific css + */ +export const SearchResultsPage = () => { + const { boundingBox, aroundLatLng, aroundUserLocationRadius } = + useAppContext(); + + // Calculate initial config synchronously BEFORE rendering + // This prevents InstantSearch from making searches without the bounding box + const initialConfig = React.useMemo(() => { + // Wait until we have geographic data before providing initial config + if (!boundingBox && !aroundLatLng) { + return {}; + } + + return boundingBox + ? { + insideBoundingBox: [boundingBox.split(",").map(Number)], + hitsPerPage: HITS_PER_PAGE, + } + : { + aroundLatLng, + aroundRadius: aroundUserLocationRadius, + aroundPrecision: DEFAULT_AROUND_PRECISION, + minimumAroundRadius: 100, + hitsPerPage: HITS_PER_PAGE, + }; + }, [boundingBox, aroundLatLng, aroundUserLocationRadius]); + + return ( + + + + ); +}; diff --git a/app/pages/ServiceDetailPage/ServiceDetailPage.tsx b/app/pages/ServiceDetailPage/ServiceDetailPage.tsx index accc79c57..4c053fe52 100644 --- a/app/pages/ServiceDetailPage/ServiceDetailPage.tsx +++ b/app/pages/ServiceDetailPage/ServiceDetailPage.tsx @@ -25,19 +25,19 @@ import { Service, } from "../../models"; import styles from "./ServiceDetailPage.module.scss"; -import { searchClient } from "@algolia/client-search"; -import config from "../../config"; +import { getSearchProvider } from "../../search"; import PageNotFound, { NotFoundType } from "components/ui/PageNotFound"; -const client = searchClient( - config.ALGOLIA_APPLICATION_ID, - config.ALGOLIA_READ_ONLY_API_KEY -); - -const INDEX_NAME = `${config.ALGOLIA_INDEX_PREFIX}_services_search`; +// Get the search provider instance +const searchProvider = getSearchProvider(); +// Access Algolia-specific methods for direct API calls +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const algoliaProvider = searchProvider as any; +const algoliaClient = algoliaProvider.getFullClient(); +const indexName = algoliaProvider.getIndexName(); // NOTE: `serviceFallback` and `setServiceFallback` is a hack to fetch data from -// Algolia rather than the Shelter Tech API. It's nott known why some data is +// Algolia rather than the Shelter Tech API. It's not known why some data is // not in sync between ST's API and their Algolia instance. // // DECISION: Manage the fetched service or fallback service result separately. @@ -71,8 +71,8 @@ export const ServiceDetailPage = () => { try { // CAVEAT: Hopefully this does not change! const serviceObjectID = `service_${pathname.split("/")[2]}`; - const service = (await client.getObject({ - indexName: INDEX_NAME, + const service = (await algoliaClient.getObject({ + indexName, objectID: serviceObjectID, })) as unknown as Service; diff --git a/app/search/constants.ts b/app/search/constants.ts new file mode 100644 index 000000000..35fc25b3c --- /dev/null +++ b/app/search/constants.ts @@ -0,0 +1,35 @@ +/** + * Provider-agnostic search constants + * These values are independent of the search provider implementation + */ + +/** + * Number of search results to display per page + */ +export const HITS_PER_PAGE = 40; + +/** + * Default search configuration + */ +export const DEFAULT_SEARCH_CONFIG = { + /** Maximum number of refinement items to display */ + REFINEMENT_LIMIT: 9999, + /** Default refinement operator */ + REFINEMENT_OPERATOR: "or" as const, + /** Default geo precision in meters */ + DEFAULT_AROUND_PRECISION: 1600, + /** Minimum radius for geo search */ + MINIMUM_AROUND_RADIUS: 100, +}; + +/** + * Search provider type + * Add new providers here as they're implemented + */ +export type SearchProviderType = "algolia" | "typesense"; + +/** + * Feature flag for search provider selection + * TODO: Move to environment config when Typesense is ready + */ +export const ACTIVE_SEARCH_PROVIDER: SearchProviderType = "algolia"; diff --git a/app/search/context/SearchContext.tsx b/app/search/context/SearchContext.tsx new file mode 100644 index 000000000..20d792e75 --- /dev/null +++ b/app/search/context/SearchContext.tsx @@ -0,0 +1,69 @@ +import React, { createContext, useContext, ReactNode, useMemo } from "react"; +import { InstantSearch } from "react-instantsearch-core"; +import { history as historyRouter } from "instantsearch.js/es/lib/routers"; +import { getAlgoliaProvider } from "../providers/algolia"; +import type { ISearchProvider } from "../types"; + +interface SearchContextValue { + provider: ISearchProvider; +} + +const SearchContext = createContext(null); + +interface SearchProviderProps { + children: ReactNode; +} + +/** + * Search Provider Component + * Wraps InstantSearch and provides abstracted search functionality + * + * This component isolates Algolia/InstantSearch from the rest of the app. + * When migrating to Typesense, only this file needs to change. + */ +export const SearchProvider: React.FC = ({ children }) => { + const provider = useMemo(() => getAlgoliaProvider(), []); + const searchClient = provider.getLiteClient(); + const indexName = provider.getIndexName(); + + const contextValue = useMemo( + () => ({ + provider, + }), + [provider] + ); + + return ( + + + {children} + + + ); +}; + +/** + * Hook to access the search provider + * Components should use custom hooks instead of this directly + */ +export function useSearchContext(): SearchContextValue { + const context = useContext(SearchContext); + if (!context) { + throw new Error("useSearchContext must be used within SearchProvider"); + } + return context; +} diff --git a/app/search/context/index.ts b/app/search/context/index.ts new file mode 100644 index 000000000..48eb429da --- /dev/null +++ b/app/search/context/index.ts @@ -0,0 +1 @@ +export { SearchProvider, useSearchContext } from "./SearchContext"; diff --git a/app/search/hooks/index.ts b/app/search/hooks/index.ts new file mode 100644 index 000000000..2cb2604cc --- /dev/null +++ b/app/search/hooks/index.ts @@ -0,0 +1,11 @@ +/** + * Provider-agnostic search hooks + * These hooks abstract away search provider specifics + */ + +export { useSearchResults } from "./useSearchResults"; +export { useSearchPagination } from "./useSearchPagination"; +export { useSearchRefinements } from "./useSearchRefinements"; +export { useSearchQuery } from "./useSearchQuery"; +export { useClearRefinements } from "./useClearRefinements"; +export { useSearchConfigure } from "./useSearchConfigure"; diff --git a/app/search/hooks/useClearRefinements.ts b/app/search/hooks/useClearRefinements.ts new file mode 100644 index 000000000..145b92386 --- /dev/null +++ b/app/search/hooks/useClearRefinements.ts @@ -0,0 +1,16 @@ +import { useClearRefinements as useAlgoliaClearRefinements } from "react-instantsearch"; + +/** + * Provider-agnostic hook for clearing refinements + * Wraps Algolia's useClearRefinements hook + */ +export function useClearRefinements() { + const { refine, canRefine } = useAlgoliaClearRefinements(); + + return { + /** Clear all refinements */ + clearAll: refine, + /** Are there any refinements to clear? */ + hasRefinements: canRefine, + }; +} diff --git a/app/search/hooks/useSearchConfigure.ts b/app/search/hooks/useSearchConfigure.ts new file mode 100644 index 000000000..bcac94d57 --- /dev/null +++ b/app/search/hooks/useSearchConfigure.ts @@ -0,0 +1,41 @@ +import type { SearchConfig } from "../types"; +import { HITS_PER_PAGE } from "../constants"; + +/** + * Provider-agnostic hook for search configuration + * Returns props to pass to the Configure component + * + * This abstracts away Algolia-specific configuration format + */ +export function useSearchConfigure(config: SearchConfig) { + // Transform provider-agnostic config to Algolia format + const algoliaConfig: Record = { + hitsPerPage: config.hitsPerPage || HITS_PER_PAGE, + }; + + if (config.filters) { + algoliaConfig.filters = config.filters; + } + + if (config.insideBoundingBox) { + algoliaConfig.insideBoundingBox = config.insideBoundingBox; + } + + if (config.aroundLatLng) { + algoliaConfig.aroundLatLng = config.aroundLatLng; + } + + if (config.aroundRadius !== undefined) { + algoliaConfig.aroundRadius = config.aroundRadius; + } + + if (config.aroundPrecision) { + algoliaConfig.aroundPrecision = config.aroundPrecision; + } + + if (config.minimumAroundRadius) { + algoliaConfig.minimumAroundRadius = config.minimumAroundRadius; + } + + return algoliaConfig; +} diff --git a/app/search/hooks/useSearchPagination.ts b/app/search/hooks/useSearchPagination.ts new file mode 100644 index 000000000..ab926af00 --- /dev/null +++ b/app/search/hooks/useSearchPagination.ts @@ -0,0 +1,35 @@ +import { usePagination as useAlgoliaPagination } from "react-instantsearch"; + +/** + * Provider-agnostic hook for pagination + * Wraps Algolia's usePagination hook + */ +export function useSearchPagination() { + const { + refine, + currentRefinement: currentPage, + nbPages, + isFirstPage, + isLastPage, + pages, + } = useAlgoliaPagination(); + + return { + /** Navigate to a specific page (0-indexed) */ + goToPage: refine, + /** Current page number (0-indexed) */ + currentPage, + /** Total number of pages */ + totalPages: nbPages, + /** Is this the first page? */ + isFirstPage, + /** Is this the last page? */ + isLastPage, + /** Array of available page numbers */ + availablePages: pages, + /** Navigate to next page */ + nextPage: () => !isLastPage && refine(currentPage + 1), + /** Navigate to previous page */ + previousPage: () => !isFirstPage && refine(currentPage - 1), + }; +} diff --git a/app/search/hooks/useSearchQuery.ts b/app/search/hooks/useSearchQuery.ts new file mode 100644 index 000000000..abfb0727a --- /dev/null +++ b/app/search/hooks/useSearchQuery.ts @@ -0,0 +1,18 @@ +import { useSearchBox as useAlgoliaSearchBox } from "react-instantsearch"; + +/** + * Provider-agnostic hook for search query input + * Wraps Algolia's useSearchBox hook + */ +export function useSearchQuery() { + const { query, refine, clear } = useAlgoliaSearchBox(); + + return { + /** Current search query */ + query, + /** Update the search query */ + setQuery: refine, + /** Clear the search query */ + clearQuery: clear, + }; +} diff --git a/app/search/hooks/useSearchRefinements.ts b/app/search/hooks/useSearchRefinements.ts new file mode 100644 index 000000000..035e2f54d --- /dev/null +++ b/app/search/hooks/useSearchRefinements.ts @@ -0,0 +1,55 @@ +import { useRefinementList as useAlgoliaRefinementList } from "react-instantsearch"; +import type { RefinementItem } from "../types"; + +interface UseSearchRefinementsOptions { + attribute: string; + limit?: number; + operator?: "and" | "or"; +} + +/** + * Provider-agnostic hook for refinement/faceting + * Wraps Algolia's useRefinementList hook + */ +export function useSearchRefinements(options: UseSearchRefinementsOptions) { + const { attribute, limit = 9999, operator = "or" } = options; + + const { + items: algoliaItems, + refine: algoliaRefine, + canRefine, + canToggleShowMore, + isShowingMore, + toggleShowMore, + searchForItems, + } = useAlgoliaRefinementList({ + attribute, + limit, + operator, + }); + + // Transform Algolia items to provider-agnostic format + const items: RefinementItem[] = algoliaItems.map((item) => ({ + label: item.label, + value: item.value, + count: item.count, + isRefined: item.isRefined, + })); + + return { + /** Available refinement items */ + items, + /** Toggle a refinement value */ + toggleRefinement: algoliaRefine, + /** Can any refinements be applied? */ + canRefine, + /** Can show more items? */ + canToggleShowMore, + /** Are more items showing? */ + isShowingMore, + /** Toggle showing more items */ + toggleShowMore, + /** Search within refinement items */ + searchItems: searchForItems, + }; +} diff --git a/app/search/hooks/useSearchResults.ts b/app/search/hooks/useSearchResults.ts new file mode 100644 index 000000000..dd0aea935 --- /dev/null +++ b/app/search/hooks/useSearchResults.ts @@ -0,0 +1,36 @@ +import { useInstantSearch } from "react-instantsearch"; +import type { SearchResults, SearchHit } from "../types"; + +/** + * Provider-agnostic hook for accessing search results + * Wraps Algolia's useInstantSearch hook + * + * When migrating to Typesense, update this hook's implementation + * but keep the same return interface + */ +export function useSearchResults() { + const { results: algoliaResults, status, indexUiState } = useInstantSearch(); + + const results: SearchResults | null = algoliaResults + ? { + hits: algoliaResults.hits as T[], + nbHits: algoliaResults.nbHits, + page: algoliaResults.page, + nbPages: algoliaResults.nbPages, + hitsPerPage: algoliaResults.hitsPerPage, + processingTimeMS: algoliaResults.processingTimeMS, + query: algoliaResults.query, + facets: algoliaResults.facets as unknown as + | Record> + | undefined, + } + : null; + + return { + results, + isSearching: status === "loading" || status === "stalled", + isIdle: status === "idle", + isError: status === "error", + query: indexUiState?.query || "", + }; +} diff --git a/app/search/index.ts b/app/search/index.ts new file mode 100644 index 000000000..21164b9b7 --- /dev/null +++ b/app/search/index.ts @@ -0,0 +1,19 @@ +/** + * Main search module exports + * Everything the app needs to use search functionality + */ + +// Types +export * from "./types"; + +// Constants +export * from "./constants"; + +// Context +export * from "./context"; + +// Hooks +export * from "./hooks"; + +// Provider access (for advanced use cases only) +export { getSearchProvider } from "./providers"; diff --git a/app/search/providers/algolia/AlgoliaProvider.ts b/app/search/providers/algolia/AlgoliaProvider.ts new file mode 100644 index 000000000..886e30b7a --- /dev/null +++ b/app/search/providers/algolia/AlgoliaProvider.ts @@ -0,0 +1,210 @@ +import { liteClient as createLiteClient } from "algoliasearch/lite"; +import { searchClient as createSearchClient } from "@algolia/client-search"; +import config from "../../../config"; +import type { + ISearchProvider, + SearchConfig, + SearchResults, + RefinementItem, + SearchState, + SearchHit, +} from "../../types"; + +/** + * Algolia-specific implementation of the search provider interface + * Wraps all Algolia/InstantSearch logic to isolate it from the rest of the app + */ +export class AlgoliaProvider implements ISearchProvider { + private liteClient: ReturnType; + private fullClient: ReturnType; + private indexName: string; + private state: SearchState; + private listeners: Set<(state: SearchState) => void>; + + constructor() { + this.liteClient = createLiteClient( + config.ALGOLIA_APPLICATION_ID, + config.ALGOLIA_READ_ONLY_API_KEY + ); + + this.fullClient = createSearchClient( + config.ALGOLIA_APPLICATION_ID, + config.ALGOLIA_READ_ONLY_API_KEY + ); + + this.indexName = `${config.ALGOLIA_INDEX_PREFIX}_services_search`; + + this.state = { + query: "", + results: null, + isSearching: false, + error: null, + pagination: { + currentPage: 0, + nbPages: 0, + isFirstPage: true, + isLastPage: true, + }, + refinements: {}, + }; + + this.listeners = new Set(); + } + + /** + * Get the lite client for InstantSearch usage + * This is the client that should be passed to component + */ + getLiteClient() { + return this.liteClient; + } + + /** + * Get the full client for direct API calls + * Used for operations not supported by InstantSearch + */ + getFullClient() { + return this.fullClient; + } + + /** + * Get the index name + */ + getIndexName(): string { + return this.indexName; + } + + async search(searchConfig: SearchConfig): Promise { + this.updateState({ isSearching: true, error: null }); + + try { + const response = await this.liteClient.search([ + { + indexName: this.indexName, + params: this.buildAlgoliaParams(searchConfig), + }, + ]); + + const result = response.results[0]; + + // Type guard to ensure we have a search response, not a facet values response + if (!("hits" in result)) { + throw new Error("Invalid search response"); + } + + const searchResults: SearchResults = { + hits: result.hits as unknown as SearchHit[], + nbHits: result.nbHits ?? 0, + page: result.page ?? 0, + nbPages: result.nbPages ?? 0, + hitsPerPage: result.hitsPerPage ?? 40, + processingTimeMS: result.processingTimeMS ?? 0, + query: result.query, + facets: result.facets, + }; + + this.updateState({ + results: searchResults, + isSearching: false, + query: searchConfig.query || "", + pagination: { + currentPage: result.page ?? 0, + nbPages: result.nbPages ?? 0, + isFirstPage: (result.page ?? 0) === 0, + isLastPage: (result.page ?? 0) === (result.nbPages ?? 0) - 1, + }, + }); + + return searchResults; + } catch (error) { + this.updateState({ + isSearching: false, + error: error as Error, + }); + throw error; + } + } + + private buildAlgoliaParams( + searchConfig: SearchConfig + ): Record { + const params: Record = {}; + + if (searchConfig.query !== undefined) params.query = searchConfig.query; + if (searchConfig.filters) params.filters = searchConfig.filters; + if (searchConfig.page !== undefined) params.page = searchConfig.page; + if (searchConfig.hitsPerPage) params.hitsPerPage = searchConfig.hitsPerPage; + if (searchConfig.aroundLatLng) + params.aroundLatLng = searchConfig.aroundLatLng; + if (searchConfig.aroundRadius !== undefined) + params.aroundRadius = searchConfig.aroundRadius; + if (searchConfig.aroundPrecision) + params.aroundPrecision = searchConfig.aroundPrecision; + if (searchConfig.insideBoundingBox) + params.insideBoundingBox = searchConfig.insideBoundingBox; + if (searchConfig.facets) params.facets = searchConfig.facets; + if (searchConfig.minimumAroundRadius) + params.minimumAroundRadius = searchConfig.minimumAroundRadius; + + return params; + } + + getRefinements(attribute: string): RefinementItem[] { + // This will be populated by InstantSearch hooks in the context + // For now, return empty array - full implementation in Phase 3 + // Attribute will be used in Phase 3: ${attribute} + return []; + } + + refine(attribute: string, value: string): void { + // Implemented through InstantSearch hooks in context + // Will be used in Phase 3: ${attribute}, ${value} + } + + clearRefinements(): void { + // Implemented through InstantSearch hooks in context + this.updateState({ refinements: {} }); + } + + clearRefinement(attribute: string): void { + // Implemented through InstantSearch hooks in context + const newRefinements = { ...this.state.refinements }; + delete newRefinements[attribute]; + this.updateState({ refinements: newRefinements }); + } + + goToPage(page: number): void { + this.updateState({ + pagination: { + ...this.state.pagination, + currentPage: page, + }, + }); + } + + getState(): SearchState { + return { ...this.state }; + } + + subscribe(listener: (state: SearchState) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private updateState(updates: Partial): void { + this.state = { ...this.state, ...updates }; + this.listeners.forEach((listener) => listener(this.state)); + } +} + +// Singleton instance +let algoliaProviderInstance: AlgoliaProvider | null = null; + +export function getAlgoliaProvider(): AlgoliaProvider { + if (!algoliaProviderInstance) { + algoliaProviderInstance = new AlgoliaProvider(); + } + return algoliaProviderInstance; +} diff --git a/app/search/providers/algolia/index.ts b/app/search/providers/algolia/index.ts new file mode 100644 index 000000000..21467f7d3 --- /dev/null +++ b/app/search/providers/algolia/index.ts @@ -0,0 +1,6 @@ +/** + * Algolia provider exports + * All Algolia-specific code is contained in this directory + */ + +export { AlgoliaProvider, getAlgoliaProvider } from "./AlgoliaProvider"; diff --git a/app/search/providers/index.ts b/app/search/providers/index.ts new file mode 100644 index 000000000..ed4bd60c6 --- /dev/null +++ b/app/search/providers/index.ts @@ -0,0 +1,23 @@ +import type { ISearchProvider } from "../types"; +import { ACTIVE_SEARCH_PROVIDER } from "../constants"; +import { getAlgoliaProvider } from "./algolia"; + +/** + * Provider factory + * Returns the active search provider based on feature flag + * + * To switch providers, update ACTIVE_SEARCH_PROVIDER in constants.ts + */ +export function getSearchProvider(): ISearchProvider { + switch (ACTIVE_SEARCH_PROVIDER) { + case "algolia": + return getAlgoliaProvider(); + case "typesense": + // Will be implemented in Phase 13 + throw new Error("Typesense provider not yet implemented"); + default: + throw new Error(`Unknown search provider: ${ACTIVE_SEARCH_PROVIDER}`); + } +} + +export * from "./algolia"; diff --git a/app/search/types.ts b/app/search/types.ts new file mode 100644 index 000000000..e16260574 --- /dev/null +++ b/app/search/types.ts @@ -0,0 +1,98 @@ +/** + * Provider-agnostic search types and interfaces + * These interfaces abstract away search provider specifics (Algolia, Typesense, etc.) + */ + +export interface SearchCoordinates { + lat: number; + lng: number; +} + +export interface SearchBoundingBox { + topLeft: SearchCoordinates; + bottomRight: SearchCoordinates; +} + +export interface SearchHit { + id: string; + name: string; + type: "service" | "resource"; + _geoloc: SearchCoordinates; + [key: string]: unknown; +} + +export interface SearchResults { + hits: T[]; + nbHits: number; + page: number; + nbPages: number; + hitsPerPage: number; + processingTimeMS: number; + query: string; + facets?: Record>; +} + +export interface RefinementItem { + label: string; + value: string; + count: number; + isRefined: boolean; +} + +export interface SearchConfig { + query?: string; + filters?: string; + page?: number; + hitsPerPage?: number; + aroundLatLng?: string; + aroundRadius?: number | "all"; + aroundPrecision?: number; + insideBoundingBox?: number[][]; + facets?: string[]; + minimumAroundRadius?: number; +} + +export interface PaginationState { + currentPage: number; + nbPages: number; + isFirstPage: boolean; + isLastPage: boolean; +} + +export interface SearchState { + query: string; + results: SearchResults | null; + isSearching: boolean; + error: Error | null; + pagination: PaginationState; + refinements: Record; +} + +/** + * Base interface for search provider implementations + */ +export interface ISearchProvider { + /** Execute a search query */ + search(config: SearchConfig): Promise; + + /** Get refinement/facet data for an attribute */ + getRefinements(attribute: string): RefinementItem[]; + + /** Apply a refinement filter */ + refine(attribute: string, value: string): void; + + /** Clear all refinements */ + clearRefinements(): void; + + /** Clear specific refinements for an attribute */ + clearRefinement(attribute: string): void; + + /** Navigate to a specific page */ + goToPage(page: number): void; + + /** Get current search state */ + getState(): SearchState; + + /** Subscribe to state changes */ + subscribe(listener: (state: SearchState) => void): () => void; +} diff --git a/app/utils/SearchConfigContext.tsx b/app/utils/SearchConfigContext.tsx new file mode 100644 index 000000000..31328339b --- /dev/null +++ b/app/utils/SearchConfigContext.tsx @@ -0,0 +1,164 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useEffect, + ReactNode, +} from "react"; +import { Configure } from "react-instantsearch-core"; +import { AroundRadius } from "@algolia/client-search"; + +/** + * SearchConfigContext - Centralized Search Configuration Manager + * + * PROBLEM: Previously, Sidebar and Configure components sent competing search requests, + * causing race conditions and incorrect results. + * + * SOLUTION: This context provides a single source of truth for all search configuration. + * - Collects config from pages (geo bounds, filters, pagination) + * - Collects config from Sidebar refinements (categories, eligibilities, distance) + * - Merges all config into ONE Configure component + * - Eliminates race conditions by coordinating all parameters + * + * USAGE: + * 1. Wrap your page component with + * 2. Call updateConfig() instead of rendering directly + * 3. SearchConfigProvider renders the merged for you + */ + +interface SearchConfig { + // Geographic filters + insideBoundingBox?: number[][]; + aroundLatLng?: string; + aroundRadius?: AroundRadius; + aroundPrecision?: number; + minimumAroundRadius?: number; + + // Pagination + hitsPerPage?: number; + + // Category filters + filters?: string; + + // Any other Algolia configure props + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +interface SearchConfigContextValue { + /** + * Update the search configuration. This will merge with existing config + * and trigger a single coordinated search request. + */ + updateConfig: (config: Partial) => void; + + /** + * Reset the configuration to empty state + */ + resetConfig: () => void; + + /** + * Current merged configuration + */ + currentConfig: SearchConfig; +} + +const SearchConfigContext = createContext( + undefined +); + +interface SearchConfigProviderProps { + children: ReactNode; + /** + * Enable debug mode to log configuration changes + */ + debug?: boolean; + /** + * Initial configuration to set immediately on mount + * This prevents race conditions by ensuring Configure is set before searches fire + */ + initialConfig?: Partial; +} + +/** + * SearchConfigProvider - Wraps search pages to provide centralized config management + * + * This component: + * 1. Accepts config updates from child components via updateConfig() + * 2. Merges all config into a single state object + * 3. Renders ONE component with the merged config + * 4. Prevents race conditions by coordinating all search parameters + */ +export const SearchConfigProvider: React.FC = ({ + children, + debug = false, + initialConfig = {}, +}) => { + const [config, setConfig] = useState(initialConfig); + + const updateConfig = useCallback( + (newConfig: Partial) => { + setConfig((prevConfig) => { + const mergedConfig = { ...prevConfig, ...newConfig }; + + if (debug) { + // eslint-disable-next-line no-console + console.log("[SearchConfig] Config updated:", { + previous: prevConfig, + new: newConfig, + merged: mergedConfig, + stackTrace: new Error().stack, + }); + } + + return mergedConfig; + }); + }, + [debug] + ); + + const resetConfig = useCallback(() => { + if (debug) { + // eslint-disable-next-line no-console + console.log("[SearchConfig] Config reset"); + } + setConfig({}); + }, [debug]); + + useEffect(() => { + if (debug && Object.keys(config).length > 0) { + // eslint-disable-next-line no-console + console.log("[SearchConfig] Current config:", config); + } + }, [config, debug]); + + const contextValue: SearchConfigContextValue = { + updateConfig, + resetConfig, + currentConfig: config, + }; + + return ( + + {/* This is the SINGLE Configure component that receives merged config from all sources */} + + {children} + + ); +}; + +/** + * Hook to access search configuration context + * + * @throws Error if used outside of SearchConfigProvider + */ +export const useSearchConfig = (): SearchConfigContextValue => { + const context = useContext(SearchConfigContext); + if (context === undefined) { + throw new Error( + "useSearchConfig must be used within a SearchConfigProvider" + ); + } + return context; +};