From ef22fc462706e597ccaa253d30a2afdadeaee87c Mon Sep 17 00:00:00 2001 From: Niclas Edenvin Date: Tue, 28 Jan 2025 12:08:40 +0000 Subject: [PATCH] This creates the alternative hotels page. It is mostly a copy of the select hotel page, and most of the contents of the pages lives under the same component in /components. Merged in feat/sw-397-alternative-hotels (pull request #1211) Feat/sw 397 alternative hotels * fix(SW-397): create alternative hotels page * update types * Adapt to new changes for fetching data * Make bookingcode optional * Code review fixes Approved-by: Simon.Emanuelsson --- .../alternative-hotels/layout.module.css | 11 + .../(standard)/alternative-hotels/layout.tsx | 7 + .../alternative-hotels/map/page.module.css | 6 + .../alternative-hotels/map/page.tsx | 37 ++++ .../(standard)/alternative-hotels/page.tsx | 33 +++ .../(standard)/select-hotel/utils.ts | 40 +++- .../hotelreservation/(standard)/utils.ts | 80 +++++-- components/BookingWidget/Client.tsx | 2 +- .../MobileMapButtonContainer/index.tsx | 11 +- .../SelectHotel/NoAvailabilityAlert.tsx | 48 ++++ .../SelectHotelMapContainer.tsx | 55 +++-- .../HotelReservation/SelectHotel/index.tsx | 126 +++++++---- components/TempDesignSystem/Alert/alert.ts | 1 + components/TempDesignSystem/Alert/index.tsx | 7 +- constants/routes/hotelReservation.js | 14 ++ i18n/dictionaries/en.json | 3 + lib/api/endpoints.ts | 3 + server/routers/hotels/input.ts | 13 ++ server/routers/hotels/output.ts | 13 ++ server/routers/hotels/query.ts | 206 +++++++++++++++++- server/routers/hotels/telemetry.ts | 20 ++ .../selectHotel/availabilityInput.ts | 8 + .../hotelReservation/selectHotel/map.ts | 8 +- .../selectHotel/noAvailabilityAlert.ts | 7 + .../selectHotel/selectHotel.ts | 9 +- .../selectHotel/selectHotelSearchParams.ts | 8 +- types/trpc/routers/hotel/locations.ts | 10 +- utils/url.ts | 12 +- 28 files changed, 693 insertions(+), 105 deletions(-) create mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.module.css create mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.tsx create mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.module.css create mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.tsx create mode 100644 app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/page.tsx create mode 100644 components/HotelReservation/SelectHotel/NoAvailabilityAlert.tsx create mode 100644 types/components/hotelReservation/selectHotel/noAvailabilityAlert.ts diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.module.css new file mode 100644 index 000000000..f955dbffc --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.module.css @@ -0,0 +1,11 @@ +.layout { + min-height: 100dvh; + background-color: var(--Base-Background-Primary-Normal); + position: relative; +} + +@media screen and (min-width: 768px) { + .layout { + z-index: 0; + } +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.tsx new file mode 100644 index 000000000..b2723b81c --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/layout.tsx @@ -0,0 +1,7 @@ +import styles from "./layout.module.css" + +export default function HotelReservationLayout({ + children, +}: React.PropsWithChildren) { + return
{children}
+} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.module.css b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.module.css new file mode 100644 index 000000000..d0a692d8b --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.module.css @@ -0,0 +1,6 @@ +.main { + display: grid; + background-color: var(--Scandic-Brand-Warm-White); + min-height: 100dvh; + position: relative; +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.tsx new file mode 100644 index 000000000..3d32d124d --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/map/page.tsx @@ -0,0 +1,37 @@ +import { notFound } from "next/navigation" +import { Suspense } from "react" + +import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer" +import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton" +import { MapContainer } from "@/components/MapContainer" +import { setLang } from "@/i18n/serverContext" + +import { getHotelSearchDetails } from "../../utils" + +import styles from "./page.module.css" + +import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" +import type { LangParams, PageArgs } from "@/types/params" + +export default async function SelectHotelMapPage({ + params, + searchParams, +}: PageArgs) { + setLang(params.lang) + + return ( +
+ + } + > + + + +
+ ) +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/page.tsx new file mode 100644 index 000000000..f7461912b --- /dev/null +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/alternative-hotels/page.tsx @@ -0,0 +1,33 @@ +import { Suspense } from "react" + +import SelectHotel from "@/components/HotelReservation/SelectHotel" +import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton" +import { setLang } from "@/i18n/serverContext" + +import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" +import type { LangParams, PageArgs } from "@/types/params" + +export default async function AlternativeHotelsPage({ + params, + searchParams, +}: PageArgs) { + setLang(params.lang) + + const roomKey = Object.keys(searchParams) + .filter((key) => key.startsWith("room[")) + .map((key) => searchParams[key]) + .join("-") + + return ( + } + > + + + ) +} diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts index 7960d3314..92d36884c 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils.ts @@ -3,7 +3,10 @@ import { serverClient } from "@/lib/trpc/server" import { getLang } from "@/i18n/serverContext" -import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" +import type { + AlternativeHotelsAvailabilityInput, + AvailabilityInput, +} from "@/types/components/hotelReservation/selectHotel/availabilityInput" import type { HotelData, NullableHotelData, @@ -12,6 +15,7 @@ import type { CategorizedFilters, Filter, } from "@/types/components/hotelReservation/selectHotel/hotelFilters" +import type { HotelsAvailabilityItem } from "@/server/routers/hotels/output" const hotelSurroundingsFilterNames = [ "Hotel surroundings", @@ -34,13 +38,41 @@ const hotelFacilitiesFilterNames = [ export async function fetchAvailableHotels( input: AvailabilityInput ): Promise { - const availableHotels = await serverClient().hotel.availability.hotels(input) + const availableHotels = + await serverClient().hotel.availability.hotelsByCity(input) if (!availableHotels) return [] + return enhanceHotels(availableHotels) +} + +export async function fetchAlternativeHotels( + hotelId: string, + input: AlternativeHotelsAvailabilityInput +): Promise { + const alternativeHotelIds = await serverClient().hotel.nearbyHotelIds({ + hotelId, + }) + + if (!alternativeHotelIds) return [] + + const availableHotels = + await serverClient().hotel.availability.hotelsByHotelIds({ + ...input, + hotelIds: alternativeHotelIds, + }) + + if (!availableHotels) return [] + + return enhanceHotels(availableHotels) +} + +async function enhanceHotels(hotels: { + availability: HotelsAvailabilityItem[] +}) { const language = getLang() - const hotels = availableHotels.availability.map(async (hotel) => { + const hotelFetchers = hotels.availability.map(async (hotel) => { const hotelData = await getHotelData({ hotelId: hotel.hotelId.toString(), language, @@ -54,7 +86,7 @@ export async function fetchAvailableHotels( } }) - return await Promise.all(hotels) + return await Promise.all(hotelFetchers) } export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters { diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils.ts b/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils.ts index 92ddc303c..d4763b53d 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils.ts +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils.ts @@ -3,49 +3,81 @@ import { notFound } from "next/navigation" import { getLocations } from "@/lib/trpc/memoizedRequests" import { generateChildrenString } from "@/components/HotelReservation/utils" -import { convertSearchParamsToObj } from "@/utils/url" +import { convertSearchParamsToObj, type SelectHotelParams } from "@/utils/url" -import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" +import type { + AlternativeHotelsSearchParams, + SelectHotelSearchParams, +} from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { Child, SelectRateSearchParams, } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { Location } from "@/types/trpc/routers/hotel/locations" +import { + type HotelLocation, + isHotelLocation, + type Location, +} from "@/types/trpc/routers/hotel/locations" interface HotelSearchDetails { city: Location | null - hotel: Location | null - selectHotelParams: T + hotel: HotelLocation | null + selectHotelParams: SelectHotelParams & { city: string | undefined } adultsInRoom: number childrenInRoomString?: string childrenInRoom?: Child[] } export async function getHotelSearchDetails< - T extends SelectHotelSearchParams | SelectRateSearchParams, ->({ - searchParams, -}: { - searchParams: T & { - [key: string]: string - } -}): Promise | null> { + T extends + | SelectHotelSearchParams + | SelectRateSearchParams + | AlternativeHotelsSearchParams, +>( + { + searchParams, + }: { + searchParams: T & { + [key: string]: string + } + }, + isAlternativeHotels?: boolean +): Promise | null> { const selectHotelParams = convertSearchParamsToObj(searchParams) const locations = await getLocations() if (!locations || "error" in locations) return null - const city = locations.data.find( - (location) => - location.name.toLowerCase() === selectHotelParams.city?.toLowerCase() - ) - const hotel = locations.data.find( - (location) => - "operaId" in location && location.operaId == selectHotelParams.hotelId - ) + const hotel = + ("hotelId" in selectHotelParams && + (locations.data.find( + (location) => + isHotelLocation(location) && + "operaId" in location && + location.operaId === selectHotelParams.hotelId + ) as HotelLocation | undefined)) || + null + + if (isAlternativeHotels && !hotel) { + return notFound() + } + + const cityName = isAlternativeHotels + ? hotel?.relationships.city.name + : "city" in selectHotelParams + ? (selectHotelParams.city as string | undefined) + : undefined + + const city = + (typeof cityName === "string" && + locations.data.find( + (location) => location.name.toLowerCase() === cityName.toLowerCase() + )) || + null if (!city && !hotel) return notFound() + if (isAlternativeHotels && (!city || !hotel)) return notFound() let adultsInRoom = 1 let childrenInRoomString: HotelSearchDetails["childrenInRoomString"] = @@ -63,9 +95,9 @@ export async function getHotelSearchDetails< } return { - city: city ?? null, - hotel: hotel ?? null, - selectHotelParams, + city, + hotel, + selectHotelParams: { city: cityName, ...selectHotelParams }, adultsInRoom, childrenInRoomString, childrenInRoom, diff --git a/components/BookingWidget/Client.tsx b/components/BookingWidget/Client.tsx index bbc19a97c..52d1b3e74 100644 --- a/components/BookingWidget/Client.tsx +++ b/components/BookingWidget/Client.tsx @@ -77,7 +77,7 @@ export default function BookingWidgetClient({ const selectedLocation = bookingWidgetSearchData ? getLocationObj( - (bookingWidgetSearchData.hotel ?? + (bookingWidgetSearchData.hotelId ?? bookingWidgetSearchData.city) as string ) : undefined diff --git a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx index 811f595d8..031b8c65f 100644 --- a/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx +++ b/components/HotelReservation/SelectHotel/MobileMapButtonContainer/index.tsx @@ -2,7 +2,10 @@ import { useIntl } from "react-intl" -import { selectHotelMap } from "@/constants/routes/hotelReservation" +import { + alternativeHotelsMap, + selectHotelMap, +} from "@/constants/routes/hotelReservation" import { MapIcon } from "@/components/Icons" import Button from "@/components/TempDesignSystem/Button" @@ -17,8 +20,10 @@ import type { CategorizedFilters } from "@/types/components/hotelReservation/sel export default function MobileMapButtonContainer({ filters, + isAlternative, }: { filters: CategorizedFilters + isAlternative?: boolean }) { const intl = useIntl() const lang = useLang() @@ -33,7 +38,9 @@ export default function MobileMapButtonContainer({ size="small" > + ) + } + + return ( + + ) +} diff --git a/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer.tsx b/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer.tsx index f198fd595..5acdb7bff 100644 --- a/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer.tsx +++ b/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer.tsx @@ -6,6 +6,7 @@ import { env } from "@/env/server" import { getCityCoordinates } from "@/lib/trpc/memoizedRequests" import { + fetchAlternativeHotels, fetchAvailableHotels, getFiltersFromHotels, } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" @@ -36,16 +37,20 @@ function isValidHotelData(hotel: NullableHotelData): hotel is HotelData { export async function SelectHotelMapContainer({ searchParams, + isAlternativeHotels, }: SelectHotelMapContainerProps) { const lang = getLang() const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY const getHotelSearchDetailsPromise = safeTry( - getHotelSearchDetails({ - searchParams: searchParams as SelectHotelSearchParams & { - [key: string]: string + getHotelSearchDetails( + { + searchParams: searchParams as SelectHotelSearchParams & { + [key: string]: string + }, }, - }) + isAlternativeHotels + ) ) const [searchDetails] = await getHotelSearchDetailsPromise @@ -58,19 +63,29 @@ export async function SelectHotelMapContainer({ adultsInRoom, childrenInRoom, childrenInRoomString, + hotel: isAlternativeFor, } = searchDetails if (!city) return notFound() - const fetchAvailableHotelsPromise = safeTry( - fetchAvailableHotels({ - cityId: city.id, - roomStayStartDate: selectHotelParams.fromDate, - roomStayEndDate: selectHotelParams.toDate, - adults: adultsInRoom, - children: childrenInRoomString, - }) - ) + const fetchAvailableHotelsPromise = isAlternativeFor + ? safeTry( + fetchAlternativeHotels(isAlternativeFor.id, { + roomStayStartDate: selectHotelParams.fromDate, + roomStayEndDate: selectHotelParams.toDate, + adults: adultsInRoom, + children: childrenInRoomString, + }) + ) + : safeTry( + fetchAvailableHotels({ + cityId: city.id, + roomStayStartDate: selectHotelParams.fromDate, + roomStayEndDate: selectHotelParams.toDate, + adults: adultsInRoom, + children: childrenInRoomString, + }) + ) const [hotels] = await fetchAvailableHotelsPromise @@ -87,18 +102,24 @@ export async function SelectHotelMapContainer({ const departureDate = new Date(selectHotelParams.toDate) const pageTrackingData: TrackingSDKPageData = { - pageId: "select-hotel", + pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel", domainLanguage: lang, channel: TrackingChannelEnum["hotelreservation"], - pageName: "hotelreservation|select-hotel|mapview", - siteSections: "hotelreservation|select-hotel|mapview", + pageName: isAlternativeHotels + ? "hotelreservation|alternative-hotels|mapview" + : "hotelreservation|select-hotel|mapview", + siteSections: isAlternativeHotels + ? "hotelreservation|altervative-hotels|mapview" + : "hotelreservation|select-hotel|mapview", pageType: "bookinghotelsmapviewpage", siteVersion: "new-web", } const hotelsTrackingData: TrackingSDKHotelInfo = { availableResults: validHotels.length, - searchTerm: selectHotelParams.city, + searchTerm: isAlternativeFor + ? selectHotelParams.hotelId + : (selectHotelParams.city as string), arrivalDate: format(arrivalDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"), noOfAdults: adultsInRoom, diff --git a/components/HotelReservation/SelectHotel/index.tsx b/components/HotelReservation/SelectHotel/index.tsx index c1f5ca417..e8fbbcaef 100644 --- a/components/HotelReservation/SelectHotel/index.tsx +++ b/components/HotelReservation/SelectHotel/index.tsx @@ -3,18 +3,20 @@ import { notFound } from "next/navigation" import { Suspense } from "react" import { + alternativeHotels, + alternativeHotelsMap, selectHotel, selectHotelMap, } from "@/constants/routes/hotelReservation" import { + fetchAlternativeHotels, fetchAvailableHotels, getFiltersFromHotels, } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils" import { ChevronRightIcon } from "@/components/Icons" import StaticMap from "@/components/Maps/StaticMap" -import Alert from "@/components/TempDesignSystem/Alert" import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs" import Button from "@/components/TempDesignSystem/Button" import Link from "@/components/TempDesignSystem/Link" @@ -29,6 +31,7 @@ import HotelCount from "./HotelCount" import HotelFilter from "./HotelFilter" import HotelSorter from "./HotelSorter" import MobileMapButtonContainer from "./MobileMapButtonContainer" +import NoAvailabilityAlert from "./NoAvailabilityAlert" import styles from "./selectHotel.module.css" @@ -41,20 +44,23 @@ import { type TrackingSDKHotelInfo, type TrackingSDKPageData, } from "@/types/components/tracking" -import { AlertTypeEnum } from "@/types/enums/alert" export default async function SelectHotel({ params, searchParams, + isAlternativeHotels, }: SelectHotelProps) { const intl = await getIntl() const getHotelSearchDetailsPromise = safeTry( - getHotelSearchDetails({ - searchParams: searchParams as SelectHotelSearchParams & { - [key: string]: string + getHotelSearchDetails( + { + searchParams: searchParams as SelectHotelSearchParams & { + [key: string]: string + }, }, - }) + isAlternativeHotels + ) ) const [searchDetails] = await getHotelSearchDetailsPromise @@ -67,19 +73,29 @@ export default async function SelectHotel({ adultsInRoom, childrenInRoomString, childrenInRoom, + hotel: isAlternativeFor, } = searchDetails if (!city) return notFound() - const hotelsPromise = safeTry( - fetchAvailableHotels({ - cityId: city.id, - roomStayStartDate: selectHotelParams.fromDate, - roomStayEndDate: selectHotelParams.toDate, - adults: adultsInRoom, - children: childrenInRoomString, - }) - ) + const hotelsPromise = isAlternativeFor + ? safeTry( + fetchAlternativeHotels(isAlternativeFor.id, { + roomStayStartDate: selectHotelParams.fromDate, + roomStayEndDate: selectHotelParams.toDate, + adults: adultsInRoom, + children: childrenInRoomString, + }) + ) + : safeTry( + fetchAvailableHotels({ + cityId: city.id, + roomStayStartDate: selectHotelParams.fromDate, + roomStayEndDate: selectHotelParams.toDate, + adults: adultsInRoom, + children: childrenInRoomString, + }) + ) const [hotels] = await hotelsPromise @@ -105,32 +121,50 @@ export default async function SelectHotel({ href: `/${params.lang}/hotelreservation`, uid: "hotel-reservation", }, - { - title: intl.formatMessage({ id: "Select hotel" }), - href: `${selectHotel(params.lang)}/?${convertedSearchParams}`, - uid: "select-hotel", - }, - { - title: city.name, - uid: city.id, - }, + isAlternativeFor + ? { + title: intl.formatMessage({ id: "Alternative hotels" }), + href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`, + uid: "alternative-hotels", + } + : { + title: intl.formatMessage({ id: "Select hotel" }), + href: `${selectHotel(params.lang)}/?${convertedSearchParams}`, + uid: "select-hotel", + }, + isAlternativeFor + ? { + title: isAlternativeFor.name, + uid: isAlternativeFor.id, + } + : { + title: city.name, + uid: city.id, + }, ] - const isAllUnavailable = hotels?.every((hotel) => hotel.price === undefined) + const isAllUnavailable = + hotels?.every((hotel) => hotel.price === undefined) || false const pageTrackingData: TrackingSDKPageData = { - pageId: "select-hotel", + pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel", domainLanguage: params.lang, channel: TrackingChannelEnum["hotelreservation"], - pageName: "hotelreservation|select-hotel", - siteSections: "hotelreservation|select-hotel", + pageName: isAlternativeFor + ? "hotelreservation|alternative-hotels" + : "hotelreservation|select-hotel", + siteSections: isAlternativeFor + ? "hotelreservation|alternative-hotels" + : "hotelreservation|select-hotel", pageType: "bookinghotelspage", siteVersion: "new-web", } const hotelsTrackingData: TrackingSDKHotelInfo = { availableResults: validHotels.length, - searchTerm: selectHotelParams.city, + searchTerm: isAlternativeFor + ? selectHotelParams.hotelId + : (selectHotelParams.city as string), arrivalDate: format(arrivalDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"), noOfAdults: adultsInRoom, @@ -155,7 +189,11 @@ export default async function SelectHotel({
- {city.name} + + {isAlternativeFor + ? `${intl.formatMessage({ id: "Alternatives for" })} ${isAlternativeFor.name}` + : city.name} +
@@ -171,18 +209,22 @@ export default async function SelectHotel({
- {isAllUnavailable && ( - - )} +
diff --git a/components/TempDesignSystem/Alert/alert.ts b/components/TempDesignSystem/Alert/alert.ts index cbced8df2..ef866060b 100644 --- a/components/TempDesignSystem/Alert/alert.ts +++ b/components/TempDesignSystem/Alert/alert.ts @@ -19,5 +19,6 @@ export interface AlertProps extends VariantProps { link?: { url: string title: string + keepSearchParams?: boolean } | null } diff --git a/components/TempDesignSystem/Alert/index.tsx b/components/TempDesignSystem/Alert/index.tsx index bc5b20c01..ce383d533 100644 --- a/components/TempDesignSystem/Alert/index.tsx +++ b/components/TempDesignSystem/Alert/index.tsx @@ -64,7 +64,12 @@ export default function Alert({ ) : null}
{link ? ( - + {link.title} ) : null} diff --git a/constants/routes/hotelReservation.js b/constants/routes/hotelReservation.js index dffc0acb2..943f09e37 100644 --- a/constants/routes/hotelReservation.js +++ b/constants/routes/hotelReservation.js @@ -57,3 +57,17 @@ export function selectHotelMap(lang) { export function selectRate(lang) { return `${hotelreservation(lang)}/select-rate` } + +/** + * @param {Lang} lang + */ +export function alternativeHotels(lang) { + return `${hotelreservation(lang)}/alternative-hotels` +} + +/** + * @param {Lang} lang + */ +export function alternativeHotelsMap(lang) { + return `${hotelreservation(lang)}/alternative-hotels/map` +} diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json index 015a4a5e3..b8b623c4e 100644 --- a/i18n/dictionaries/en.json +++ b/i18n/dictionaries/en.json @@ -28,6 +28,7 @@ "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", "Allergy Room": "Allergy room", "Already a friend?": "Already a friend?", + "Alternatives for": "Alternatives for", "Always open": "Always open", "Amenities": "Amenities", "Amusement park": "Amusement park", @@ -369,6 +370,7 @@ "Phone number": "Phone number", "Please enter a valid phone number": "Please enter a valid phone number", "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.", + "Please try and change your search for this destination or see alternative hotels.": "Please try and change your search for this destination or see alternative hotels.", "Points": "Points", "Points being calculated": "Points being calculated", "Points earned prior to May 1, 2021": "Points earned prior to May 1, 2021", @@ -432,6 +434,7 @@ "Search": "Search", "See all FAQ": "See all FAQ", "See all photos": "See all photos", + "See alternative hotels": "See alternative hotels", "See details": "See details", "See hotel details": "See hotel details", "See less FAQ": "See less FAQ", diff --git a/lib/api/endpoints.ts b/lib/api/endpoints.ts index 74855587d..d7fce87af 100644 --- a/lib/api/endpoints.ts +++ b/lib/api/endpoints.ts @@ -38,6 +38,9 @@ export namespace endpoints { export function city(cityId: string) { return `${base.path.availability}/${version}/${base.enitity.Availabilities}/city/${cityId}` } + export function hotels() { + return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel` + } export function hotel(hotelId: string) { return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}` } diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index c3baec035..5b49eb355 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -12,6 +12,15 @@ export const getHotelsAvailabilityInputSchema = z.object({ bookingCode: z.string().optional().default(""), }) +export const getHotelsByHotelIdsAvailabilityInputSchema = z.object({ + hotelIds: z.array(z.number()), + roomStayStartDate: z.string(), + roomStayEndDate: z.string(), + adults: z.number(), + children: z.string().optional(), + bookingCode: z.string().optional().default(""), +}) + export const getRoomsAvailabilityInputSchema = z.object({ hotelId: z.number(), roomStayStartDate: z.string(), @@ -66,6 +75,10 @@ export const getHotelsInput = z.object({ }) export interface GetHotelsInput extends z.infer {} +export const nearbyHotelIdsInput = z.object({ + hotelId: z.string(), +}) + export const getBreakfastPackageInputSchema = z.object({ adults: z.number().min(1, { message: "at least one adult is required" }), fromDate: z diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts index 41cc6d937..40c12ea20 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -542,6 +542,8 @@ export type HotelsAvailability = z.infer export type ProductType = HotelsAvailability["data"][number]["attributes"]["productType"] export type ProductTypePrices = z.infer +export type HotelsAvailabilityItem = + HotelsAvailability["data"][number]["attributes"] const roomConfigurationSchema = z.object({ status: z.string(), @@ -889,6 +891,17 @@ export const getHotelIdsByCityIdSchema = z }) .transform((data) => data.data.map((hotel) => hotel.id)) +export const getNearbyHotelIdsSchema = z + .object({ + data: z.array( + z.object({ + // We only care about the hotel id + id: z.string(), + }) + ), + }) + .transform((data) => data.data.map((hotel) => hotel.id)) + export const getMeetingRoomsSchema = z.object({ data: z.array( z.object({ diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index c3d24b62b..b785621b2 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -23,6 +23,7 @@ import { getCityCoordinatesInputSchema, getHotelDataInputSchema, getHotelsAvailabilityInputSchema, + getHotelsByHotelIdsAvailabilityInputSchema, getHotelsInput, getMeetingRoomsInputSchema, getRatesInputSchema, @@ -30,12 +31,14 @@ import { getRoomsAvailabilityInputSchema, getSelectedRoomAvailabilityInputSchema, type HotelDataInput, + nearbyHotelIdsInput, } from "./input" import { breakfastPackagesSchema, getHotelDataSchema, getHotelsAvailabilitySchema, getMeetingRoomsSchema, + getNearbyHotelIdsSchema, getRatesSchema, getRoomPackagesSchema, getRoomsAvailabilitySchema, @@ -59,9 +62,15 @@ import { hotelsAvailabilityCounter, hotelsAvailabilityFailCounter, hotelsAvailabilitySuccessCounter, + hotelsByHotelIdAvailabilityCounter, + hotelsByHotelIdAvailabilityFailCounter, + hotelsByHotelIdAvailabilitySuccessCounter, meetingRoomsCounter, meetingRoomsFailCounter, meetingRoomsSuccessCounter, + nearbyHotelIdsCounter, + nearbyHotelIdsFailCounter, + nearbyHotelIdsSuccessCounter, roomsAvailabilityCounter, roomsAvailabilityFailCounter, roomsAvailabilitySuccessCounter, @@ -204,7 +213,7 @@ export const getHotelData = cache( export const hotelQueryRouter = router({ availability: router({ - hotels: serviceProcedure + hotelsByCity: serviceProcedure .input(getHotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { const { lang } = ctx @@ -319,6 +328,122 @@ export const hotelQueryRouter = router({ ), } }), + hotelsByHotelIds: serviceProcedure + .input(getHotelsByHotelIdsAvailabilityInputSchema) + .query(async ({ input, ctx }) => { + const { lang } = ctx + const apiLang = toApiLang(lang) + const { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + } = input + + const params: Record = { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + ...(children && { children }), + ...(bookingCode && { bookingCode }), + language: apiLang, + } + hotelsByHotelIdAvailabilityCounter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + }) + console.info( + "api.hotels.hotelsByHotelIdAvailability start", + JSON.stringify({ query: { params } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Availability.hotels(), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + hotelsByHotelIdAvailabilityFailCounter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability error", + JSON.stringify({ + query: { params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + const apiJson = await apiResponse.json() + const validateAvailabilityData = + getHotelsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + hotelsByHotelIdAvailabilityFailCounter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability validation error", + JSON.stringify({ + query: { params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + hotelsByHotelIdAvailabilitySuccessCounter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + }) + console.info( + "api.hotels.hotelsByHotelIdAvailability success", + JSON.stringify({ + query: { params }, + }) + ) + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } + }), rooms: serviceProcedure .input(getRoomsAvailabilityInputSchema) .query(async ({ input, ctx }) => { @@ -907,6 +1032,85 @@ export const hotelQueryRouter = router({ ) }), }), + nearbyHotelIds: serviceProcedure + .input(nearbyHotelIdsInput) + .query(async function ({ ctx, input }) { + const { lang } = ctx + const apiLang = toApiLang(lang) + + const { hotelId } = input + const params: Record = { + language: apiLang, + } + nearbyHotelIdsCounter.add(1, { + hotelId, + }) + console.info( + "api.hotels.nearbyHotelIds start", + JSON.stringify({ query: { hotelId, params } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.nearbyHotels(hotelId), + { + headers: { + Authorization: `Bearer ${ctx.serviceToken}`, + }, + }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + nearbyHotelIdsFailCounter.add(1, { + hotelId, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.nearbyHotelIds error", + JSON.stringify({ + query: { hotelId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + const apiJson = await apiResponse.json() + const validateHotelData = getNearbyHotelIdsSchema.safeParse(apiJson) + if (!validateHotelData.success) { + nearbyHotelIdsFailCounter.add(1, { + hotelId, + error_type: "validation_error", + error: JSON.stringify(validateHotelData.error), + }) + console.error( + "api.hotels.nearbyHotelIds validation error", + JSON.stringify({ + query: { hotelId, params }, + error: validateHotelData.error, + }) + ) + throw badRequestError() + } + nearbyHotelIdsSuccessCounter.add(1, { + hotelId, + }) + console.info( + "api.hotels.nearbyHotelIds success", + JSON.stringify({ + query: { hotelId, params }, + }) + ) + + return validateHotelData.data.map((id: string) => parseInt(id, 10)) + }), locations: router({ get: serviceProcedure.query(async function ({ ctx }) { const searchParams = new URLSearchParams() diff --git a/server/routers/hotels/telemetry.ts b/server/routers/hotels/telemetry.ts index bf5705ce5..9351f3f8d 100644 --- a/server/routers/hotels/telemetry.ts +++ b/server/routers/hotels/telemetry.ts @@ -25,6 +25,16 @@ export const hotelsAvailabilityFailCounter = meter.createCounter( "trpc.hotel.availability.hotels-fail" ) +export const hotelsByHotelIdAvailabilityCounter = meter.createCounter( + "trpc.hotel.availability.hotels-by-hotel-id" +) +export const hotelsByHotelIdAvailabilitySuccessCounter = meter.createCounter( + "trpc.hotel.availability.hotels-by-hotel-id-success" +) +export const hotelsByHotelIdAvailabilityFailCounter = meter.createCounter( + "trpc.hotel.availability.hotels-by-hotel-id-fail" +) + export const roomsAvailabilityCounter = meter.createCounter( "trpc.hotel.availability.rooms" ) @@ -73,6 +83,16 @@ export const getHotelIdsFailCounter = meter.createCounter( "trpc.hotel.hotel-ids.get-fail" ) +export const nearbyHotelIdsCounter = meter.createCounter( + "trpc.hotel.nearby-hotel-ids.get" +) +export const nearbyHotelIdsSuccessCounter = meter.createCounter( + "trpc.hotel.nearby-hotel-ids.get-success" +) +export const nearbyHotelIdsFailCounter = meter.createCounter( + "trpc.hotel.nearby-hotel-ids.get-fail" +) + export const meetingRoomsCounter = meter.createCounter( "trpc.hotels.meetingRooms" ) diff --git a/types/components/hotelReservation/selectHotel/availabilityInput.ts b/types/components/hotelReservation/selectHotel/availabilityInput.ts index d8b7aad26..4ce3f9e77 100644 --- a/types/components/hotelReservation/selectHotel/availabilityInput.ts +++ b/types/components/hotelReservation/selectHotel/availabilityInput.ts @@ -6,3 +6,11 @@ export type AvailabilityInput = { children?: string bookingCode?: string } + +export type AlternativeHotelsAvailabilityInput = { + roomStayStartDate: string + roomStayEndDate: string + adults: number + children?: string + bookingCode?: string +} diff --git a/types/components/hotelReservation/selectHotel/map.ts b/types/components/hotelReservation/selectHotel/map.ts index 121d778ea..7918d49b8 100644 --- a/types/components/hotelReservation/selectHotel/map.ts +++ b/types/components/hotelReservation/selectHotel/map.ts @@ -6,7 +6,10 @@ import type { imageSchema } from "@/server/routers/hotels/schemas/image" import type { Child } from "../selectRate/selectRate" import type { HotelData } from "./hotelCardListingProps" import type { CategorizedFilters, Filter } from "./hotelFilters" -import type { SelectHotelSearchParams } from "./selectHotelSearchParams" +import type { + AlternativeHotelsSearchParams, + SelectHotelSearchParams, +} from "./selectHotelSearchParams" export interface HotelListingProps { hotels: HotelData[] @@ -65,5 +68,6 @@ export interface HotelCardDialogListingProps { } export type SelectHotelMapContainerProps = { - searchParams: SelectHotelSearchParams + searchParams: SelectHotelSearchParams | AlternativeHotelsSearchParams + isAlternativeHotels?: boolean } diff --git a/types/components/hotelReservation/selectHotel/noAvailabilityAlert.ts b/types/components/hotelReservation/selectHotel/noAvailabilityAlert.ts new file mode 100644 index 000000000..7405d5281 --- /dev/null +++ b/types/components/hotelReservation/selectHotel/noAvailabilityAlert.ts @@ -0,0 +1,7 @@ +import type { HotelData } from "./hotelCardListingProps" + +export type NoAvailabilityAlertProp = { + isAllUnavailable: boolean + isAlternative?: boolean + hotels: HotelData[] +} diff --git a/types/components/hotelReservation/selectHotel/selectHotel.ts b/types/components/hotelReservation/selectHotel/selectHotel.ts index 6ff8e05b8..8beba9035 100644 --- a/types/components/hotelReservation/selectHotel/selectHotel.ts +++ b/types/components/hotelReservation/selectHotel/selectHotel.ts @@ -1,6 +1,10 @@ import type { CheckInData, Hotel, ParkingData } from "@/types/hotel" +import type { HotelLocation } from "@/types/trpc/routers/hotel/locations" import type { Lang } from "@/constants/languages" -import type { SelectHotelSearchParams } from "./selectHotelSearchParams" +import type { + AlternativeHotelsSearchParams, + SelectHotelSearchParams, +} from "./selectHotelSearchParams" export enum AvailabilityEnum { Available = "Available", @@ -42,5 +46,6 @@ export interface SelectHotelProps { params: { lang: Lang } - searchParams: SelectHotelSearchParams + searchParams: SelectHotelSearchParams | AlternativeHotelsSearchParams + isAlternativeHotels?: boolean } diff --git a/types/components/hotelReservation/selectHotel/selectHotelSearchParams.ts b/types/components/hotelReservation/selectHotel/selectHotelSearchParams.ts index ee387d6d1..a303cf10d 100644 --- a/types/components/hotelReservation/selectHotel/selectHotelSearchParams.ts +++ b/types/components/hotelReservation/selectHotel/selectHotelSearchParams.ts @@ -5,5 +5,11 @@ export interface SelectHotelSearchParams { fromDate: string toDate: string rooms: Pick[] - [key: string]: string | string[] | Pick[] +} + +export interface AlternativeHotelsSearchParams { + hotel: string + fromDate: string + toDate: string + rooms: Pick[] } diff --git a/types/trpc/routers/hotel/locations.ts b/types/trpc/routers/hotel/locations.ts index 8f2deed1e..40d56dc7f 100644 --- a/types/trpc/routers/hotel/locations.ts +++ b/types/trpc/routers/hotel/locations.ts @@ -1,6 +1,6 @@ -import { z } from "zod" +import type { z } from "zod" -import { apiLocationsSchema } from "@/server/routers/hotels/output" +import type { apiLocationsSchema } from "@/server/routers/hotels/output" export interface LocationSchema extends z.output {} @@ -9,3 +9,9 @@ export type Location = Locations[number] export type CityLocation = Location & { type: "cities" } export type HotelLocation = Location & { type: "hotels" } + +export function isHotelLocation( + location: Location | null +): location is HotelLocation { + return location?.type === "hotels" +} diff --git a/utils/url.ts b/utils/url.ts index 732e3e348..2d56dad4f 100644 --- a/utils/url.ts +++ b/utils/url.ts @@ -29,6 +29,10 @@ const keyedSearchParams = new Map([ ["child", "childrenInRoom"], ]) +export type SelectHotelParams = Omit & { + hotelId: string +} & PartialRoom + export function getKeyFromSearchParam(key: string): string { return keyedSearchParams.get(key) || key } @@ -46,12 +50,12 @@ export function convertSearchParamsToObj( searchParams: Record ) { const searchParamsObject = Object.entries(searchParams).reduce< - T & PartialRoom + SelectHotelParams >((acc, [key, value]) => { // The params are sometimes indexed with a number (for ex: `room[0].adults`), // so we need to split them by . or [] const keys = key.replace(/\]/g, "").split(/\[|\./) - const firstKey = getKeyFromSearchParam(keys[0]) as keyof T + const firstKey = getKeyFromSearchParam(keys[0]) // Room is a special case since it is an array, so we need to handle it separately if (firstKey === "rooms") { @@ -92,11 +96,11 @@ export function convertSearchParamsToObj( roomObject[index][roomObjectKey] = value } } else { - acc[firstKey] = value as T[keyof T] + return { ...acc, [firstKey]: value } } return acc - }, {} as T) + }, {} as SelectHotelParams) return searchParamsObject }