@@ -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;
+};