diff --git a/app/components/SearchAndBrowse/Header/BrowseHeaderSection.tsx b/app/components/SearchAndBrowse/Header/BrowseHeaderSection.tsx new file mode 100644 index 000000000..18e13789d --- /dev/null +++ b/app/components/SearchAndBrowse/Header/BrowseHeaderSection.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import styles from "./SearchHeaderSection.module.scss"; + +/** + * Renders mobile and desktop views for Listings navigation + */ +export const BrowseHeaderSection = ({ + descriptionText, +}: { + descriptionText: string; +}) => { + return ( +
+
+

Services

+
+ {descriptionText} +
+ ); +}; diff --git a/app/components/SearchAndBrowse/Header/BrowseSubheader.module.scss b/app/components/SearchAndBrowse/Header/BrowseSubheader.module.scss new file mode 100644 index 000000000..6bcdeb835 --- /dev/null +++ b/app/components/SearchAndBrowse/Header/BrowseSubheader.module.scss @@ -0,0 +1,44 @@ +@import "~styles/utils/_helpers.scss"; + +.headerInner { + display: flex; + margin: 0 auto; + padding: $general-spacing-md $general-spacing-lg; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid $color-grey3; + + @media screen and (max-width: $break-tablet-s) { + padding: $general-spacing-s $general-spacing-xxs; + } +} + +.title { + text-align: left; + font-weight: bold; + font-size: 26px; + + @media screen and (max-width: $break-tablet-s) { + font-size: 21px; + line-height: 20px; + } +} + +.printAllBtn, +.qrCodeBtn { + display: none; + + &.showBtn { + display: block; + + @media screen and (max-width: $break-tablet-s) { + display: none; + } + } +} + +.btnText { + font-weight: 600; + font-size: 16px; + margin-left: 10px; +} diff --git a/app/components/SearchAndBrowse/Header/BrowseSubheader.tsx b/app/components/SearchAndBrowse/Header/BrowseSubheader.tsx new file mode 100644 index 000000000..d2edce34b --- /dev/null +++ b/app/components/SearchAndBrowse/Header/BrowseSubheader.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import { Button } from "components/ui/inline/Button/Button"; +import websiteConfig from "utils/websiteConfig"; +import { CATEGORIES } from "pages/constants"; +import DropdownMenu from "components/ui/Navigation/DropdownMenu"; + +import styles from "./BrowseSubheader.module.scss"; + +const { showPrintResultsBtn } = websiteConfig; + +interface Props { + currentCategory: string; +} + +// TODO: This should be the same as the dropdown links in the Navigation dropdown (which comes from Strapi) +const DROPDOWN_LINKS = CATEGORIES.map((category) => ({ + id: category.slug, + url: `/${category.slug}/results`, + text: category.name, +})); + +export const BrowseSubheader = ({ currentCategory }: Props) => { + const title = currentCategory; + + const uuid = crypto.randomUUID(); + + return ( +
+
+
+

{title}

