diff --git a/.github/workflows/cypress_public.yml b/.github/workflows/cypress_public.yml index 1e67ead15e..74624bc4a2 100644 --- a/.github/workflows/cypress_public.yml +++ b/.github/workflows/cypress_public.yml @@ -41,6 +41,7 @@ env: SHOW_PUBLIC_LOTTERY: "TRUE" GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} GOOGLE_MAPS_MAP_ID: ${{ secrets.GOOGLE_MAPS_MAP_ID }} + SHOW_ALL_MAP_PINS: "TRUE" jobs: public-cypress: diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts index 1025f917fa..4ba337f50e 100644 --- a/api/src/controllers/listing.controller.ts +++ b/api/src/controllers/listing.controller.ts @@ -251,6 +251,7 @@ export class ListingController { listingId, language, queryParams.view, + queryParams.combined, ); } } diff --git a/api/src/dtos/listings/listings-retrieve-params.dto.ts b/api/src/dtos/listings/listings-retrieve-params.dto.ts index 23cbdb00db..d2952ffd6e 100644 --- a/api/src/dtos/listings/listings-retrieve-params.dto.ts +++ b/api/src/dtos/listings/listings-retrieve-params.dto.ts @@ -15,4 +15,11 @@ export class ListingsRetrieveParams { groups: [ValidationsGroupsEnum.default], }) view?: ListingViews; + + @Expose() + @ApiPropertyOptional({ + type: Boolean, + example: true, + }) + combined?: boolean; } diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index 462af4b15e..40b7fd1af5 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -650,8 +650,11 @@ export class ListingService implements OnModuleInit { listingId: string, lang: LanguagesEnum = LanguagesEnum.en, view: ListingViews = ListingViews.full, + combined?: boolean, ): Promise { - const listingRaw = await this.findOrThrow(listingId, view); + const listingRaw = combined + ? await this.findOrThrowCombined(listingId) + : await this.findOrThrow(listingId, view); let result = mapTo(Listing, listingRaw); @@ -1310,6 +1313,21 @@ export class ListingService implements OnModuleInit { return listing; } + async findOrThrowCombined(id: string) { + const listing = await this.prisma.combinedListings.findUnique({ + where: { + id, + }, + }); + + if (!listing) { + throw new NotFoundException( + `listingId ${id} was requested but not found`, + ); + } + return listing; + } + /* This should be run for all address fields on an update of a listing. The address either needs to be updated if fields are changed, deleted if no longer attached, diff --git a/ci/buildspec/build_public.yml b/ci/buildspec/build_public.yml index 3071fd24ca..87832cc573 100644 --- a/ci/buildspec/build_public.yml +++ b/ci/buildspec/build_public.yml @@ -47,6 +47,7 @@ phases: --build-arg "API_PASS_KEY=${API_PASS_KEY}" --build-arg "GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}" --build-arg "GOOGLE_MAPS_MAP_ID=${GOOGLE_MAPS_MAP_ID}" + --build-arg "SHOW_ALL_MAP_PINS=${SHOW_ALL_MAP_PINS}" --target test -t sites/public:test . @@ -78,6 +79,7 @@ phases: --build-arg "API_PASS_KEY=${API_PASS_KEY}" --build-arg "GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}" --build-arg "GOOGLE_MAPS_MAP_ID=${GOOGLE_MAPS_MAP_ID}" + --build-arg "SHOW_ALL_MAP_PINS=${SHOW_ALL_MAP_PINS}" --target run -t sites/public:run-candidate . diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 9c00dbd513..4e17323325 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -351,6 +351,8 @@ export class ListingsService { id: string /** */ view?: ListingViews + /** */ + combined?: boolean } = {} as any, options: IRequestOptions = {} ): Promise { @@ -359,7 +361,7 @@ export class ListingsService { url = url.replace("{id}", params["id"] + "") const configs: IRequestConfig = getConfigs("get", "application/json", url, options) - configs.params = { view: params["view"] } + configs.params = { view: params["view"], combined: params["combined"] } /** 适配ios13,get请求不允许带body */ @@ -438,6 +440,8 @@ export class ListingsService { id: string /** */ view?: ListingViews + /** */ + combined?: boolean } = {} as any, options: IRequestOptions = {} ): Promise { @@ -446,7 +450,7 @@ export class ListingsService { url = url.replace("{id}", params["id"] + "") const configs: IRequestConfig = getConfigs("get", "application/json", url, options) - configs.params = { view: params["view"] } + configs.params = { view: params["view"], combined: params["combined"] } /** 适配ios13,get请求不允许带body */ @@ -2934,6 +2938,9 @@ export interface ListingsQueryParams { export interface ListingsRetrieveParams { /** */ view?: ListingViews + + /** */ + combined?: boolean } export interface PaginationAllowsAllQueryParams { diff --git a/sites/public/.env.template b/sites/public/.env.template index 18eb9993e6..016a12f074 100644 --- a/sites/public/.env.template +++ b/sites/public/.env.template @@ -38,6 +38,7 @@ SHOW_PWDLESS=TRUE BLOOM_API_BASE=https://api.housingbayarea.bloom.exygy.dev GOOGLE_MAPS_API_KEY=SUPER_SECRET_KEY GOOGLE_MAPS_MAP_ID= +SHOW_ALL_MAP_PINS= # API passkey, requests missing this will not be alllowed to progress diff --git a/sites/public/package.json b/sites/public/package.json index 50d69e2ffb..b39b01c24b 100644 --- a/sites/public/package.json +++ b/sites/public/package.json @@ -51,6 +51,7 @@ "react-dom": "18.2.0", "react-google-recaptcha-v3": "^1.10.1", "react-hook-form": "^6.15.5", + "react-swipeable": "^7.0.2", "tailwindcss": "2.2.10", "tough-cookie": "4.1.3", "winston": "^3.13.0" diff --git a/sites/public/public/images/map-pin-deprecated.svg b/sites/public/public/images/map-pin-deprecated.svg new file mode 100644 index 0000000000..238290b2f3 --- /dev/null +++ b/sites/public/public/images/map-pin-deprecated.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/sites/public/src/components/listings/ListingsCombinedDeprecated.module.scss b/sites/public/src/components/listings/ListingsCombinedDeprecated.module.scss new file mode 100644 index 0000000000..b607e6ee5e --- /dev/null +++ b/sites/public/src/components/listings/ListingsCombinedDeprecated.module.scss @@ -0,0 +1,118 @@ +.swipe-area { + display: none; + + @media (max-width: $screen-md) { + display: block; + position: absolute; + top: 0; + height: 3.25rem; + width: 100%; + z-index: var(--seeds-z-index-overlay); + background-color: var(--background-color); + border-top-left-radius: var(--bloom-rounded-3xl); + border-top-right-radius: var(--bloom-rounded-3xl); + } +} + +.swipe-area-bottom { + display: none; + + @media (max-width: $screen-md) { + display: block; + position: absolute; + top: calc(var(--listings-component-height)); + height: 3.25rem; + width: 100%; + z-index: var(--seeds-z-index-overlay); + background-color: var(--background-color); + border-top-left-radius: var(--bloom-rounded-3xl); + border-top-right-radius: var(--bloom-rounded-3xl); + } +} + +.swipe-area-line { + display: none; + + @media (max-width: $screen-md) { + display: block; + width: 30%; + margin-left: 35%; + margin-right: 35%; + margin-top: var(--bloom-s4); + border-top: 1px solid var(--line-color); + } +} + +.listings-list-deprecated { + overflow-y: auto; + width: var(--listings-list-width); + + @media (max-width: $screen-md) { + padding-top: 0; + z-index: 1; + top: var(--seeds-s10); + width: 100%; + overflow-y: auto; + background-color: var(--background-color); + display: block; + position: static; + z-index: 1; + height: calc(100dvh - 370px); + overflow-y: auto; + background-color: var(--background-color); + } +} + +.listings-outer-container-deprecated { + overflow-y: auto; + width: var(--listings-list-width); + + @media (max-width: $screen-md) { + position: absolute; + max-height: var(--listings-component-height-mobile); + z-index: 1; + top: calc(calc(calc(100vh - 183px) / 2)); + width: 100%; + border-top-left-radius: var(--bloom-rounded-3xl); + border-top-right-radius: var(--bloom-rounded-3xl); + } +} + +.listings-list-wrapper-deprecated { + @media (max-width: $screen-md) { + padding-top: var(--seeds-s10); + } +} + +.listings-list-expanded-deprecated { + overflow-y: auto; + width: var(--listings-list-width); + + @media (max-width: $screen-md) { + margin-top: var(--seeds-s4); + z-index: 1; + top: var(--seeds-s10); + width: 100%; + overflow-y: auto; + background-color: var(--background-color); + display: block; + position: static; + z-index: 1; + height: calc(100dvh - 230px); + overflow-y: auto; + background-color: var(--background-color); + } +} + +.listings-map-deprecated { + flex: 1; + position: relative; + display: flex; + height: 100%; + @media (max-width: $screen-md) { + position: absolute; + z-index: 0; + height: calc(100dvh - 230px); + width: 100%; + } +} diff --git a/sites/public/src/components/listings/ListingsCombinedDeprecated.tsx b/sites/public/src/components/listings/ListingsCombinedDeprecated.tsx new file mode 100644 index 0000000000..b3ff6e9246 --- /dev/null +++ b/sites/public/src/components/listings/ListingsCombinedDeprecated.tsx @@ -0,0 +1,157 @@ +import React, { useState } from "react" +import { Listing } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { ListingsMap } from "./ListingsMapDeprecated" +import { ListingsList } from "./ListingsListDeprecated" +import { useSwipeable } from "react-swipeable" +import styles from "./ListingsCombined.module.scss" +import deprecatedStyles from "./ListingsCombinedDeprecated.module.scss" + +type ListingsCombinedProps = { + listings: Listing[] + currentPage: number + lastPage: number + onPageChange: (page: number) => void + googleMapsApiKey: string + loading: boolean + listView: boolean + setListView: React.Dispatch> + isDesktop: boolean +} + +const ListingsCombined = (props: ListingsCombinedProps) => { + const [showListingsList, setShowListingsList] = useState(true) + const [showListingsMap, setShowListingsMap] = useState(true) + + const swipeHandler = useSwipeable({ + onSwipedUp: () => { + if (showListingsMap) { + if (showListingsList) { + // This is for the combined listings page, swiping up shows the listings list page. + setShowListingsList(true) + setShowListingsMap(false) + return + } else { + // This is for the listings map only page, swiping up shows the listings combined page. + setShowListingsList(true) + setShowListingsMap(true) + return + } + } + }, + onSwipedDown: () => { + if (showListingsList) { + if (showListingsMap) { + // This is for the combined listings page, swiping down shows the listings map page. + setShowListingsList(false) + setShowListingsMap(true) + return + } else { + // This is for the listings list only page, swiping up shows the listings combined page. + setShowListingsList(true) + setShowListingsMap(true) + return + } + } + }, + preventScrollOnSwipe: true, + }) + + const getListingsList = () => { + return ( +
+
+ +
+
+
+
+
+
+ +
+
+
+ ) + } + + const getListingsMap = () => { + return ( +
+
+ +
+
+
+
+
+ ) + } + + const getListingsCombined = () => { + return ( +
+
+ +
+
+
+
+
+
+ +
+
+
+ ) + } + + let div: JSX.Element + + if (props.isDesktop) { + div = getListingsCombined() + } else if (showListingsList && !showListingsMap) { + div = getListingsList() + } else if (showListingsMap && !showListingsList) { + div = getListingsMap() + } else if (showListingsList && showListingsMap) { + div = getListingsCombined() + } + + return div +} + +export { ListingsCombined as default, ListingsCombined } diff --git a/sites/public/src/components/listings/ListingsListDeprecated.tsx b/sites/public/src/components/listings/ListingsListDeprecated.tsx new file mode 100644 index 0000000000..191b053003 --- /dev/null +++ b/sites/public/src/components/listings/ListingsListDeprecated.tsx @@ -0,0 +1,100 @@ +import * as React from "react" +import { Listing } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { ZeroListingsItem } from "@bloom-housing/doorway-ui-components" +import { LoadingOverlay, t, InfoCard, LinkButton } from "@bloom-housing/ui-components" +import { getListings } from "../../lib/helpers" +import { Pagination } from "./Pagination" +import styles from "./ListingsCombined.module.scss" +import deprecatedStyles from "./ListingsCombinedDeprecated.module.scss" +import CustomSiteFooter from "../shared/CustomSiteFooter" + +type ListingsListProps = { + listings: Listing[] + currentPage: number + lastPage: number + onPageChange: (page: number) => void + loading: boolean +} + +const ListingsList = (props: ListingsListProps) => { + const listingsDiv = ( +
+ {props.listings.length > 0 || props.loading ? ( +
+ {getListings( + props.listings.map((listing, index) => { + return { ...listing, name: `${index + 1}. ${listing.name}` } + }) + )} +
+ ) : ( + + )} +
+ ) + + const infoCards = ( +
+ {process.env.notificationsSignUpUrl && ( + + + {t("t.signUp")} + + + )} + + + {t("t.helpCenter")} + + + + + {t("t.seeListings")} + + +
+ ) + + const pagination = + props.lastPage != 0 ? ( + + ) : ( + <> + ) + return ( + <> +
+ {listingsDiv} + {pagination} + {infoCards} + +
+ + ) +} +export { ListingsList as default, ListingsList } diff --git a/sites/public/src/components/listings/ListingsMapDeprecated.tsx b/sites/public/src/components/listings/ListingsMapDeprecated.tsx new file mode 100644 index 0000000000..36c460b432 --- /dev/null +++ b/sites/public/src/components/listings/ListingsMapDeprecated.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useState } from "react" +import { GoogleMap, InfoWindow, Marker, useJsApiLoader } from "@react-google-maps/api" +import { getListingUrl, getListingCard } from "../../lib/helpers" +import styles from "./ListingsCombined.module.scss" +import { MapControl } from "../shared/MapControlDeprecated" +import { t } from "@bloom-housing/ui-components" +import { Listing } from "@bloom-housing/shared-helpers/src/types/backend-swagger" + +type ListingsMapProps = { + listings?: Listing[] + googleMapsApiKey: string + desktopMinWidth?: number + isMapExpanded: boolean + setShowListingsList?: React.Dispatch> +} + +const containerStyle: React.CSSProperties = { + display: "flex", + width: "100%", + height: "100%", + position: "relative", +} + +const center = { + lat: 37.579795, + lng: -122.374118, +} + +const ListingsMap = (props: ListingsMapProps) => { + const { isLoaded } = useJsApiLoader({ + googleMapsApiKey: props.googleMapsApiKey, + }) + + const [openInfoWindow, setOpenInfoWindow] = useState(false) + const [infoWindowIndex, setInfoWindowIndex] = useState(null) + + const [isDesktop, setIsDesktop] = useState(true) + + const DESKTOP_MIN_WIDTH = props.desktopMinWidth || 767 // @screen md + useEffect(() => { + if (window.innerWidth > DESKTOP_MIN_WIDTH) { + setIsDesktop(true) + } else { + setIsDesktop(false) + } + + const updateMedia = () => { + if (window.innerWidth > DESKTOP_MIN_WIDTH) { + setIsDesktop(true) + } else { + setIsDesktop(false) + } + } + window.addEventListener("resize", updateMedia) + return () => window.removeEventListener("resize", updateMedia) + }, [DESKTOP_MIN_WIDTH]) + + const markers = [] + let index = 0 + props.listings.forEach((listing: Listing) => { + const lat = listing.listingsBuildingAddress.latitude + const lng = listing.listingsBuildingAddress.longitude + const uri = getListingUrl(listing) + const key = ++index + + // Create an info window that is associated to each marker and that contains the listing card + // for that listing. + const infoWindow = ( + setOpenInfoWindow(false)}> +
+ {getListingCard({ ...listing, name: `${key}. ${listing.name}` }, key - 1)} +
+
+ ) + markers.push({ lat, lng, uri, key, infoWindow }) + }) + + const mapRef = React.useRef(null) + return isLoaded ? ( +
+ + {t("t.skipMapOfListings")} + + + + {markers.map((marker) => ( + { + if (isDesktop) { + setOpenInfoWindow(true) + setInfoWindowIndex(marker.key) + } else { + if (props.isMapExpanded) { + // Bring up the listings list with the correct listing at the top. A short timeout + // is needed so the listings row element can be found in the document. + setTimeout(() => { + props.setShowListingsList(true) + }, 1) + setTimeout(() => { + const element = document.getElementsByClassName("listings-row")[marker.key - 1] + element.scrollIntoView({ block: "start" }) + window.scrollTo(0, 0) + }, 5) + } else { + const element = document.getElementsByClassName("listings-row")[marker.key - 1] + element.scrollIntoView({ block: "start" }) + window.scrollTo(0, 0) + } + } + }} + key={marker.key.toString()} + icon={{ + url: "/images/map-pin-deprecated.svg", + labelOrigin: new google.maps.Point(14, 15), + }} + > + {/* Only display the info window when the corresponding marker has been clicked. */} + {openInfoWindow && infoWindowIndex === marker.key && marker.infoWindow} + + ))} + +
+ ) : ( + <> + ) +} + +export { ListingsMap as default, ListingsMap } diff --git a/sites/public/src/components/listings/MapClusterer.tsx b/sites/public/src/components/listings/MapClusterer.tsx index cb3e1a5f56..39a2ef3c1d 100644 --- a/sites/public/src/components/listings/MapClusterer.tsx +++ b/sites/public/src/components/listings/MapClusterer.tsx @@ -108,7 +108,11 @@ export const MapClusterer = ({ const fetchInfoWindow = async (listingId: string) => { try { - const response = await listingsService.retrieve({ id: listingId, view: ListingViews.base }) + const response = await listingsService.retrieve({ + id: listingId, + view: ListingViews.base, + combined: true, + }) setInfoWindowContent(
{getListingCard(response, infoWindowIndex)} diff --git a/sites/public/src/components/listings/search/ListingsSearch.module.scss b/sites/public/src/components/listings/search/ListingsSearch.module.scss index 480dfc7537..58c10c5f5b 100644 --- a/sites/public/src/components/listings/search/ListingsSearch.module.scss +++ b/sites/public/src/components/listings/search/ListingsSearch.module.scss @@ -27,7 +27,7 @@ background: white; padding-inline: var(--seeds-s4); padding-block: var(--seeds-s1); - gap: var(--seeds-s4); + gap: var(--seeds-s6); color: var(--seeds-text-color); border-block-end: 1px solid var(--seeds-border-color); @@ -36,6 +36,7 @@ width: 100%; font-size: var(--seeds-font-size-sm); padding-inline: var(--seeds-s4); + padding-block: var(--seeds-s2); } strong { @@ -46,16 +47,17 @@ .search-switch-container { padding-block: 0; @media (max-width: $screen-md) { - padding-block-start: var(--seeds-s2); + padding-block: var(--seeds-s1); border-block-start: 1px solid var(--seeds-border-color); justify-content: flex-end; border-block-end: none; + border-bottom: 1px solid var(--seeds-border-color); } } .search-total-results { font-size: var(--seeds-font-size-xl); - margin-right: var(--seeds-s4); + margin-right: var(--seeds-s6); @media (max-width: $screen-md) { font-size: var(--seeds-font-size-sm); @@ -63,9 +65,7 @@ } .hide-total-results { - @media (max-width: $screen-md) { - display: none; - } + display: none; } .filter-desktop { diff --git a/sites/public/src/components/listings/search/ListingsSearchCombinedDeprecated.tsx b/sites/public/src/components/listings/search/ListingsSearchCombinedDeprecated.tsx new file mode 100644 index 0000000000..ddd07a97ae --- /dev/null +++ b/sites/public/src/components/listings/search/ListingsSearchCombinedDeprecated.tsx @@ -0,0 +1,198 @@ +import React, { useRef, useState, useEffect, useContext } from "react" +import { UserStatus } from "../../../lib/constants" +import { ListingList, pushGtmEvent, AuthContext } from "@bloom-housing/shared-helpers" +import { t } from "@bloom-housing/ui-components" +import { ListingSearchParams, generateSearchQuery } from "../../../lib/listings/search" +import { searchListings } from "../../../lib/listings/listing-service" +import styles from "./ListingsSearchDeprecated.module.scss" +import { ListingsCombined } from "../ListingsCombinedDeprecated" +import { FormOption, ListingsSearchModal } from "./ListingsSearchModalDeprecated" +import { ListingsSearchMetadata } from "./ListingsSearchMetadataDeprecated" + +type ListingsSearchCombinedProps = { + searchString?: string + googleMapsApiKey: string + bedrooms: FormOption[] + bathrooms: FormOption[] + counties: FormOption[] +} + +/** + * This combines the search form with the listings map/list. Listings are updated + * in both when the search form is submitted. + * + * @param props + * @returns + */ +function ListingsSearchCombinedDeprecated(props: ListingsSearchCombinedProps) { + const { profile, listingsService } = useContext(AuthContext) + const [modalOpen, setModalOpen] = useState(false) + const [filterCount, setFilterCount] = useState(0) + const [listView, setListView] = useState(true) + const [isDesktop, setIsDesktop] = useState(true) + + // Store the current search params for pagination + const searchParams = useRef({ + bedrooms: null, + bathrooms: null, + monthlyRent: null, + counties: [], + } as ListingSearchParams) + + const [searchResults, setSearchResults] = useState({ + listings: [], + currentPage: 0, + lastPage: 0, + totalItems: 0, + loading: true, + }) + + useEffect(() => { + pushGtmEvent({ + event: "pageView", + pageTitle: t("nav.siteTitle"), + status: profile ? UserStatus.LoggedIn : UserStatus.NotLoggedIn, + numberOfListings: searchResults.listings.length, + listingIds: searchResults.listings.map((listing) => listing.id), + }) + }, [profile, searchResults]) + + const DESKTOP_MIN_WIDTH = 767 // @screen md + useEffect(() => { + if (window.innerWidth > DESKTOP_MIN_WIDTH) { + setIsDesktop(true) + } else { + setIsDesktop(false) + } + + const updateMedia = () => { + if (window.innerWidth > DESKTOP_MIN_WIDTH) { + setIsDesktop(true) + } else { + setIsDesktop(false) + } + } + window.addEventListener("resize", updateMedia) + return () => window.removeEventListener("resize", updateMedia) + }, []) + + // The search function expects a string + // This can be changed later if needed + const pageSize = 25 + + const search = async (params: ListingSearchParams, page: number) => { + const qb = generateSearchQuery(params) + const result = await searchListings(qb, pageSize, page, listingsService) + + const listings = result.items + const meta = result.meta + + setSearchResults({ + listings: listings, + currentPage: meta.currentPage, + lastPage: meta.totalPages, + totalItems: meta.totalItems, + loading: false, + }) + + searchParams.current = params + + document.getElementById("listings-outer-container")?.scrollTo(0, 0) + document.getElementById("listings-list")?.scrollTo(0, 0) + document.getElementById("listings-list-expanded")?.scrollTo(0, 0) + window.scrollTo(0, 0) + } + + const onFormSubmit = async (params: ListingSearchParams) => { + await search(params, 1) + } + + const onPageChange = async (page: number) => { + await search(searchParams.current, page) + } + + const onModalClose = () => { + setModalOpen(false) + } + + const updateFilterCount = (count: number) => { + setFilterCount(count) + } + + return ( +
+ + + + + +
+ ) +} + +const locations: FormOption[] = [ + { + label: "Alameda", + value: "Alameda", + }, + { + label: "Contra Costa", + value: "Contra Costa", + }, + { + label: "Marin", + value: "Marin", + }, + { + label: "Napa", + value: "Napa", + }, + { + label: "San Mateo", + value: "San Mateo", + }, + { + label: "Santa Clara", + value: "Santa Clara", + }, + { + label: "Solano", + value: "Solano", + }, + { + label: "Sonoma", + value: "Sonoma", + }, + { + label: "San Francisco", + value: "San Francisco", + isDisabled: true, + doubleColumn: true, + }, +] + +export { ListingsSearchCombinedDeprecated as default, locations } diff --git a/sites/public/src/components/listings/search/ListingsSearchDeprecated.module.scss b/sites/public/src/components/listings/search/ListingsSearchDeprecated.module.scss new file mode 100644 index 0000000000..1254c315e8 --- /dev/null +++ b/sites/public/src/components/listings/search/ListingsSearchDeprecated.module.scss @@ -0,0 +1,112 @@ +.listings-vars { + // This is a not-ideal way to do "fill window minus header and filter modal" however + // I can't find another way to do this. + // TODO: update header + filter modal to a not-magic number + --desktop-header-offset: 169px; + --mobile-header-offset: 215px; + --listings-component-height: calc(100dvh - var(--desktop-header-offset)); + --listings-component-height-mobile: calc(100dvh - var(--mobile-header-offset)); + + --listings-component-half-height: calc(var(--listings-component-height) / 2); + --listings-list-width: 600px; + --listings-list-overscroll: calc(var(--seeds-s14) + var(--seeds-s1)); + --swipe-area-height: var(--seeds-s14); + --background-color: var(--seeds-bg-color-surface); + --line-color: var(--seeds-color-gray-650); + + position: absolute; +} + +.search-filter-bar { + display: flex; + justify-content: flex-end; + align-items: center; + position: relative; + right: 0px; + z-index: calc(var(--seeds-z-index-overlay) - 1); + background: white; + padding-inline: var(--seeds-s4); + padding-block: var(--seeds-s1); + gap: var(--seeds-s6); + color: var(--seeds-text-color); + border-block-end: 1px solid var(--seeds-border-color); + + @media (max-width: $screen-md) { + position: static; + width: 100%; + font-size: var(--seeds-font-size-sm); + padding-inline: var(--seeds-s4); + padding-block: var(--seeds-s2); + } + + strong { + font-weight: var(--seeds-font-weight-semibold); + } +} + +.search-switch-container { + padding-block: 0; + @media (max-width: $screen-md) { + padding-block: var(--seeds-s1); + border-block-start: 1px solid var(--seeds-border-color); + justify-content: flex-end; + border-block-end: none; + border-bottom: 1px solid var(--seeds-border-color); + } +} + +.search-total-results { + font-size: var(--seeds-font-size-xl); + margin-right: var(--seeds-s6); + + @media (max-width: $screen-md) { + font-size: var(--seeds-font-size-sm); + } +} + +.hide-total-results { + display: none; +} + +.filter-desktop { + --button-font-family: inherit; + --button-font-weight: var(--seeds-font-weight-regular); + --button-interior-gap: var(--seeds-s1_5); + line-height: var(--seeds-line-height-4); + display: none; + @media (max-width: $screen-md) { + display: block; + } +} + +.filter-mobile { + --button-font-family: inherit; + --button-font-weight: var(--seeds-font-weight-regular); + --button-interior-gap: var(--seeds-s1_5); + line-height: var(--seeds-line-height-4); + display: block; + @media (max-width: $screen-md) { + display: none; + } +} + +.total-results { + display: flex; + flex-direction: row; + align-items: center; + @media (max-width: $screen-md) { + padding-block-end: 0; + } +} + +.switch-view-button { + display: none; + + @media (max-width: $screen-md) { + svg { + margin-right: var(--seeds-s1); + margin-left: 0; + } + display: block; + } +} diff --git a/sites/public/src/components/listings/search/ListingsSearchMetadata.tsx b/sites/public/src/components/listings/search/ListingsSearchMetadata.tsx index c69c521572..33c9c32ed8 100644 --- a/sites/public/src/components/listings/search/ListingsSearchMetadata.tsx +++ b/sites/public/src/components/listings/search/ListingsSearchMetadata.tsx @@ -66,21 +66,13 @@ export const ListingsSearchMetadata = ({
-
+
- + {t("search.totalResults")} {searchResults.totalItems} {searchResults.lastPage > 0 && ( - + ( {t("t.pageXofY", { current: searchResults.currentPage, diff --git a/sites/public/src/components/listings/search/ListingsSearchMetadataDeprecated.tsx b/sites/public/src/components/listings/search/ListingsSearchMetadataDeprecated.tsx new file mode 100644 index 0000000000..4e18b7c749 --- /dev/null +++ b/sites/public/src/components/listings/search/ListingsSearchMetadataDeprecated.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { FunnelIcon } from "@heroicons/react/24/solid" +import { Listing } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { t } from "@bloom-housing/ui-components" +import { Button, Icon } from "@bloom-housing/ui-seeds" +import styles from "./ListingsSearchDeprecated.module.scss" + +export interface ListingsSearchMetadataProps { + loading: boolean + setModalOpen: React.Dispatch> + filterCount: number + searchResults: { + listings: Listing[] + currentPage: number + lastPage: number + totalItems: number + } +} + +export const ListingsSearchMetadata = ({ + setModalOpen, + filterCount, + searchResults, +}: ListingsSearchMetadataProps) => { + return ( +
+
+ <> + + +
+
+
+ + {t("search.totalResults")} {searchResults.totalItems} + + {searchResults.lastPage > 0 && ( + + ( + {t("t.pageXofY", { + current: searchResults.currentPage, + total: searchResults.lastPage, + })} + ) + + )} +
+ +
+
+ ) +} diff --git a/sites/public/src/components/listings/search/ListingsSearchModalDeprecated.tsx b/sites/public/src/components/listings/search/ListingsSearchModalDeprecated.tsx new file mode 100644 index 0000000000..eaff3043e7 --- /dev/null +++ b/sites/public/src/components/listings/search/ListingsSearchModalDeprecated.tsx @@ -0,0 +1,350 @@ +import React, { useCallback, useEffect, useState } from "react" +import { ListingSearchParams, parseSearchString } from "../../../lib/listings/search" +import { t } from "@bloom-housing/ui-components" +import { + ButtonGroup, + ButtonGroupSpacing, + Button, + Field, + FieldGroup, + FieldSingle, +} from "@bloom-housing/doorway-ui-components" +import { Dialog } from "@bloom-housing/ui-seeds" +import { useForm } from "react-hook-form" +import { numericSearchFieldGenerator } from "./helpers" +import { FilterAvailabilityEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" + +const inputSectionStyle: React.CSSProperties = { + margin: "0px 15px", +} + +const hyphenContainerStyle: React.CSSProperties = { + position: "relative", +} + +const hyphenStyle: React.CSSProperties = { + fontSize: "2rem", + position: "relative", + bottom: "1px", + padding: ".7rem", + width: "100%", +} + +const sectionTitle: React.CSSProperties = { + fontWeight: "bold", +} + +const sectionTitleTopBorder: React.CSSProperties = { + fontWeight: "bold", + borderTop: "2px solid var(--bloom-color-gray-450)", + padding: "1rem 0 1rem 0", +} + +const rentStyle: React.CSSProperties = { + margin: "0px 0px", + display: "flex", +} + +const clearButtonStyle: React.CSSProperties = { + textDecoration: "underline", +} + +export type FormOption = { + label: string + value: string + isDisabled?: boolean + labelNoteHTML?: string + doubleColumn?: boolean +} + +type ListingsSearchModalProps = { + open: boolean + searchString?: string + bedrooms: FormOption[] + bathrooms: FormOption[] + counties: FormOption[] + onSubmit: (params: ListingSearchParams) => void + onClose: () => void + onFilterChange: (count: number) => void +} +// TODO: Refactor ListingsSearchModal to utilize react-hook-form. It is currently using a custom form object and custom valueSetters +// which is mostly functional but fails to leverage UI-C's formatting, accessibility and any other future improvements to the +// package. To expedite development and avoid excessive workarounds (ie. lines 213, 221), a full form refactor should be completed. +export function ListingsSearchModal(props: ListingsSearchModalProps) { + const searchString = props.searchString || "" + + // We hold a map of county label to county FormOption + const countyLabelMap = {} + const countyLabels = [] + props.counties.forEach((county) => { + countyLabelMap[county.label] = county + countyLabels.push(county.label) + }) + + const nullState: ListingSearchParams = { + bedrooms: null, + bathrooms: null, + minRent: "", + monthlyRent: "", + counties: countyLabels, + availability: null, + ids: undefined, + } + const initialState = parseSearchString(searchString, nullState) + const [formValues, setFormValues] = useState(initialState) + + const countFilters = useCallback((params: ListingSearchParams) => { + let count = 0 + // For each of our search params, count the number that aren't empty + Object.values(params).forEach((value) => { + if (value == null || value == "") return + if (Array.isArray(value) && value.length == props.counties.length) return + count++ + }) + return count + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // We're factoring out the function to prevent requiring props in useEffect + const filterChange = props.onFilterChange + useEffect(() => { + filterChange(countFilters(formValues)) + }, [formValues, filterChange, countFilters]) + + // Run this once immediately after first render + // Empty array is intentional; it's how we make sure it only runs once + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + // Set initial filter count + props.onFilterChange(countFilters(formValues)) + // Fetch listings + onSubmit() + }, []) + + const clearValues = () => { + setFormValues(nullState) + + // Reset currency fields: + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + document.querySelector("#minRent").value = null + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + document.querySelector("#monthlyRent").value = null + } + + const onSubmit = () => { + props.onSubmit(formValues) + props.onClose() + } + + const updateValue = (name: string, value: string) => { + // Create a copy of the current value to ensure re-render + const newValues = {} as ListingSearchParams + Object.assign(newValues, formValues) + newValues[name] = value + setFormValues(newValues) + // console.log(`${name} has been set to ${value}`) // uncomment to debug + } + + const updateValueMulti = (name: string, labels: string[]) => { + const newValues = { ...formValues } as ListingSearchParams + newValues[name] = labels + setFormValues(newValues) + // console.log(`${name} has been set to ${value}`) // uncomment to debug + } + + const translatedBedroomOptions: FormOption[] = [ + { + label: t("listings.unitTypes.any"), + value: null, + }, + { + label: t("listings.unitTypes.studio"), + value: "0", + }, + ] + + const translatedBathroomOptions: FormOption[] = [ + { + label: t("listings.unitTypes.any"), + value: null, + }, + ] + + const bedroomOptions: FormOption[] = [ + ...translatedBedroomOptions, + ...numericSearchFieldGenerator(1, 4), + ] + const bathroomOptions: FormOption[] = [ + ...translatedBathroomOptions, + ...numericSearchFieldGenerator(1, 4), + ] + const mkCountyFields = (counties: FormOption[]): FieldSingle[] => { + const countyFields: FieldSingle[] = [] as FieldSingle[] + + const selected = {} + + formValues.counties.forEach((label) => { + selected[label] = true + }) + let check = false + const dahliaNote = `(${t( + "filter.goToDahlia" + )} DAHLIA)` + counties.forEach((county, idx) => { + // FieldGroup uses the label attribute to check for selected inputs. + check = selected[county.label] !== undefined + if (county.isDisabled) { + check = false + } + countyFields.push({ + id: `county-item-${county.label}`, + index: idx, + label: county.label, + value: county.value, + defaultChecked: check, + disabled: county.isDisabled || false, + doubleColumn: county.doubleColumn || false, + note: county.label === "San Francisco" ? dahliaNote : county.labelNoteHTML || "", + } as FieldSingle) + }) + return countyFields + } + const countyFields = mkCountyFields(props.counties) + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, getValues, setValue, watch } = useForm() + const monthlyRentFormatted = watch("monthlyRent") + const minRentFormatted = watch("minRent") + const currencyFormatting = /,|\.\d{2}/g + + // workarounds to leverage UI-C's currency formatting without full refactor + useEffect(() => { + if (typeof minRentFormatted !== "undefined") { + const minRentRaw = minRentFormatted.replaceAll(currencyFormatting, "") + updateValue("minRent", minRentRaw) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [minRentFormatted]) + + useEffect(() => { + if (typeof monthlyRentFormatted !== "undefined") { + const monthlyRentRaw = monthlyRentFormatted.replaceAll(currencyFormatting, "") + updateValue("monthlyRent", monthlyRentRaw) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [monthlyRentFormatted]) + + return ( + + {t("search.filters")} + +
+
{t("t.opportunityType")}
+ +
+
+
{t("t.bedrooms")}
+ +
+
+
{t("t.bathrooms")}
+ +
+
+
{t("t.monthlyRent")}
+
+ +
+
-
+
+ +
+
+ +
+
{t("t.counties")}
+ +
+ {t("welcome.bayAreaCountyMap")} +
+ + +
+ +
+
+ ) +} diff --git a/sites/public/src/components/shared/MapControlDeprecated.tsx b/sites/public/src/components/shared/MapControlDeprecated.tsx new file mode 100644 index 0000000000..7c32a18c39 --- /dev/null +++ b/sites/public/src/components/shared/MapControlDeprecated.tsx @@ -0,0 +1,62 @@ +import React, { RefObject } from "react" +import { GoogleMap } from "@react-google-maps/api" +import styles from "./MapControl.module.scss" +import { Icon } from "@bloom-housing/ui-seeds" +import PlusIcon from "@heroicons/react/24/solid/PlusIcon" +import MinusIcon from "@heroicons/react/24/solid/MinusIcon" + +import { t } from "@bloom-housing/ui-components" + +export interface MapControlProps { + mapRef: RefObject +} + +const MapControlZoomIn = (props: MapControlProps) => { + const click = () => { + const m = props.mapRef.current + const currentZoom: number = m.state.map.getZoom() + m.state.map.setZoom(currentZoom + 1) + } + return ( + + ) +} + +const MapControlZoomOut = (props: MapControlProps) => { + const click = () => { + const m = props.mapRef.current + const currentZoom: number = m.state.map.getZoom() + m.state.map.setZoom(currentZoom - 1) + } + + return ( + + ) +} + +const MapControl = (props: MapControlProps) => { + return ( +
+ + +
+ ) +} + +export { MapControl as default, MapControl } diff --git a/sites/public/src/lib/runtime-config.ts b/sites/public/src/lib/runtime-config.ts index f5b55b0e03..0d43de114f 100644 --- a/sites/public/src/lib/runtime-config.ts +++ b/sites/public/src/lib/runtime-config.ts @@ -42,6 +42,10 @@ export const runtimeConfig = { return this.env.GOOGLE_MAPS_MAP_ID }, + getShowAllMapPins() { + return this.env.SHOW_ALL_MAP_PINS + }, + getBackendApiBase() { if (this.env.BACKEND_PROXY_BASE) { // try proxy base first diff --git a/sites/public/src/pages/listings.tsx b/sites/public/src/pages/listings.tsx index 118a916e7c..956b08bede 100644 --- a/sites/public/src/pages/listings.tsx +++ b/sites/public/src/pages/listings.tsx @@ -6,6 +6,7 @@ import { MetaTags } from "../components/shared/MetaTags" import ListingsSearchCombined, { locations, } from "../components/listings/search/ListingsSearchCombined" +import ListingsSearchCombinedDeprecated from "../components/listings/search/ListingsSearchCombinedDeprecated" import { FormOption } from "../components/listings/search/ListingsSearchModal" import { runtimeConfig } from "../lib/runtime-config" import Layout from "../layouts/application" @@ -14,6 +15,7 @@ export interface ListingsProps { listingsEndpoint: string googleMapsApiKey: string googleMapsMapId: string + showAllMapPins: string bedrooms: FormOption[] bathrooms: FormOption[] } @@ -41,14 +43,24 @@ export default function ListingsPage(props: ListingsProps) { {t("nav.listings")} - + {props.showAllMapPins === "TRUE" ? ( + + ) : ( + + )} ) } @@ -58,6 +70,7 @@ export function getServerSideProps() { props: { googleMapsApiKey: runtimeConfig.getGoogleMapsApiKey() || null, googleMapsMapId: runtimeConfig.getGoogleMapsMapId() || null, + showAllMapPins: runtimeConfig.getShowAllMapPins() || null, }, } } diff --git a/sites/public/styles/overrides.scss b/sites/public/styles/overrides.scss index 99853fa6fd..07b6fffd7f 100644 --- a/sites/public/styles/overrides.scss +++ b/sites/public/styles/overrides.scss @@ -295,6 +295,7 @@ } .listings-row { + scroll-margin-top: 35px; --inter-row-gap: var(--seeds-s3_5); } diff --git a/yarn.lock b/yarn.lock index 6d50abfd3a..3c2c9d422b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12643,6 +12643,11 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" +react-swipeable@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/react-swipeable/-/react-swipeable-7.0.2.tgz#ef8858096a47144ba7060675af1cd672e00ecc12" + integrity sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w== + react-tabs@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-3.2.3.tgz#ccbb3e1241ad3f601047305c75db661239977f2f"