- {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
}