+ +
+ +
+
+ ); +}; diff --git a/app/components/SearchAndBrowse/Header/CustomDropdown.module.scss b/app/components/SearchAndBrowse/Header/CustomDropdown.module.scss new file mode 100644 index 000000000..cb6a23ff0 --- /dev/null +++ b/app/components/SearchAndBrowse/Header/CustomDropdown.module.scss @@ -0,0 +1,49 @@ +@import "~styles/utils/_helpers.scss"; + +.dropdown { + position: relative; + display: inline-block; +} + +.title { + font-size: 26px; + font-weight: bold; + cursor: pointer; + font-family: Montserrat; + padding: 0; + + @include r_max($break-tablet-s) { + font-size: 20px; + } +} + +.arrowDown i, +.arrowUp i { + margin-left: 5px; +} + +.dropdownMenu { + display: block; + position: absolute; + background-color: white; + border-radius: $rounded-md; + top: 50px; + min-width: 160px; + box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.2); + z-index: $z-index-top; + list-style: none; + padding: 0; + margin: 0; +} + +.dropdownMenu li { + font-family: Montserrat; + font-weight: 500; + padding: $general-spacing-s $general-spacing-md; + cursor: pointer; +} + +.dropdownMenu li:hover, +.dropdownMenu li:focus { + background-color: $gray-100; +} diff --git a/app/components/SearchAndBrowse/Header/CustomDropdown.tsx b/app/components/SearchAndBrowse/Header/CustomDropdown.tsx new file mode 100644 index 000000000..656bee960 --- /dev/null +++ b/app/components/SearchAndBrowse/Header/CustomDropdown.tsx @@ -0,0 +1,155 @@ +import React, { useState, useRef, useEffect } from "react"; +import { ServiceCategory } from "pages/constants"; +import styles from "./CustomDropdown.module.scss"; + +interface DropdownProps { + categories: Readonly; + currentCategory: string; + onCategoryChange: (slug: string) => void; + resultsTitle: string; +} + +// NOTE: built quickly for demo + +export const CustomDropdown: React.FC = ({ + categories, + currentCategory, + onCategoryChange, + resultsTitle, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + + const handleCategoryChange = (slug: string) => { + onCategoryChange(slug); + setIsOpen(false); + buttonRef.current?.focus(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + setIsOpen(false); + buttonRef.current?.focus(); + } + + if (event.key === "ArrowDown" && !isOpen) { + setIsOpen(true); + event.preventDefault(); + } + + if (event.key === "ArrowDown" && isOpen) { + const firstMenuItem = menuRef.current?.querySelector("li"); + (firstMenuItem as HTMLElement)?.focus(); + event.preventDefault(); + } + + if (event.key === "ArrowUp" && isOpen) { + const lastMenuItem = menuRef.current?.querySelector("li:last-child"); + (lastMenuItem as HTMLElement)?.focus(); + event.preventDefault(); + } + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [dropdownRef]); + + const handleItemKeyDown = ( + event: React.KeyboardEvent, + slug: string, + index: number + ) => { + const menuItems = Array.from(menuRef.current?.querySelectorAll("li") || []); + + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + { + const nextItem = menuItems[index + 1] || menuItems[0]; + (nextItem as HTMLElement)?.focus(); + } + break; + case "ArrowUp": + event.preventDefault(); + { + const prevItem = + menuItems[index - 1] || menuItems[menuItems.length - 1]; + (prevItem as HTMLElement)?.focus(); + } + break; + case "Enter": + case " ": + event.preventDefault(); + handleCategoryChange(slug); + break; + case "Tab": + setIsOpen(false); + break; + default: + break; + } + }; + + return ( +
+ + {isOpen && ( +
    + {categories.map((category, index) => ( +
  • handleCategoryChange(`/${category.slug}/results`)} + className={currentCategory === category.slug ? styles.active : ""} + role="option" + id={category.slug} + tabIndex={0} + aria-selected={currentCategory === category.slug} + onKeyDown={(event) => + handleItemKeyDown(event, `/${category.slug}/results`, index + 1) + } + > + {category.name} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/app/components/SearchAndBrowse/Header/SearchHeaderSection.module.scss b/app/components/SearchAndBrowse/Header/SearchHeaderSection.module.scss new file mode 100644 index 000000000..3bd25be28 --- /dev/null +++ b/app/components/SearchAndBrowse/Header/SearchHeaderSection.module.scss @@ -0,0 +1,36 @@ +@import "~styles/utils/_helpers.scss"; + +.searchHeaderContainer { + display: flex; + justify-content: space-between; + align-items: center; + gap: $spacing-6; + + @include r_max($break-tablet-s) { + display: block; + gap: $spacing-3; + } + + .searchHeaderContainerLeft { + display: flex; + align-items: center; + gap: $spacing-6; + + h1 { + font-size: 26px; + min-height: 100%; + line-height: 100%; + } + + @include r_max($break-tablet-s) { + display: block; + width: 100%; + margin-bottom: $general-spacing-md; + + h1 { + margin-bottom: $general-spacing-md; + font-size: 20px; + } + } + } +} diff --git a/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx b/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx new file mode 100644 index 000000000..e285dc82a --- /dev/null +++ b/app/components/SearchAndBrowse/Header/SearchHeaderSection.tsx @@ -0,0 +1,38 @@ +import { Button } from "components/ui/inline/Button/Button"; +import React from "react"; +import { useSearchBox } from "react-instantsearch-core"; +import styles from "./SearchHeaderSection.module.scss"; + +/** + * Renders mobile and desktop views for Listings navigation + */ +export const SearchHeaderSection = ({ + descriptionText, +}: { + descriptionText: string; +}) => { + const { query } = useSearchBox(); + return ( +
+
+ {query ? ( +

Search results for "{query}"

+ ) : ( +

Search results

+ )} +
+ +
+ ); +}; diff --git a/app/components/SearchAndBrowse/Pagination/ResultsPagination.module.scss b/app/components/SearchAndBrowse/Pagination/ResultsPagination.module.scss new file mode 100644 index 000000000..2bcae3fdf --- /dev/null +++ b/app/components/SearchAndBrowse/Pagination/ResultsPagination.module.scss @@ -0,0 +1,57 @@ +@import "~styles/utils/_helpers.scss"; + +.paginationContainer { + position: relative; + z-index: 2; + padding: 24px; + background-color: $gray-50; + + :global(.ais-Pagination-link) { + display: inline-block; + width: 35px; + margin: 0 3px; + text-align: center; + color: $black; + padding: $general-spacing-md $spacing-0; + + &:hover { + color: $black; + } + } + + :global(.ais-Pagination-item--selected) { + font-weight: 900; + font-size: 18px; + } + + :global(.ais-Pagination-item--nextPage), + :global(.ais-Pagination-item--previousPage) { + margin: 0 3px; + font-size: 0.87rem; + } + + :global(.ais-Pagination-item--disabled) { + opacity: 0.5; + border: 0; + } + + &.hidePagination { + display: none; + } +} + +.resultsPagination { + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.algoliaImgWrapper { + display: flex; + justify-content: center; + + img { + width: 120px; + } +} diff --git a/app/components/SearchAndBrowse/Pagination/ResultsPagination.tsx b/app/components/SearchAndBrowse/Pagination/ResultsPagination.tsx new file mode 100644 index 000000000..72fa8ebc3 --- /dev/null +++ b/app/components/SearchAndBrowse/Pagination/ResultsPagination.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Pagination } from "react-instantsearch"; +import websiteConfig from "utils/websiteConfig"; + +import styles from "./ResultsPagination.module.scss"; + +const { + appImages: { algolia }, +} = websiteConfig; + +const ResultsPagination = ({ noResults }: { noResults?: boolean }) => ( +
+
+
+ +
+
+ Search by Algolia +
+
+
+); + +export default ResultsPagination; diff --git a/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.test.tsx b/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.test.tsx new file mode 100644 index 000000000..280a7b3d1 --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.test.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { InstantSearch } from "react-instantsearch-core"; +import { render, screen, waitFor } from "@testing-library/react"; +import BrowseRefinementList from "components/SearchAndBrowse/Refinements/BrowseRefinementList"; +import { createSearchClient } from "../../../../test/helpers/createSearchClient"; +import { createRandomCategories } from "../../../../test/helpers/createRandomCategories"; + +describe("BrowseRefinementList", () => { + test("renders all categories returned by the search client", async () => { + const numCategories = 25; + const searchClient = createSearchClient({ + facets: { + categories: createRandomCategories(numCategories), + }, + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getAllByTestId("browserefinementlist-item")).toHaveLength( + numCategories + ); + }); + }); +}); diff --git a/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.tsx b/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.tsx new file mode 100644 index 000000000..1ab02f8c5 --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/BrowseRefinementList.tsx @@ -0,0 +1,67 @@ +import { RefinementListItem } from "instantsearch.js/es/connectors/refinement-list/connectRefinementList"; +import React, { useEffect, useState } from "react"; +import { useRefinementList, UseRefinementListProps } from "react-instantsearch"; +import styles from "./RefinementFilters.module.scss"; + +interface Props extends UseRefinementListProps { + transform?: (items: RefinementListItem[]) => RefinementListItem[]; + attribute: string; +} + +// Arbitrary upper limit to ensure all refinements are displayed +const MAXIMUM_ITEMS = 9999; + +/** + * Facet refinement list component for browse interfaces + */ +const BrowseRefinementList = ({ attribute, transform }: Props) => { + const [checked, setChecked] = useState>(new Set()); + const { items, refine } = useRefinementList({ + attribute, + sortBy: ["name:asc"], + limit: MAXIMUM_ITEMS, + }); + + useEffect(() => { + items.forEach((item) => { + if (item.isRefined) { + checked.add(item.value); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + const changeRefinement = (value: string) => { + refine(value); + + if (checked.has(value)) { + checked.delete(value); + } else { + checked.add(value); + } + + setChecked(checked); + }; + + const transformedItems = transform === undefined ? items : transform(items); + + return ( +
    + {transformedItems.map((item) => ( +
  • + +
  • + ))} +
+ ); +}; + +export default BrowseRefinementList; diff --git a/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx b/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx new file mode 100644 index 000000000..9fc6710dc --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/ClearAllFilters.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import { Button } from "components/ui/inline/Button/Button"; +import { useClearRefinements } from "react-instantsearch"; +import { useAppContextUpdater } from "utils"; + +/** + * Filter clearing component that handles both facet clearing and map boundary reset + */ +const ClearAllFilter = () => { + const { setAroundRadius } = useAppContextUpdater(); + const { refine } = useClearRefinements(); + return ( + + ); +}; + +export default ClearAllFilter; diff --git a/app/components/SearchAndBrowse/Refinements/ClearSearchButton.tsx b/app/components/SearchAndBrowse/Refinements/ClearSearchButton.tsx new file mode 100644 index 000000000..8285fea5e --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/ClearSearchButton.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Button } from "components/ui/inline/Button/Button"; +import { useInstantSearch } from "react-instantsearch-core"; + +const ClearSearchButton = () => { + const { setIndexUiState } = useInstantSearch(); + + // Algolia provides a hook that can manage the query state, specifically *clearing*: + // ``` + // const { clear } = useSearchBox(); + // onClick(() => clear); + // ``` + // However, for reasons still unknown, in practice using this hook causes unnecessary + // re-renders when no results are returned. Fortunately, we can use another hook set + // the index state manually. + const handleOnClick = () => + setIndexUiState({ + query: "", + page: 0, + }); + + return ( + + ); +}; + +export default ClearSearchButton; diff --git a/app/components/SearchAndBrowse/Refinements/OpenNowFilter.tsx b/app/components/SearchAndBrowse/Refinements/OpenNowFilter.tsx new file mode 100644 index 000000000..79c1e02ea --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/OpenNowFilter.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from "react"; +import { useRefinementList } from "react-instantsearch"; +import { getCurrentDayTime } from "utils/index"; +import styles from "./RefinementFilters.module.scss"; + +/** + * A custom Algolia InstantSearch RefinementList widget representing the Open + * Now checkbox. + * + * We implement this as a custom widget because we want to show users a + * different representation of the search parameter than is actually stored + * internally. Internally the open_times attribute is represented as an array of + * times in 30-minute chunks, where the presence of a chunk indicates that the + * organization or service is open during that chunk. Externally, we only want + * to present a binary Open Now filter where activating the filter means + * filtering for schedules where an open_times chunk exists for the user's + * current local time. + * + * For example, if it is Sunday 10:00 AM locally, then enabling the Open Now + * filter should filter for organizations or services which have 'Su-10:00' in + * the open_times array. + */ +const OpenNowFilter = () => { + const { refine, items } = useRefinementList({ attribute: "open_times" }); + const [isChecked, toggleChecked] = useState(false); + + useEffect(() => { + const currentDayTime = getCurrentDayTime(); + if (items.map((item) => item.value).includes(currentDayTime)) { + toggleChecked(true); + } else { + toggleChecked(false); + } + }, [items]); + + const toggleRefinement = () => { + const currentDayTime = getCurrentDayTime(); + if (isChecked) { + toggleChecked(false); + } else if (items.map((item) => item.value).includes(currentDayTime)) { + toggleChecked(true); + } + refine(currentDayTime); + }; + + return ( + + ); +}; + +export default OpenNowFilter; diff --git a/app/components/SearchAndBrowse/Refinements/RefinementFilters.module.scss b/app/components/SearchAndBrowse/Refinements/RefinementFilters.module.scss new file mode 100644 index 000000000..0b217097f --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/RefinementFilters.module.scss @@ -0,0 +1,35 @@ +@import "~styles/utils/_helpers.scss"; + +.clearAll { + font-size: 16px; + color: $color-btn-primary; + cursor: pointer; + margin-top: 32px; + @media screen and (min-width: $min-desktop) { + margin-top: 13px; + } +} + +.checkBox { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + align-items: center; + margin-top: $general-spacing-s; + color: $color-grey5; + cursor: pointer; + &.disabled { + color: $color-grey4; + } + + @media screen and (max-width: $break-tablet-s) { + font-size: 14px; + } +} + +.refinementInput { + width: auto; + margin-right: 10px; + margin-left: 0; + cursor: pointer; +} diff --git a/app/components/SearchAndBrowse/Refinements/SearchRefinementList.tsx b/app/components/SearchAndBrowse/Refinements/SearchRefinementList.tsx new file mode 100644 index 000000000..f6e2f133e --- /dev/null +++ b/app/components/SearchAndBrowse/Refinements/SearchRefinementList.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from "react"; +import { useRefinementList, UseRefinementListProps } from "react-instantsearch"; +import styles from "./RefinementFilters.module.scss"; + +interface Props extends UseRefinementListProps { + mapping: Record; +} + +const DEFAULT_CONFIG = { + // Arbitrary upper limit to ensure all refinements are displayed + limit: 9999, + operator: "or" as const, +}; + +const SearchRefinementList = ({ attribute, mapping }: Props) => { + const mappingLabels = Object.keys(mapping); + const [checked, setChecked] = useState>({}); + const { items, refine } = useRefinementList({ + attribute, + ...DEFAULT_CONFIG, + }); + + const keyHasAtLeastOneRefined = (key: string): boolean => { + const refined = items.filter((item) => item.isRefined); + const refinedItemLabels = refined.map((item) => item.label); + const anyCommon = refinedItemLabels.filter((label) => + mapping[key].includes(label) + ); + + return anyCommon.length > 0; + }; + + const changeRefinement = (key: string) => { + mapping[key].forEach((mappingValue) => { + refine(mappingValue); + }); + + if (checked[key]) { + checked[key] = false; + } else { + checked[key] = keyHasAtLeastOneRefined(key); + } + + setChecked(checked); + }; + + const refinementMappingHasResults = (key: string) => { + return items.some((item) => mapping[key].includes(item.label)); + }; + + useEffect(() => { + mappingLabels.forEach((key) => { + checked[key] = keyHasAtLeastOneRefined(key); + }); + + setChecked(checked); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items]); + + return ( +
    + {mappingLabels.map((mappingLabel) => { + const mappingHasResults = refinementMappingHasResults(mappingLabel); + + return ( +
  • + +
  • + ); + })} +
+ ); +}; + +export default SearchRefinementList; diff --git a/app/components/SearchAndBrowse/SearchAndBrowseResultsPage.module.scss b/app/components/SearchAndBrowse/SearchAndBrowseResultsPage.module.scss new file mode 100644 index 000000000..213b51953 --- /dev/null +++ b/app/components/SearchAndBrowse/SearchAndBrowseResultsPage.module.scss @@ -0,0 +1,22 @@ +@import "~styles/utils/_helpers.scss"; + +.container { + width: 100%; +} + +.flexContainer { + display: flex; + width: 100%; + margin: auto; + padding-top: $spacing-6; + + @media screen and (max-width: $break-desktop-s) { + flex-direction: column; + } +} + +.results { + // This style is set so that the results container will fill the width of the page + // even when there are no search results + flex-grow: 1; +} diff --git a/app/components/SearchAndBrowse/SearchMap/SearchEntry.scss b/app/components/SearchAndBrowse/SearchMap/SearchEntry.scss new file mode 100644 index 000000000..cdc7472af --- /dev/null +++ b/app/components/SearchAndBrowse/SearchMap/SearchEntry.scss @@ -0,0 +1,152 @@ +.results-table-entry { + display: flex; + position: relative; + margin-bottom: 20px; + list-style-type: none; + transition: all 200ms ease; + background: #fff; + &:active, + &:hover, + &:focus { + box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.2); + } + @media screen and (max-width: 767px) { + margin-bottom: 0; + border-bottom: 1px solid #f4f4f4; + } +} + +.entry-details { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 25px 30px 30px; + width: 100%; + flex: 1 1 80%; + @media screen and (max-width: 600px) { + padding: 25px 30px 30px 20px; + } +} + +.entry-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.entry-headline { + color: rgba(0, 0, 0, 0.87); + font-size: 18px; + font-weight: 600; + margin-bottom: 5px; + display: block; + letter-spacing: -0.25px; + &:hover { + color: #276ce5; + } +} + +.entry-meta { + display: flex; + color: #666; + font-size: 0.87rem; + margin-bottom: 2px; + @media screen and (max-width: 767px) { + flex-direction: column; + } +} + +.entry-schedule { + @media screen and (min-width: 767px) { + &:before { + content: " • "; + margin-left: 4px; + } + } +} + +.entry-body { + margin-top: 10px; + max-height: 40px; + padding-bottom: 6px; + text-overflow: ellipsis; + overflow: hidden; + @media screen and (max-width: 767px) { + display: none; + } + p { + font-size: 14px; + line-height: 19px; + color: #666; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } +} + +.mohcd-funded { + display: flex; + align-items: center; + & > img { + width: 35px; + padding-right: 5px; + } + & > p { + font-size: 12px; + color: #666; + min-width: 110px; + } + @media screen and (max-width: 1024px) { + display: none; + } +} + +.action-buttons { + flex: 1 0 auto; + width: 20%; + display: flex; + @media screen and (min-width: 767px) { + display: none; + } +} + +.action-button { + position: relative; + padding-top: 100%; + width: 100%; +} + +.action-button a { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + text-align: center; + font-size: 0.87rem; + font-weight: 600; + text-transform: uppercase; + margin-top: 5px; + i { + width: 32px; + font-size: 32px; + } +} + +.relative-opening-time { + &.status-red { + color: #e34646; + } + + &.status-green { + color: #01c270; + } + + &.status-amber { + color: #f3ae00; + } +} diff --git a/app/components/SearchAndBrowse/SearchMap/SearchEntry.tsx b/app/components/SearchAndBrowse/SearchMap/SearchEntry.tsx new file mode 100644 index 000000000..71984ab46 --- /dev/null +++ b/app/components/SearchAndBrowse/SearchMap/SearchEntry.tsx @@ -0,0 +1,74 @@ +import React, { Component } from "react"; +import { Link } from "react-router-dom"; +import ReactMarkdown from "react-markdown"; +import websiteConfig from "utils/websiteConfig"; +import { RelativeOpeningTime } from "components/DetailPage/RelativeOpeningTime"; +import type { TransformedSearchHit } from "models/SearchHits"; +import "./SearchEntry.scss"; + +const { + appImages: { mohcdSeal }, +} = websiteConfig; + +interface Props { + hit: TransformedSearchHit; +} + +export default class SearchEntry extends Component { + render() { + const { hit } = this.props; + const { recurringSchedule, type } = hit; + + return ( + +
  • +
    +
    +

    {hit.headline}

    + {hit.is_mohcd_funded && ( +
    + MOHCD seal +

    Funded by MOHCD

    +
    + )} +
    + {type === "service" && ( +

    + + {hit.service_of} + +

    + )} +

    + {hit.addressDisplay} + {recurringSchedule && ( + + + + )} +

    +
    + + {hit.longDescription} + +
    +
    + +
  • + + ); + } +} diff --git a/app/components/SearchAndBrowse/SearchMap/SearchMap.scss b/app/components/SearchAndBrowse/SearchMap/SearchMap.scss new file mode 100644 index 000000000..aa908363c --- /dev/null +++ b/app/components/SearchAndBrowse/SearchMap/SearchMap.scss @@ -0,0 +1,50 @@ +@import "~styles/utils/_helpers.scss"; + +.mapLoaderContainer .loader .sk-fading-circle { + margin: 200px auto; +} + +.results-map { + position: sticky; + width: 100%; + min-width: 300px; + height: calc(100vh - $header-height); + top: $header-height; + + @media screen and (max-width: $break-tablet-p) { + height: 25rem; + } + + @media screen and (max-width: $break-tablet-s) { + min-height: 250px; + } +} + +.results-map-inner { + width: 100%; +} + +.marker { + transform: translateY(-50%); + position: absolute; + cursor: pointer; +} + +.tippy-tooltip-content { + .entry-details { + text-align: start; + padding: 20px; + } +} + +.map-wrapper { + height: 100%; +} + +.searchAreaButton { + position: absolute; + top: 5%; + left: 50%; + transform: translateX(-50%); + z-index: 1; +} diff --git a/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx b/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx new file mode 100644 index 000000000..88cb12b9a --- /dev/null +++ b/app/components/SearchAndBrowse/SearchMap/SearchMap.tsx @@ -0,0 +1,145 @@ +import React, { ReactElement, useState } from "react"; +import GoogleMap from "google-map-react"; +import { Tooltip } from "react-tippy"; +import "react-tippy/dist/tippy.css"; +import SearchEntry from "components/SearchAndBrowse/SearchMap/SearchEntry"; +import { useAppContext, useAppContextUpdater } from "utils"; +import { Button } from "components/ui/inline/Button/Button"; +import { + createMapOptions, + UserLocationMarker, + CustomMarker, +} from "components/ui/MapElements"; +import "./SearchMap.scss"; +import { TransformedSearchHit } from "../../../models"; +import config from "../../../config"; +import { SearchMapActions } from "components/SearchAndBrowse/SearchResults/SearchResults"; + +interface SearchMapProps { + hits: TransformedSearchHit[]; + mobileMapIsCollapsed: boolean; + handleSearchMapAction: (searchMapAction: SearchMapActions) => void; +} +export const SearchMap = ({ + hits, + mobileMapIsCollapsed, + handleSearchMapAction, +}: SearchMapProps) => { + const [googleMapObject, setMapObject] = useState( + null + ); + const { userLocation, aroundLatLng } = useAppContext(); + const { setAroundLatLng } = useAppContextUpdater(); + + function handleSearchThisAreaClick() { + const center = googleMapObject?.getCenter(); + if (center?.lat() && center?.lng()) { + setAroundLatLng(`${center.lat()}, ${center.lng()}`); + } + handleSearchMapAction(SearchMapActions.SearchThisArea); + } + + const aroundLatLngToMapCenter = { + lat: Number(aroundLatLng.split(",")[0]), + lng: Number(aroundLatLng.split(",")[1]), + }; + + // Center the map to the user's choice (`aroundLatLng`) with a fallback to our best guess when sniffing their + // location on app start (`userLocation`) + const googleMapsCenter = () => { + if (aroundLatLng) { + return aroundLatLngToMapCenter; + } else if (userLocation) { + return { lat: userLocation?.lat, lng: userLocation?.lng }; + } else { + return undefined; + } + }; + + return ( +
    +

    Map of search results

    +
    + {/* If map is being overlaid, hide the search area button. It is is neither clickable + nor relevant in this mode. + */} + {!mobileMapIsCollapsed && ( + + )} + { + // SetMapObject shares the Google Map object across parent/sibling components + // so that they can adjustments to markers, coordinates, layout, etc., + setMapObject(map); + }} + options={createMapOptions} + > + + {hits.reduce((markers, hit) => { + // Add a marker for each address of each hit + hit.locations.forEach((location) => { + markers.push( + + ); + }); + return markers; + }, [] as ReactElement[])} + +
    +
    + ); +}; + +// The GoogleMap component expects children to be passed lat/long, +// even though we don't use them here. +// +/* eslint-disable react/no-unused-prop-types */ +const GoogleSearchHitMarkerWorkaround = ({ + hit, + tag, +}: { + lat: number; + lng: number; + hit: TransformedSearchHit; + tag: string; +}) => ( + // TODO: Figure out why TS complaining after pckg update + /* eslint-disable @typescript-eslint/ban-ts-comment */ + // @ts-ignore + } + interactive + position="bottom" + theme="light" + trigger="click" + useContext + > + + +); +/* eslint-enable react/no-unused-prop-types */ diff --git a/app/components/SearchAndBrowse/SearchResults/SearchResult.tsx b/app/components/SearchAndBrowse/SearchResults/SearchResult.tsx new file mode 100644 index 000000000..91cdf17a9 --- /dev/null +++ b/app/components/SearchAndBrowse/SearchResults/SearchResult.tsx @@ -0,0 +1,100 @@ +import React, { forwardRef } from "react"; +import { TransformedSearchHit } from "models"; +import { Link } from "react-router-dom"; +import { LabelTag } from "components/ui/LabelTag"; +import { formatPhoneNumber, renderAddressMetadata } from "utils"; +import { removeAsterisksAndHashes } from "utils/strings"; +import ReactMarkdown from "react-markdown"; +import styles from "./SearchResults.module.scss"; + +interface SearchResultProps { + hit: TransformedSearchHit; +} + +export const SearchResult = forwardRef( + (props, ref) => { + const { hit } = props; + + return ( + // ref is for focusing on the first search hit when user paginates and scrolls to top +
    +
    +
    +
    +
    +

    + {hit.resultListIndexDisplay}.{" "} + + {hit.name} + +

    + {hit.type === "service" && ( +
    + + {hit.service_of} + +
    + )} +
    +
    + +
    +
    +
    +
    +

    + Call: {hit.phoneNumber} +

    +

    + Website: {hit.websiteUrl} +

    +
    +
    +
    +
    + {renderAddressMetadata(hit)} +
    + {/* Once we can update all dependencies, we can add remarkBreaks as remarkPlugin here */} + + {hit.longDescription + ? removeAsterisksAndHashes(hit.longDescription) + : ""} + +
    +
    +
    +
    + {hit.phoneNumber && ( + + + Call {formatPhoneNumber(hit.phoneNumber)} + + + )} + {hit.websiteUrl && ( + + Go to website + + )} +
    +
    + ); + } +); diff --git a/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss b/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss new file mode 100644 index 000000000..f1c077208 --- /dev/null +++ b/app/components/SearchAndBrowse/SearchResults/SearchResults.module.scss @@ -0,0 +1,309 @@ +@import "~styles/utils/_helpers.scss"; + +.searchResultsAndMapContainer { + display: flex; + + @media screen and (max-width: $break-tablet-p) { + flex-direction: column-reverse; + } +} + +.searchResultsContainer { + grid-area: results; + display: grid; + gap: $general-spacing-md; + margin: $spacing-0 $general-spacing-lg; + flex: 0 0 640px; + + @media screen and (max-width: $break-desktop-s) { + margin: $spacing-0 $general-spacing-md; + } + + @media screen and (max-width: $break-tablet-p) { + width: 100%; + padding-top: $general-spacing-md; + margin: 0; + gap: $spacing-0; + position: relative; + border-radius: var(--rounded-2xl, 1rem) var(--rounded-2xl, 1rem) 0rem 0rem; + top: -30px; + z-index: 2; + background: #fff; + transition: top 200ms ease-in-out; + + // Sets height from top of the viewport on mobile + &.resultsPositionWhenMapCollapsed { + top: calc(-50vh + 10px); + z-index: 1; + } + } +} + +.noResultsMessage { + margin: 35px auto 65px; + text-align: center; + &.hidden { + display: none; + } + + .noResultsText { + margin-bottom: $general-spacing-md; + } +} + +:global(.results-map) { + grid-area: map; +} + +.searchResultsTopShadow { + @media screen and (max-width: $break-tablet-s) { + box-shadow: 0 0 20px rgb(0 0 0 / 55%); + } +} + +.searchResultsHeader { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + font-size: 20px; + } + + @media screen and (max-width: $break-tablet-p) { + flex-direction: column; + justify-content: center; + margin: auto; + padding: $general-spacing-md; + gap: $general-spacing-xs; + width: 100%; + border-bottom: 1px solid $border-gray; + + h2 { + font-size: 18px; + } + + a { + margin: auto; + } + } +} + +.searchResult { + padding: $general-spacing-md; + border: 1px solid $border-gray; + border-radius: $rounded-md; + display: flex; + justify-content: space-between; + gap: $general-spacing-md; + + @media screen and (max-width: $break-tablet-p) { + border-radius: 0; + border: none; + border-bottom: 1px solid $border-gray; + flex-direction: column; + width: 100vw; + + p { + font-size: 14px; + } + } +} + +.searchResultContentContainer { + width: 100%; + max-width: 584px; + @media screen and (max-width: $break-tablet-p) { + max-width: 100%; + } +} + +.title { + font-weight: 600; + font-size: 20px; + font-family: "Montserrat"; + + @media screen and (max-width: $break-tablet-s) { + font-size: 18px; + } +} + +.titleContainer { + display: flex; + justify-content: space-between; + + @media screen and (max-width: $break-tablet-s) { + display: block; + } +} + +.serviceOf { + font-size: 14px; + line-height: 125%; +} + +.titleLink, +.serviceOfLink { + color: $text-link; + + &:hover { + color: $text-link-hover; + } +} + +.searchResultSubcatContainer { + display: flex; + align-items: flex-start; + gap: $general-spacing-xxs; + + @media screen and (max-width: $break-tablet-s) { + margin-top: $general-spacing-s; + } +} + +.address { + font-size: 16px; + margin-top: $general-spacing-s; + color: $text-secondary; + + &::before { + content: "\f3c5"; // Location pin icon + font-family: "Font Awesome 5 Free"; + font-weight: 900; + margin-right: $general-spacing-xs; + } + + @media screen and (max-width: $break-tablet-s) { + font-size: 14px; + } +} + +.description { + font-size: 16px; + padding-top: $general-spacing-xs; + color: $text-secondary; + line-height: 150%; + + a { + word-break: break-word; + } + @media screen and (max-width: $break-tablet-s) { + font-size: 14px; + } +} + +.sideLinks { + font-weight: bold; + font-size: 18px; + color: $color-brand; + + @media screen and (max-width: $break-tablet-p) { + font-size: 16px; + display: flex; + gap: $general-spacing-md; + } +} + +.icon { + height: 36px; + width: 36px; + background: $primary-button-default-background; + border-radius: $rounded-full; + color: $white; + display: flex; + justify-content: center; + align-items: center; + font-size: 16px; + + @media screen and (max-width: $break-tablet-s) { + height: 28px; + width: 28px; + font-size: 12px; + } + + &:hover { + text-decoration: none; + color: $white; + background: $primary-button-hover-background; + } +} + +.icon-phone { + margin-bottom: $general-spacing-md; + &::before { + content: "\f095"; // phone icon + transform: scaleX(-1); + font-family: "Font Awesome 5 Free"; + font-weight: 900; + } + @media screen and (max-width: $break-tablet-s) { + margin-bottom: $spacing-0; + } +} + +.icon-popout { + &::before { + content: "\f35d"; // popout icon + font-family: "Font Awesome 5 Free"; + font-weight: 900; + } +} + +.algoliaImgWrapper { + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0 0; + + & > img { + height: 16px; + } +} + +// Reformats page to only show search results +@media print { + :global(.searchResultsPage) { + visibility: hidden; + + :global(.results-map) { + display: none; + } + + a, + button, + input, + textarea { + transition: none !important; + } + + a[href]::after { + content: "" !important; + } + } + + .searchResultsContainer { + visibility: visible; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + margin: 50px auto; + width: 6.5in; + display: block; + } + + .searchResult { + display: block; + padding: 20px 0; + border-left: 0; + } + + .sideLink { + display: none; + &.showInPrintView { + display: block; + padding-top: 20px; + } + } +} diff --git a/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx b/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..83a9e996d --- /dev/null +++ b/app/components/SearchAndBrowse/SearchResults/SearchResults.tsx @@ -0,0 +1,115 @@ +import React, { useCallback } from "react"; +import { SearchMap } from "components/SearchAndBrowse/SearchMap/SearchMap"; +import { SearchResult } from "components/SearchAndBrowse/SearchResults/SearchResult"; +import { + TransformedSearchHit, + transformSearchResults, +} from "models/SearchHits"; +import { + useInstantSearch, + usePagination, + useSearchBox, +} from "react-instantsearch"; +import styles from "./SearchResults.module.scss"; +import ClearSearchButton from "../Refinements/ClearSearchButton"; +import { Loader } from "components/ui/Loader"; +import ResultsPagination from "components/SearchAndBrowse/Pagination/ResultsPagination"; + +export enum SearchMapActions { + SearchThisArea, +} + +const SearchResults = ({ + mobileMapIsCollapsed, +}: { + mobileMapIsCollapsed: boolean; +}) => { + const { refine: refinePagination } = usePagination(); + const { + // Results type is algoliasearchHelper.SearchResults + results: searchResults, + status, + } = useInstantSearch(); + const { query } = useSearchBox(); + + const handleFirstResultFocus = useCallback((node: HTMLDivElement | null) => { + if (node) { + node.focus(); + } + }, []); + + const searchMapHitData = transformSearchResults(searchResults); + + const hasNoResults = searchMapHitData.nbHits === 0 && status === "idle" && ( + + ); + + const NoResultsDisplay = () => ( +
    +
    + No results {query && `for ${` "${query}" `}`} found in your area. +
    Try a different location, filter, or search term. +
    + + {query && } +
    + ); + + const searchResultsHeader = () => { + return ( +
    +

    {searchResults.nbHits} results

    + +
    + ); + }; + + const handleAction = (searchMapAction: SearchMapActions) => { + switch (searchMapAction) { + case SearchMapActions.SearchThisArea: + return refinePagination(0); + } + }; + + return ( +
    +
    +

    Search results

    + {hasNoResults ? ( + + ) : ( + <> + {searchResultsHeader()} + {searchMapHitData.hits.map((hit: TransformedSearchHit, index) => ( + + ))} +
    +
    + +
    +
    + + )} +
    + +
    + ); +}; + +export default SearchResults; diff --git a/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.module.scss b/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.module.scss new file mode 100644 index 000000000..dc870b36a --- /dev/null +++ b/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.module.scss @@ -0,0 +1,45 @@ +@import "~styles/utils/_helpers.scss"; + +.mapListToggleContainer { + display: flex; + border-radius: $rounded-lg; + border: 2px solid $secondary-button-outline; + + @media screen and (min-width: $break-tablet-s) { + display: none; + } + + .mapListToggleBtn { + margin: 0; + padding: 0; + width: 32px; + height: 32px; + &:focus, + &:active, + &:hover { + box-shadow: none; + } + + &:focus { + border-radius: 0; + margin: 3px; + width: 26px; + height: 26px; + } + + span { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + + color: $secondary-button-default-label; + + &.activeView { + background: $secondary-button-default-label; + color: white; + } + } + } +} diff --git a/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.tsx b/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.tsx new file mode 100644 index 000000000..eac63a166 --- /dev/null +++ b/app/components/SearchAndBrowse/Sidebar/MobileMapToggleButtons.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import classNames from "classnames"; +import styles from "./MobileMapToggleButtons.module.scss"; + +// Collapses and expands map view for mobile +const MobileMapToggleButtons = ({ + isMapCollapsed, + setIsMapCollapsed, +}: { + isMapCollapsed: boolean; + setIsMapCollapsed: (_isMapCollapsed: boolean) => void; +}) => { + return ( +
    + + +
    + ); +}; + +export default MobileMapToggleButtons; diff --git a/app/components/SearchAndBrowse/Sidebar/Sidebar.module.scss b/app/components/SearchAndBrowse/Sidebar/Sidebar.module.scss new file mode 100644 index 000000000..fbd0ed55c --- /dev/null +++ b/app/components/SearchAndBrowse/Sidebar/Sidebar.module.scss @@ -0,0 +1,198 @@ +@import "~styles/utils/_helpers.scss"; +@import "../Refinements/RefinementFilters.module.scss"; + +@keyframes slideRight { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } +} + +.sidebar { + flex-shrink: 0; + position: sticky; + height: calc(100vh - $header-height); + width: 290px; + top: $header-height; + border-right: 1px solid $border-gray; + padding: 20px 25px 50px 30px; + overflow-y: auto; + + @media screen and (max-width: $break-desktop-s) { + height: auto; + width: 100%; + padding: 0; + align-self: flex-start; + border-right: 0; + background-color: #fff; + z-index: $z-index-filters-menu; + } + + @media screen and (max-width: $break-tablet-s) { + width: 100%; + top: $header-mobile-height; + margin: 0; + } +} + +.filtersContainer { + @media screen and (max-width: $break-desktop-s) { + display: none; + position: fixed; + height: 100vh; + width: 42%; + top: 0; + padding: 0 20px 65px; + overflow-y: auto; + box-shadow: 0 0 20px rgb(0 0 0 / 55%); + background-color: #fff; + animation-duration: 200ms; + animation-name: slideRight; + &.showFilters { + display: block; + } + } + + @media screen and (max-width: $break-tablet-s) { + width: 100%; + top: 131px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + animation-name: slideUp; + height: calc(100vh - 65px); + } +} + +.closeBtnContainer { + text-align: right; + @media screen and (min-width: $min-desktop) { + display: none; + } +} + +.filterGroup { + margin-top: $general-spacing-xl; +} + +.hideFilterGroup { + display: none; +} + +.filterTitle { + font-size: 18px; + font-weight: 600; + + @media screen and (max-width: $break-desktop-s) { + font-size: 16px; + font-weight: 500; + } +} + +.filterResourcesHeaderMobile { + position: relative; + padding-top: $general-spacing-md; + text-align: center; + @media screen and (min-width: $break-desktop-s) { + display: none; + } +} + +.filterResourcesTitleDesktop, +.filterResourcesTitle { + font-weight: bold; + font-size: 18px; +} + +.filterResourcesTitleDesktop { + @media screen and (max-width: $break-desktop-s) { + display: none; + } +} + +.filterResourcesBtn { + .closeIcon { + height: 25px; + } + + .filtersIcon { + height: 15px; + padding: 0 3px 3px 0; + } + + .filterBtn { + background: none; + border: none; + padding: 5px 10px 5px 0; + color: $color-brand; + font-weight: 400; + font-size: 15px; + + &:active, + &:focus, + &:hover { + box-shadow: none; + } + + &:hover { + color: $color-brand-dark; + } + + @media screen and (max-width: $break-tablet-l) { + padding: 0; + } + } +} + +.filtersButtonContainer { + display: none; + margin-bottom: $general-spacing-md; + + @media screen and (max-width: $break-desktop-s) { + display: flex; + justify-content: space-between; + align-items: center; + padding-left: 30px; + padding: 0 $general-spacing-md; + margin-bottom: $general-spacing-xs; + } + + @media screen and (min-width: $break-tablet-s) { + margin-bottom: $general-spacing-lg; + } +} + +.filterResourcesCloseButton { + position: absolute; + top: $general-spacing-md; + right: $general-spacing-xs; +} + +.checkBox { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + align-items: center; + margin-top: 13px; + color: $color-grey5; + cursor: pointer; + &.disabled { + color: $color-grey4; + } + + @media screen and (max-width: $break-tablet-s) { + font-size: 14px; + } +} diff --git a/app/components/SearchAndBrowse/Sidebar/Sidebar.tsx b/app/components/SearchAndBrowse/Sidebar/Sidebar.tsx new file mode 100644 index 000000000..32e7f82c8 --- /dev/null +++ b/app/components/SearchAndBrowse/Sidebar/Sidebar.tsx @@ -0,0 +1,247 @@ +import React, { useState, useRef, useCallback } from "react"; +import type { Category } from "models/Meta"; +import { + eligibilitiesMapping, + categoriesMapping, +} from "utils/refinementMappings"; +import ClearAllFilters from "components/SearchAndBrowse/Refinements/ClearAllFilters"; +import OpenNowFilter from "components/SearchAndBrowse/Refinements/OpenNowFilter"; +import BrowseRefinementList from "components/SearchAndBrowse/Refinements/BrowseRefinementList"; +import SearchRefinementList from "components/SearchAndBrowse/Refinements/SearchRefinementList"; +import { Button } from "components/ui/inline/Button/Button"; +import { + DEFAULT_AROUND_PRECISION, + useAppContext, + useAppContextUpdater, +} from "utils"; +import useClickOutside from "../../../hooks/MenuHooks"; +import MobileMapToggleButtons from "./MobileMapToggleButtons"; +import styles from "./Sidebar.module.scss"; +import { RefinementListItem } from "instantsearch.js/es/connectors/refinement-list/connectRefinementList"; + +const Sidebar = ({ + isSearchResultsPage, + eligibilities, + subcategories, + subcategoryNames = [], + sortAlgoliaSubcategoryRefinements = false, + isMapCollapsed, + setIsMapCollapsed, +}: { + isSearchResultsPage: boolean; + eligibilities?: object[] | null; + subcategories?: Category[] | null; + subcategoryNames?: string[]; + sortAlgoliaSubcategoryRefinements?: boolean; + isMapCollapsed: boolean; + setIsMapCollapsed: (_isMapCollapsed: boolean) => void; +}) => { + const [filterMenuVisible, setfilterMenuVisible] = useState(false); + const filterMenuRef = useRef(null); + const { aroundUserLocationRadius } = useAppContext(); + const { setAroundRadius } = useAppContextUpdater(); + + useClickOutside( + filterMenuRef, + () => setfilterMenuVisible(false), + filterMenuVisible + ); + + let categoryRefinementJsx: React.ReactElement | null = null; + let eligibilityRefinementJsx: React.ReactElement | null = null; + const orderByLabel = (a: { label: string }, b: { label: string }) => + a.label.localeCompare(b.label); + + const orderByPriorityRanking = useCallback( + (a: { label: string }, b: { label: string }) => { + if (!subcategoryNames) { + // noop + return 0; + } + // Our API has the ability to sort subcategories using the "child_priority_rank" on the + // CategoryRelationship table. In cases where we want to sort our sidebar categories + // following this order, we can use this sorting function, which sorts the categories + // that we receive from Algolia using the order that we get from the API. + const priorityA = subcategoryNames.indexOf(a.label); + const priorityB = subcategoryNames.indexOf(b.label); + + // If an element in the data returned from Algolia does not exist in the API's ordered array + // (i.e., Algolia is out of sync with our API), move the element to the back of the list. + if (priorityA < 0) return 1; + if (priorityB < 0) return -1; + + return priorityA - priorityB; + }, + [subcategoryNames] + ); + + const onChangeValue = (evt: React.ChangeEvent) => { + setAroundRadius(Number(evt.target.value)); + }; + + const refinementItemTransform = useCallback( + (items: RefinementListItem[]) => + items + .filter(({ label }: { label: string }) => + subcategoryNames.includes(label) + ) + .sort( + sortAlgoliaSubcategoryRefinements + ? orderByPriorityRanking + : orderByLabel + ), + [ + orderByPriorityRanking, + sortAlgoliaSubcategoryRefinements, + subcategoryNames, + ] + ); + + // Currently, the Search Results Page uses generic categories/eligibilities while the + // Service Results Page uses COVID-specific categories. This logic determines which + // of these to use as based on the isSearchResultsPage value + if (isSearchResultsPage) { + categoryRefinementJsx = ( + + ); + eligibilityRefinementJsx = ( + + ); + } else { + if (eligibilities?.length) { + eligibilityRefinementJsx = ( + + ); + } + if (subcategories?.length) { + categoryRefinementJsx = ( + + ); + } + } + + return ( +
    +
    + + +
    + +
    +
    +

    Filters

    + + + +
    + +

    Filter Resources

    + +
    +
    Availability
    + +
    + +
    +

    Eligibilities

    + {eligibilityRefinementJsx} +
    + {!isSearchResultsPage && ( +
    +

    Subcategories

    + {categoryRefinementJsx} +
    + )} + +
    +

    Distance

    + + + +
    +
    +
    + ); +}; + +export default Sidebar;