diff --git a/components/Blocks/HotelListing/index.tsx b/components/Blocks/HotelListing/index.tsx index b005aefb0..1c4694149 100644 --- a/components/Blocks/HotelListing/index.tsx +++ b/components/Blocks/HotelListing/index.tsx @@ -1,4 +1,4 @@ -import { getHotels } from "@/lib/trpc/memoizedRequests" +import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests" import SectionContainer from "@/components/Section/Container" import Title from "@/components/TempDesignSystem/Text/Title" @@ -13,7 +13,7 @@ export default async function HotelListing({ hotelsToInclude, contentType, }: HotelListingProps) { - const hotels = await getHotels({ + const hotels = await getHotelsByCSFilter({ locationFilter, hotelsToInclude: hotelsToInclude, }) @@ -27,7 +27,7 @@ export default async function HotelListing({ {heading} - {hotels.map(({ data, url }) => ( + {hotels.map(({ url, ...data }) => ( +
+ + {country} + +
+ + ) +} diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/map.module.css b/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/map.module.css new file mode 100644 index 000000000..d85a576d1 --- /dev/null +++ b/components/ContentType/DestinationPage/DestinationCountryPage/CountryMap/map.module.css @@ -0,0 +1,23 @@ +.countryMap { + --destination-map-height: 100dvh; + + position: absolute; + top: 0; + left: 0; + height: var(--destination-map-height); + width: 100dvw; + z-index: var(--hotel-dynamic-map-z-index); + display: flex; + background-color: var(--Base-Surface-Primary-light-Normal); +} +.wrapper { + position: absolute; + top: 0; + left: 0; +} + +.closeButton { + pointer-events: initial; + box-shadow: var(--button-box-shadow); + gap: var(--Spacing-x-half); +} diff --git a/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx b/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx index 92b321150..4dacc76ed 100644 --- a/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx +++ b/components/ContentType/DestinationPage/DestinationCountryPage/index.tsx @@ -16,6 +16,7 @@ import SidebarContentWrapper from "../SidebarContentWrapper" import DestinationPageSidePeek from "../Sidepeek" import StaticMap from "../StaticMap" import TopImages from "../TopImages" +import CountryMap, { preloadHotels } from "./CountryMap" import styles from "./destinationCountryPage.module.css" @@ -41,6 +42,8 @@ export default async function DestinationCountryPage() { destination_settings, } = destinationCountryPage + preloadHotels(destination_settings.country) + return ( <>
@@ -72,6 +75,7 @@ export default async function DestinationCountryPage() {
+ diff --git a/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css b/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css new file mode 100644 index 000000000..baedbf86d --- /dev/null +++ b/components/ContentType/DestinationPage/Map/DynamicMap/dynamicMap.module.css @@ -0,0 +1,66 @@ +.mapWrapper { + --button-box-shadow: 0 0 8px 1px rgba(0, 0, 0, 0.1); + width: 100%; + height: 100%; + position: relative; + z-index: 0; +} + +.mapWrapper::after { + content: ""; + position: absolute; + top: 0; + right: 0; + background: linear-gradient( + 43deg, + rgba(172, 172, 172, 0) 57.66%, + rgba(0, 0, 0, 0.25) 92.45% + ); + width: 100%; + height: 100%; + pointer-events: none; +} + +.ctaButtons { + position: absolute; + top: var(--Spacing-x2); + right: var(--Spacing-x2); + z-index: 1; + display: flex; + flex-direction: column; + gap: var(--Spacing-x7); + align-items: flex-end; + pointer-events: none; +} + +.zoomButtons { + display: grid; + gap: var(--Spacing-x1); +} + +.closeButton { + pointer-events: initial; + box-shadow: var(--button-box-shadow); + gap: var(--Spacing-x-half); +} + +.zoomButton { + width: var(--Spacing-x5); + height: var(--Spacing-x5); + padding: 0; + pointer-events: initial; + box-shadow: var(--button-box-shadow); +} + +@media screen and (min-width: 768px) { + .ctaButtons { + top: var(--Spacing-x4); + right: var(--Spacing-x4); + bottom: var(--Spacing-x4); + justify-content: space-between; + } + + .zoomButtons { + display: flex; + } +} diff --git a/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx b/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx new file mode 100644 index 000000000..bf8019538 --- /dev/null +++ b/components/ContentType/DestinationPage/Map/DynamicMap/index.tsx @@ -0,0 +1,119 @@ +"use client" + +import "client-only" + +import { Map, type MapProps, useMap } from "@vis.gl/react-google-maps" +import { type PropsWithChildren, useEffect } from "react" +import { useIntl } from "react-intl" + +import { CloseLargeIcon, MinusIcon, PlusIcon } from "@/components/Icons" +import Button from "@/components/TempDesignSystem/Button" +import { useHandleKeyUp } from "@/hooks/useHandleKeyUp" + +import styles from "./dynamicMap.module.css" + +import type { DestinationMarker } from "@/types/components/maps/destinationMarkers" + +interface DynamicMapProps { + markers: DestinationMarker[] + mapId: string + onTilesLoaded?: () => void + onClose?: () => void +} + +export default function DynamicMap({ + markers, + mapId, + onTilesLoaded, + onClose, + children, +}: PropsWithChildren) { + const intl = useIntl() + const map = useMap() + + useEffect(() => { + if (map) { + const bounds = new google.maps.LatLngBounds() + markers.forEach((marker) => { + bounds.extend(marker.coordinates) + }) + map.fitBounds(bounds) + } + }, [map, markers]) + + useHandleKeyUp((event: KeyboardEvent) => { + if (event.key === "Escape" && onClose) { + onClose() + } + }) + + function zoomIn() { + const currentZoom = map && map.getZoom() + console.log(currentZoom) + if (currentZoom) { + map.setZoom(currentZoom + 1) + } + } + function zoomOut() { + const currentZoom = map && map.getZoom() + console.log(currentZoom) + if (currentZoom) { + map.setZoom(currentZoom - 1) + } + } + + const mapOptions: MapProps = { + defaultCenter: markers[0].coordinates, // Default center will be overridden by the bounds + minZoom: 3, + defaultZoom: 8, + disableDefaultUI: true, + clickableIcons: false, + gestureHandling: "greedy", + mapId, + } + + return ( +
+ + {children} + +
+ +
+ + +
+
+
+ ) +} diff --git a/components/ContentType/DestinationPage/Map/MapContent/index.tsx b/components/ContentType/DestinationPage/Map/MapContent/index.tsx new file mode 100644 index 000000000..e867565c1 --- /dev/null +++ b/components/ContentType/DestinationPage/Map/MapContent/index.tsx @@ -0,0 +1,29 @@ +"use client" + +import { + AdvancedMarker, + AdvancedMarkerAnchorPoint, +} from "@vis.gl/react-google-maps" + +import HotelMarkerByType from "@/components/Maps/Markers" + +import styles from "./mapContent.module.css" + +import type { DestinationMarker } from "@/types/components/maps/destinationMarkers" + +interface MapContentProps { + markers: DestinationMarker[] +} + +export default function MapContent({ markers }: MapContentProps) { + return markers.map((item) => ( + + + + )) +} diff --git a/components/ContentType/DestinationPage/Map/MapContent/mapContent.module.css b/components/ContentType/DestinationPage/Map/MapContent/mapContent.module.css new file mode 100644 index 000000000..e69de29bb diff --git a/components/ContentType/DestinationPage/Map/MapProvider.tsx b/components/ContentType/DestinationPage/Map/MapProvider.tsx new file mode 100644 index 000000000..e3ce4dcd6 --- /dev/null +++ b/components/ContentType/DestinationPage/Map/MapProvider.tsx @@ -0,0 +1,15 @@ +"use client" +import { APIProvider } from "@vis.gl/react-google-maps" + +import type { PropsWithChildren } from "react" + +interface MapContainerProps { + apiKey: string +} + +export default function MapProvider({ + apiKey, + children, +}: PropsWithChildren) { + return {children} +} diff --git a/components/ContentType/DestinationPage/Map/index.tsx b/components/ContentType/DestinationPage/Map/index.tsx new file mode 100644 index 000000000..f8fe57641 --- /dev/null +++ b/components/ContentType/DestinationPage/Map/index.tsx @@ -0,0 +1,129 @@ +"use client" + +import { useSearchParams } from "next/navigation" +import { + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { Dialog, Modal } from "react-aria-components" + +import { debounce } from "@/utils/debounce" + +import DynamicMap from "./DynamicMap" +import MapContent from "./MapContent" +import MapProvider from "./MapProvider" + +import styles from "./map.module.css" + +import type { DestinationMarker } from "@/types/components/maps/destinationMarkers" +import type { HotelDataWithUrl } from "@/types/hotel" + +interface MapProps { + hotels: HotelDataWithUrl[] + mapId: string + apiKey: string +} + +export default function Map({ + hotels, + mapId, + apiKey, + children, +}: PropsWithChildren) { + const searchParams = useSearchParams() + const isMapView = useMemo( + () => searchParams.get("view") === "map", + [searchParams] + ) + const rootDiv = useRef(null) + const [mapHeight, setMapHeight] = useState("0px") + const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0) + + const markers = hotels + .map(({ hotel }) => ({ + id: hotel.id, + type: hotel.hotelType || "regular", + name: hotel.name, + coordinates: hotel.location + ? { + lat: hotel.location.latitude, + lng: hotel.location.longitude, + } + : null, + })) + .filter((item): item is DestinationMarker => !!item.coordinates) + + // Calculate the height of the map based on the viewport height from the start-point (below the header and booking widget) + const handleMapHeight = useCallback(() => { + const topPosition = rootDiv.current?.getBoundingClientRect().top ?? 0 + const scrollY = window.scrollY + setMapHeight(`calc(100dvh - ${topPosition + scrollY}px)`) + }, []) + + // Making sure the map is always opened at the top of the page, + // just below the header and booking widget as these should stay visible. + // When closing, the page should scroll back to the position it was before opening the map. + useEffect(() => { + // Skip the first render + if (!rootDiv.current) { + return + } + + if (isMapView && scrollHeightWhenOpened === 0) { + const scrollY = window.scrollY + setScrollHeightWhenOpened(scrollY) + window.scrollTo({ top: 0, behavior: "instant" }) + } else if (!isMapView && scrollHeightWhenOpened !== 0) { + window.scrollTo({ top: scrollHeightWhenOpened, behavior: "instant" }) + setScrollHeightWhenOpened(0) + } + }, [isMapView, scrollHeightWhenOpened, rootDiv]) + + useEffect(() => { + const debouncedResizeHandler = debounce(function () { + handleMapHeight() + }) + + const observer = new ResizeObserver(debouncedResizeHandler) + + observer.observe(document.documentElement) + + return () => { + if (observer) { + observer.unobserve(document.documentElement) + } + } + }, [rootDiv, isMapView, handleMapHeight]) + + function handleClose() { + window.history.pushState({}, "", window.location.pathname) + } + + return ( + +
+ + + + + + + + +
+
+ ) +} diff --git a/components/ContentType/DestinationPage/Map/map.module.css b/components/ContentType/DestinationPage/Map/map.module.css new file mode 100644 index 000000000..b0bde35a3 --- /dev/null +++ b/components/ContentType/DestinationPage/Map/map.module.css @@ -0,0 +1,35 @@ +.dialog { + --destination-map-height: 100dvh; + + position: absolute; + top: 0; + left: 0; + height: var(--destination-map-height); + width: 100dvw; + z-index: var(--hotel-dynamic-map-z-index); + display: flex; + background-color: var(--Base-Surface-Primary-light-Normal); +} + +.sidebar { + width: 100%; + max-width: 400px; + background-color: var(--Base-Surface-Primary-Normal); + overflow-y: auto; + padding: var(--Spacing-x4); + display: flex; + flex-direction: column; + gap: var(--Spacing-x4); +} + +.wrapper { + position: absolute; + top: 0; + left: 0; +} + +.closeButton { + pointer-events: initial; + box-shadow: var(--button-box-shadow); + gap: var(--Spacing-x-half); +} diff --git a/components/ContentType/DestinationPage/StaticMap/index.tsx b/components/ContentType/DestinationPage/StaticMap/index.tsx index fafdd3669..243f1c7e4 100644 --- a/components/ContentType/DestinationPage/StaticMap/index.tsx +++ b/components/ContentType/DestinationPage/StaticMap/index.tsx @@ -1,3 +1,5 @@ +import Link from "next/link" + import { MapIcon } from "@/components/Icons" import StaticMap from "@/components/Maps/StaticMap" import Button from "@/components/TempDesignSystem/Button" @@ -36,10 +38,12 @@ export default async function DestinationStaticMap({ size="small" theme="base" className={styles.button} + asChild > - {/* TODO: Decide on how the map should load */} - - {intl.formatMessage({ id: "See on map" })} + + + {intl.formatMessage({ id: "See on map" })} + ) diff --git a/components/Maps/Markers/DowntownCamper.tsx b/components/Maps/Markers/DowntownCamper.tsx new file mode 100644 index 000000000..76e045c25 --- /dev/null +++ b/components/Maps/Markers/DowntownCamper.tsx @@ -0,0 +1,123 @@ +export default function DowntownCamperMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/DowntownCamperSmall.tsx b/components/Maps/Markers/DowntownCamperSmall.tsx new file mode 100644 index 000000000..cb7989a9f --- /dev/null +++ b/components/Maps/Markers/DowntownCamperSmall.tsx @@ -0,0 +1,78 @@ +export default function DowntownCamperSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/GrandHotel.tsx b/components/Maps/Markers/GrandHotel.tsx new file mode 100644 index 000000000..e3f7125ac --- /dev/null +++ b/components/Maps/Markers/GrandHotel.tsx @@ -0,0 +1,173 @@ +export default function GrandHotelMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/GrandHotelSmall.tsx b/components/Maps/Markers/GrandHotelSmall.tsx new file mode 100644 index 000000000..8da9c8c92 --- /dev/null +++ b/components/Maps/Markers/GrandHotelSmall.tsx @@ -0,0 +1,128 @@ +export default function GrandHotelSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/Haymarket.tsx b/components/Maps/Markers/Haymarket.tsx new file mode 100644 index 000000000..aaca7b723 --- /dev/null +++ b/components/Maps/Markers/Haymarket.tsx @@ -0,0 +1,121 @@ +export default function HaymarketMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/HaymarketSmall.tsx b/components/Maps/Markers/HaymarketSmall.tsx new file mode 100644 index 000000000..f15730e59 --- /dev/null +++ b/components/Maps/Markers/HaymarketSmall.tsx @@ -0,0 +1,76 @@ +export default function HaymarketSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/HotelNorge.tsx b/components/Maps/Markers/HotelNorge.tsx new file mode 100644 index 000000000..1820bee12 --- /dev/null +++ b/components/Maps/Markers/HotelNorge.tsx @@ -0,0 +1,125 @@ +export default function HotelNorgeMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/HotelNorgeSmall.tsx b/components/Maps/Markers/HotelNorgeSmall.tsx new file mode 100644 index 000000000..9ac8f719b --- /dev/null +++ b/components/Maps/Markers/HotelNorgeSmall.tsx @@ -0,0 +1,80 @@ +export default function HotelNorgeSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/Marski.tsx b/components/Maps/Markers/Marski.tsx new file mode 100644 index 000000000..0dd7b51a3 --- /dev/null +++ b/components/Maps/Markers/Marski.tsx @@ -0,0 +1,141 @@ +export default function MarskiMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/MarskiSmall.tsx b/components/Maps/Markers/MarskiSmall.tsx new file mode 100644 index 000000000..c11160b86 --- /dev/null +++ b/components/Maps/Markers/MarskiSmall.tsx @@ -0,0 +1,96 @@ +export default function MarskiSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/ScandicGo.tsx b/components/Maps/Markers/ScandicGo.tsx new file mode 100644 index 000000000..6ed53afa9 --- /dev/null +++ b/components/Maps/Markers/ScandicGo.tsx @@ -0,0 +1,123 @@ +export default function ScandicGoMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/ScandicGoSmall.tsx b/components/Maps/Markers/ScandicGoSmall.tsx new file mode 100644 index 000000000..9a9c59a11 --- /dev/null +++ b/components/Maps/Markers/ScandicGoSmall.tsx @@ -0,0 +1,78 @@ +export default function ScandicGoSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/ScandicSmall.tsx b/components/Maps/Markers/ScandicSmall.tsx new file mode 100644 index 000000000..034013926 --- /dev/null +++ b/components/Maps/Markers/ScandicSmall.tsx @@ -0,0 +1,112 @@ +export default function ScandicSmallMarker({ + className, + ...props +}: React.SVGAttributes) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/components/Maps/Markers/index.tsx b/components/Maps/Markers/index.tsx new file mode 100644 index 000000000..587b06870 --- /dev/null +++ b/components/Maps/Markers/index.tsx @@ -0,0 +1,52 @@ +import DowntownCamperMarker from "./DowntownCamper" +import DowntownCamperSmallMarker from "./DowntownCamperSmall" +import GrandHotelMarker from "./GrandHotel" +import GrandHotelSmallMarker from "./GrandHotelSmall" +import HaymarketMarker from "./Haymarket" +import HaymarketSmallMarker from "./HaymarketSmall" +import HotelNorgeMarker from "./HotelNorge" +import HotelNorgeSmallMarker from "./HotelNorgeSmall" +import MarskiMarker from "./Marski" +import MarskiSmallMarker from "./MarskiSmall" +import ScandicMarker from "./Scandic" +import ScandicGoMarker from "./ScandicGo" +import ScandicGoSmallMarker from "./ScandicGoSmall" +import ScandicSmallMarker from "./ScandicSmall" + +import { HotelTypeEnum } from "@/types/enums/hotelType" +import { SignatureHotelEnum } from "@/types/enums/signatureHotel" + +interface HotelMarkerByTypeProps { + hotelId: string + hotelType: string + smallSize?: boolean +} + +export default function HotelMarkerByType({ + hotelId, + hotelType, + smallSize = true, +}: HotelMarkerByTypeProps) { + if (hotelType === HotelTypeEnum.ScandicGo) { + return smallSize ? : + } + + switch (hotelId) { + case SignatureHotelEnum.Haymarket: + return smallSize ? : + case SignatureHotelEnum.HotelNorge: + return smallSize ? : + case SignatureHotelEnum.DowntownCamper: + return smallSize ? ( + + ) : ( + + ) + case SignatureHotelEnum.GrandHotelOslo: + return smallSize ? : + case SignatureHotelEnum.Marski: + return smallSize ? : + default: + return smallSize ? : + } +} diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index f4495da21..336fe6818 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -13,7 +13,7 @@ import type { } from "@/types/trpc/routers/hotel/hotel" import type { Lang } from "@/constants/languages" import type { - GetHotelsInput, + GetHotelsByCSFilterInput, GetRoomsAvailabilityInput, GetSelectedRoomAvailabilityInput, } from "@/server/routers/hotels/input" @@ -67,10 +67,10 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() { return serverClient().user.tracking() }) -export const getHotels = cache(async function getMemoizedHotels( - input: GetHotelsInput +export const getHotelsByCSFilter = cache(async function getMemoizedHotels( + input: GetHotelsByCSFilterInput ) { - return serverClient().hotel.hotels.get(input) + return serverClient().hotel.hotels.byCSFilter.get(input) }) export const getHotel = cache(async function getMemoizedHotelData( @@ -206,6 +206,13 @@ export const getDestinationCityPagesByCountry = cache( }) } ) +export const getHotelsByCountry = cache( + async function getMemoizedHotelsByCountry(country: Country) { + return serverClient().hotel.hotels.byCountry.get({ + country, + }) + } +) export const getDestinationCityPage = cache( async function getMemoizedDestinationCityPage() { return serverClient().contentstack.destinationCityPage.get() diff --git a/server/routers/contentstack/destinationOverviewPage/query.ts b/server/routers/contentstack/destinationOverviewPage/query.ts index d2a8b6ba8..809d80b96 100644 --- a/server/routers/contentstack/destinationOverviewPage/query.ts +++ b/server/routers/contentstack/destinationOverviewPage/query.ts @@ -244,7 +244,6 @@ export const destinationOverviewPageQueryRouter = router({ const hotelIdsParams = new URLSearchParams({ language: apiLang, city: city.id, - onlyBasicInfo: "true", }) const hotels = await getHotelIdsByCityId( diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index 489d6842b..7ed3f649d 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -63,7 +63,7 @@ export const hotelInputSchema = z.object({ language: z.nativeEnum(Lang), }) -export const getHotelsInput = z.object({ +export const getHotelsByCSFilterInput = z.object({ locationFilter: z .object({ city: z.string().nullable(), @@ -73,7 +73,8 @@ export const getHotelsInput = z.object({ .nullable(), hotelsToInclude: z.array(z.string()), }) -export interface GetHotelsInput extends z.infer {} +export interface GetHotelsByCSFilterInput + extends z.infer {} export const nearbyHotelIdsInput = z.object({ hotelId: z.string(), @@ -116,3 +117,7 @@ export const getAdditionalDataInputSchema = z.object({ hotelId: z.string(), language: z.string(), }) + +export const getHotelsByCountryInput = z.object({ + country: z.nativeEnum(Country), +}) diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 43c0d996d..7f2cc877d 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -24,8 +24,9 @@ import { breakfastPackageInputSchema, cityCoordinatesInputSchema, getAdditionalDataInputSchema, + getHotelsByCountryInput, + getHotelsByCSFilterInput, getHotelsByHotelIdsAvailabilityInputSchema, - getHotelsInput, getMeetingRoomsInputSchema, hotelInputSchema, hotelsAvailabilityInputSchema, @@ -59,8 +60,7 @@ import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHote import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { HotelTypeEnum } from "@/types/enums/hotelType" import type { RequestOptionsWithOutBody } from "@/types/fetch" -import type { HotelData } from "@/types/hotel" -import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage" +import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { CityLocation } from "@/types/trpc/routers/hotel/locations" @@ -833,207 +833,251 @@ export const hotelQueryRouter = router({ return getHotel(input, ctx.serviceToken) }), hotels: router({ - get: contentStackBaseWithServiceProcedure - .input(getHotelsInput) - .query(async function ({ ctx, input }) { - const { locationFilter, hotelsToInclude } = input + byCountry: router({ + get: contentStackBaseWithServiceProcedure + .input(getHotelsByCountryInput) + .query(async ({ ctx, input }) => { + const { lang, serviceToken } = ctx + const { country } = input - const language = ctx.lang - const apiLang = toApiLang(language) - const options: RequestOptionsWithOutBody = { - // needs to clear default option as only - // cache or next.revalidate is permitted - cache: undefined, - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - next: { - revalidate: env.CACHE_TIME_HOTELS, - }, - } - - let hotelsToFetch: string[] = [] - - metrics.hotels.counter.add(1, { - input: JSON.stringify(input), - language, - }) - console.info( - "api.hotel.hotels start", - JSON.stringify({ - query: { - ...input, - language, + const options: RequestOptionsWithOutBody = { + // needs to clear default option as only + // cache or next.revalidate is permitted + cache: undefined, + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + next: { + revalidate: env.CACHE_TIME_HOTELS, }, - }) - ) - - if (hotelsToInclude.length) { - hotelsToFetch = hotelsToInclude - } else if (locationFilter?.city) { - const locationsParams = new URLSearchParams({ - language: apiLang, - }) - const locations = await getLocations( - language, - options, - locationsParams, - null - ) - if (!locations || "error" in locations) { - return [] } - - const cityId = locations - .filter( - (loc): loc is CityLocation => - "type" in loc && loc.type === "cities" - ) - .find((loc) => loc.cityIdentifier === locationFilter.city)?.id - - if (!cityId) { - metrics.hotels.fail.add(1, { - input: JSON.stringify(input), - language, - error_type: "not_found", - error: `CityId not found for cityIdentifier: ${locationFilter.city}`, - }) - - console.error( - "api.hotel.hotels not found error", - JSON.stringify({ - query: { ...input, language }, - error: `CityId not found for cityIdentifier: ${locationFilter.city}`, - }) - ) - return [] - } - const hotelIdsParams = new URLSearchParams({ - language: apiLang, - city: cityId, - onlyBasicInfo: "true", - }) - const hotelIds = await getHotelIdsByCityId( - cityId, - options, - hotelIdsParams - ) - - if (!hotelIds?.length) { - metrics.hotels.fail.add(1, { - cityId, - language, - error_type: "not_found", - error: `No hotelIds found for cityId: ${cityId}`, - }) - - console.error( - "api.hotel.hotels not found error", - JSON.stringify({ - query: { cityId, language }, - error: `No hotelIds found for cityId: ${cityId}`, - }) - ) - return [] - } - - const filteredHotelIds = hotelIds.filter( - (id) => !locationFilter.excluded.includes(id) - ) - - hotelsToFetch = filteredHotelIds - } else if (locationFilter?.country) { const hotelIdsParams = new URLSearchParams({ language: ApiLang.En, - country: locationFilter.country, - onlyBasicInfo: "true", + country, }) const hotelIds = await getHotelIdsByCountry( - locationFilter.country, + country, options, hotelIdsParams ) - if (!hotelIds?.length) { - metrics.hotels.fail.add(1, { + const hotels = await Promise.all( + hotelIds.map(async (hotelId) => { + const [hotelData, url] = await Promise.all([ + getHotel( + { hotelId, isCardOnlyPayment: false, language: lang }, + ctx.serviceToken + ), + getHotelPageUrl(lang, hotelId), + ]) + + return hotelData ? { ...hotelData, url } : null + }) + ) + + return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) + }), + }), + byCSFilter: router({ + get: contentStackBaseWithServiceProcedure + .input(getHotelsByCSFilterInput) + .query(async function ({ ctx, input }) { + const { locationFilter, hotelsToInclude } = input + + const language = ctx.lang + const apiLang = toApiLang(language) + const options: RequestOptionsWithOutBody = { + // needs to clear default option as only + // cache or next.revalidate is permitted + cache: undefined, + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + next: { + revalidate: env.CACHE_TIME_HOTELS, + }, + } + + let hotelsToFetch: string[] = [] + + metrics.hotels.counter.add(1, { + input: JSON.stringify(input), + language, + }) + console.info( + "api.hotel.hotels start", + JSON.stringify({ + query: { + ...input, + language, + }, + }) + ) + + if (hotelsToInclude.length) { + hotelsToFetch = hotelsToInclude + } else if (locationFilter?.city) { + const locationsParams = new URLSearchParams({ + language: apiLang, + }) + const locations = await getLocations( + language, + options, + locationsParams, + null + ) + if (!locations || "error" in locations) { + return [] + } + + const cityId = locations + .filter( + (loc): loc is CityLocation => + "type" in loc && loc.type === "cities" + ) + .find((loc) => loc.cityIdentifier === locationFilter.city)?.id + + if (!cityId) { + metrics.hotels.fail.add(1, { + input: JSON.stringify(input), + language, + error_type: "not_found", + error: `CityId not found for cityIdentifier: ${locationFilter.city}`, + }) + + console.error( + "api.hotel.hotels not found error", + JSON.stringify({ + query: { ...input, language }, + error: `CityId not found for cityIdentifier: ${locationFilter.city}`, + }) + ) + return [] + } + const hotelIdsParams = new URLSearchParams({ + language: apiLang, + city: cityId, + }) + const hotelIds = await getHotelIdsByCityId( + cityId, + options, + hotelIdsParams + ) + + if (!hotelIds?.length) { + metrics.hotels.fail.add(1, { + cityId, + language, + error_type: "not_found", + error: `No hotelIds found for cityId: ${cityId}`, + }) + + console.error( + "api.hotel.hotels not found error", + JSON.stringify({ + query: { cityId, language }, + error: `No hotelIds found for cityId: ${cityId}`, + }) + ) + return [] + } + + const filteredHotelIds = hotelIds.filter( + (id) => !locationFilter.excluded.includes(id) + ) + + hotelsToFetch = filteredHotelIds + } else if (locationFilter?.country) { + const hotelIdsParams = new URLSearchParams({ + language: ApiLang.En, country: locationFilter.country, + }) + const hotelIds = await getHotelIdsByCountry( + locationFilter.country, + options, + hotelIdsParams + ) + + if (!hotelIds?.length) { + metrics.hotels.fail.add(1, { + country: locationFilter.country, + language, + error_type: "not_found", + error: `No hotelIds found for country: ${locationFilter.country}`, + }) + + console.error( + "api.hotel.hotels not found error", + JSON.stringify({ + query: { country: locationFilter.country, language }, + error: `No hotelIds found for cityId: ${locationFilter.country}`, + }) + ) + return [] + } + + const filteredHotelIds = hotelIds.filter( + (id) => !locationFilter.excluded.includes(id) + ) + + hotelsToFetch = filteredHotelIds + } + + if (!hotelsToFetch.length) { + metrics.hotels.fail.add(1, { + input: JSON.stringify(input), language, error_type: "not_found", - error: `No hotelIds found for country: ${locationFilter.country}`, + error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, }) console.error( "api.hotel.hotels not found error", JSON.stringify({ - query: { country: locationFilter.country, language }, - error: `No hotelIds found for cityId: ${locationFilter.country}`, + query: JSON.stringify(input), + error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, }) ) return [] } - const filteredHotelIds = hotelIds.filter( - (id) => !locationFilter.excluded.includes(id) - ) + const hotels = await Promise.all( + hotelsToFetch.map(async (hotelId) => { + const [hotelData, url] = await Promise.all([ + getHotel( + { hotelId, isCardOnlyPayment: false, language }, + ctx.serviceToken + ), + getHotelPageUrl(language, hotelId), + ]) - hotelsToFetch = filteredHotelIds - } - - if (!hotelsToFetch.length) { - metrics.hotels.fail.add(1, { - input: JSON.stringify(input), - language, - error_type: "not_found", - error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, - }) - - console.error( - "api.hotel.hotels not found error", - JSON.stringify({ - query: JSON.stringify(input), - error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`, + return hotelData + ? { + ...hotelData, + url, + } + : null }) ) - return [] - } - const hotels = await Promise.all( - hotelsToFetch.map(async (hotelId) => { - const [hotelData, url] = await Promise.all([ - getHotel( - { hotelId, isCardOnlyPayment: false, language }, - ctx.serviceToken - ), - getHotelPageUrl(language, hotelId), - ]) - - return { - data: hotelData, - url, - } + metrics.hotels.success.add(1, { + input: JSON.stringify(input), + language, }) - ) - metrics.hotels.success.add(1, { - input: JSON.stringify(input), - language, - }) + console.info( + "api.hotels success", + JSON.stringify({ + query: { + input: JSON.stringify(input), + language, + }, + }) + ) - console.info( - "api.hotels success", - JSON.stringify({ - query: { - input: JSON.stringify(input), - language, - }, - }) - ) - - return hotels.filter( - (hotel): hotel is { data: HotelData; url: HotelPageUrl } => - !!hotel.data - ) - }), + return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) + }), + }), }), nearbyHotelIds: serviceProcedure .input(nearbyHotelIdsInput) diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts index f638f9220..a17bb5529 100644 --- a/server/routers/hotels/utils.ts +++ b/server/routers/hotels/utils.ts @@ -432,7 +432,6 @@ export async function getHotelIdsByCityIdentifier( const hotelIdsParams = new URLSearchParams({ language: apiLang, city: cityId, - onlyBasicInfo: "true", }) const options: RequestOptionsWithOutBody = { // needs to clear default option as only diff --git a/types/components/maps/destinationMarkers.ts b/types/components/maps/destinationMarkers.ts new file mode 100644 index 000000000..279d29d97 --- /dev/null +++ b/types/components/maps/destinationMarkers.ts @@ -0,0 +1,6 @@ +export interface DestinationMarker { + id: string + type: string + name: string + coordinates: google.maps.LatLngLiteral +} diff --git a/types/hotel.ts b/types/hotel.ts index fab71c413..63027d822 100644 --- a/types/hotel.ts +++ b/types/hotel.ts @@ -68,3 +68,5 @@ export type HotelTripAdvisor = export type AdditionalData = ReturnType export type ExtraPageSchema = z.output + +export type HotelDataWithUrl = HotelData & { url: string }