diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/hotelList.module.css b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/hotelList.module.css new file mode 100644 index 000000000..589b8c717 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/hotelList.module.css @@ -0,0 +1,17 @@ +.hotelListWrapper { + display: grid; + gap: var(--Spacing-x3); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--Spacing-x2); +} + +.hotelList { + display: grid; + gap: var(--Spacing-x3); + list-style: none; +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx new file mode 100644 index 000000000..094ab8d47 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/index.tsx @@ -0,0 +1,69 @@ +"use client" + +import { useMap, useMapsLibrary } from "@vis.gl/react-google-maps" +import { useEffect, useMemo, useState } from "react" +import { useIntl } from "react-intl" + +import Body from "@/components/TempDesignSystem/Text/Body" +import { debounce } from "@/utils/debounce" + +import HotelListItem from "../HotelListItem" +import { getVisibleHotels } from "./utils" + +import styles from "./hotelList.module.css" + +import type { HotelDataWithUrl } from "@/types/hotel" + +interface HotelListProps { + hotels: HotelDataWithUrl[] +} + +export default function HotelList({ hotels }: HotelListProps) { + const intl = useIntl() + const map = useMap() + const coreLib = useMapsLibrary("core") + const [visibleHotels, setVisibleHotels] = useState([]) + + const debouncedUpdateVisibleHotels = useMemo( + () => + debounce(() => { + setVisibleHotels(getVisibleHotels(hotels, map)) + }, 500), + [map, hotels] + ) + + useEffect(() => { + if (!map || !coreLib) { + return + } + + function handleBoundsChanged() { + debouncedUpdateVisibleHotels() + } + + coreLib.event.addListener(map, "bounds_changed", handleBoundsChanged) + return () => { + coreLib.event.clearListeners(map, "bounds_changed") + } + }, [map, coreLib, debouncedUpdateVisibleHotels]) + + return ( +
+
+ + {intl.formatMessage( + { id: "{count} hotels" }, + { count: visibleHotels.length } + )} + +
+ +
+ ) +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts new file mode 100644 index 000000000..88d7841b9 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelList/utils.ts @@ -0,0 +1,17 @@ +import type { HotelDataWithUrl } from "@/types/hotel" + +export function getVisibleHotels( + hotels: HotelDataWithUrl[], + map: google.maps.Map | null +) { + const bounds = map?.getBounds() + if (!bounds) { + return [] + } + + const visibleHotels = hotels.filter(({ hotel }) => { + const { latitude, longitude } = hotel.location + return bounds.contains({ lat: latitude, lng: longitude }) + }) + return visibleHotels +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/hotelListItem.module.css b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/hotelListItem.module.css new file mode 100644 index 000000000..43e7890ef --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/hotelListItem.module.css @@ -0,0 +1,43 @@ +.hotelListItem { + background-color: var(--Base-Surface-Primary-light-Normal); + border: 1px solid var(--Base-Border-Subtle); + border-radius: var(--Corner-radius-Medium); + overflow: hidden; +} + +.content { + display: grid; + gap: var(--Spacing-x2); + padding: var(--Spacing-x2) var(--Spacing-x3); + align-content: start; + justify-items: start; +} + +.intro { + display: grid; + gap: var(--Spacing-x-half); +} + +.captions { + display: flex; + gap: var(--Spacing-x1); +} + +.amenityList { + display: flex; + gap: var(--Spacing-x-one-and-half); + flex-wrap: wrap; + color: var(--UI-Text-Medium-contrast); + font-family: var(--typography-Body-Regular-fontFamily); + font-size: var(--typography-Caption-Underline-fontSize); +} + +.amenityItem { + display: flex; + gap: var(--Spacing-x-half); + align-items: center; +} + +.ctaWrapper { + justify-self: stretch; +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx new file mode 100644 index 000000000..f47590708 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/HotelListItem/index.tsx @@ -0,0 +1,87 @@ +"use client" + +import Link from "next/link" +import { useIntl } from "react-intl" + +import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" +import HotelLogo from "@/components/Icons/Logos" +import ImageGallery from "@/components/ImageGallery" +import Button from "@/components/TempDesignSystem/Button" +import Divider from "@/components/TempDesignSystem/Divider" +import Caption from "@/components/TempDesignSystem/Text/Caption" +import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" +import { mapApiImagesToGalleryImages } from "@/utils/imageGallery" +import { getSingleDecimal } from "@/utils/numberFormatting" + +import styles from "./hotelListItem.module.css" + +import type { Hotel } from "@/types/hotel" + +interface HotelListItemProps { + hotel: Hotel + url: string | null +} + +export default function HotelListItem({ hotel, url }: HotelListItemProps) { + const intl = useIntl() + const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) + const amenities = hotel.detailedFacilities.slice(0, 5) + + return ( +
+ +
+
+ + +

{hotel.name}

+
+
+ + {hotel.address.streetAddress} + + + + {intl.formatMessage( + { id: "{number} km to city center" }, + { + number: getSingleDecimal( + hotel.location.distanceToCentre / 1000 + ), + } + )} + +
+
+
    + {amenities.map((amenity) => { + const IconComponent = mapFacilityToIcon(amenity.id) + return ( +
  • + {IconComponent && ( + + )} + {amenity.name} +
  • + ) + })} +
+ {url && ( +
+ +
+ )} +
+
+ ) +} diff --git a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx index b90bff790..a1cbb6916 100644 --- a/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCityPage/CityMap/index.tsx @@ -2,8 +2,10 @@ import { env } from "@/env/server" import { getHotelsByCityIdentifier } from "@/lib/trpc/memoizedRequests" import Title from "@/components/TempDesignSystem/Text/Title" +import { getIntl } from "@/i18n" import Map from "../../Map" +import HotelList from "./HotelList" import type { CityLocation } from "@/types/trpc/routers/hotel/locations" @@ -15,6 +17,7 @@ export function preloadHotels(cityIdentifier: string) { void getHotelsByCityIdentifier(cityIdentifier) } export default async function CityMap({ city, cityIdentifier }: CityMapProps) { + const intl = await getIntl() const hotels = await getHotelsByCityIdentifier(cityIdentifier) return ( @@ -23,11 +26,10 @@ export default async function CityMap({ city, cityIdentifier }: CityMapProps) { mapId={env.GOOGLE_DYNAMIC_MAP_ID} apiKey={env.GOOGLE_STATIC_MAP_KEY} > -
- - {city.name} - -
+ + {intl.formatMessage({ id: `Hotels in {city}` }, { city: city.name })} + + ) } diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 0ccc54178..dff710e7d 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -258,6 +258,7 @@ "Hotel surroundings": "Hotel surroundings", "Hotels": "Hotels", "Hotels & Destinations": "Hotels & Destinations", + "Hotels in {city}": "Hotels in {city}", "Hours": "Hours", "How do you want to sleep?": "How do you want to sleep?", "How it works": "How it works", @@ -701,6 +702,7 @@ "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {{count} Hotel} other {{count} Hotels}}", "{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {{count} Location} other {{count} Locations}}", "{count} destinations": "{count} destinations", + "{count} hotels": "{count} hotels", "{count} lowercase letter": "{count} lowercase letter", "{count} number": "{count} number", "{count} special character": "{count} special character",