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
This commit is contained in:
Niclas Edenvin
2025-01-28 12:08:40 +00:00
parent 4247e37667
commit ef22fc4627
28 changed files with 693 additions and 105 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,7 @@
import styles from "./layout.module.css"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,6 @@
.main {
display: grid;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
position: relative;
}

View File

@@ -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<LangParams, AlternativeHotelsSearchParams>) {
setLang(params.lang)
return (
<div className={styles.main}>
<MapContainer>
<Suspense
key={searchParams.hotel}
fallback={<SelectHotelMapContainerSkeleton />}
>
<SelectHotelMapContainer
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
</MapContainer>
</div>
)
}

View File

@@ -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<LangParams, AlternativeHotelsSearchParams>) {
setLang(params.lang)
const roomKey = Object.keys(searchParams)
.filter((key) => key.startsWith("room["))
.map((key) => searchParams[key])
.join("-")
return (
<Suspense
key={`${searchParams.hotel}-${searchParams.fromDate}-${searchParams.toDate}-${roomKey}`}
fallback={<SelectHotelSkeleton />}
>
<SelectHotel
params={params}
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
)
}

View File

@@ -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<NullableHotelData[]> {
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<NullableHotelData[]> {
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 {

View File

@@ -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<T> {
city: Location | null
hotel: Location | null
selectHotelParams: T
hotel: HotelLocation | null
selectHotelParams: SelectHotelParams<T> & { city: string | undefined }
adultsInRoom: number
childrenInRoomString?: string
childrenInRoom?: Child[]
}
export async function getHotelSearchDetails<
T extends SelectHotelSearchParams | SelectRateSearchParams,
>({
searchParams,
}: {
searchParams: T & {
[key: string]: string
}
}): Promise<HotelSearchDetails<T> | null> {
T extends
| SelectHotelSearchParams
| SelectRateSearchParams
| AlternativeHotelsSearchParams,
>(
{
searchParams,
}: {
searchParams: T & {
[key: string]: string
}
},
isAlternativeHotels?: boolean
): Promise<HotelSearchDetails<T> | null> {
const selectHotelParams = convertSearchParamsToObj<T>(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<T>["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,

View File

@@ -77,7 +77,7 @@ export default function BookingWidgetClient({
const selectedLocation = bookingWidgetSearchData
? getLocationObj(
(bookingWidgetSearchData.hotel ??
(bookingWidgetSearchData.hotelId ??
bookingWidgetSearchData.city) as string
)
: undefined

View File

@@ -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"
>
<Link
href={selectHotelMap(lang)}
href={
isAlternative ? alternativeHotelsMap(lang) : selectHotelMap(lang)
}
color="baseButtonTextOnFillNormal"
keepSearchParams
weight="bold"

View File

@@ -0,0 +1,48 @@
import { alternativeHotels } from "@/constants/routes/hotelReservation"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import type { NoAvailabilityAlertProp } from "@/types/components/hotelReservation/selectHotel/noAvailabilityAlert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({
hotels,
isAllUnavailable,
isAlternative,
}: NoAvailabilityAlertProp) {
const intl = await getIntl()
const lang = getLang()
if (!isAllUnavailable) {
return null
}
if (hotels.length === 1 && !isAlternative) {
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "Please try and change your search for this destination or see alternative hotels.",
})}
link={{
title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}?hotel=${hotels[0].hotelData.operaId}`,
keepSearchParams: true,
}}
/>
)
}
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
/>
)
}

View File

@@ -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,

View File

@@ -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({
<Breadcrumbs breadcrumbs={breadcrumbs} />
<div className={styles.title}>
<div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle>
<Subtitle>
{isAlternativeFor
? `${intl.formatMessage({ id: "Alternatives for" })} ${isAlternativeFor.name}`
: city.name}
</Subtitle>
<HotelCount />
</div>
<div className={styles.sorter}>
@@ -171,18 +209,22 @@ export default async function SelectHotel({
<Link
className={styles.link}
color="burgundy"
href={selectHotelMap(params.lang)}
href={
isAlternativeFor
? alternativeHotelsMap(params.lang)
: selectHotelMap(params.lang)
}
keepSearchParams
>
<div className={styles.mapContainer}>
<StaticMap
city={selectHotelParams.city}
city={city.name}
country={isCityWithCountry(city) ? city.country : undefined}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${selectHotelParams.city} city center`}
altText={`Map of ${city.name} city center`}
/>
<Button wrapping size="medium" intent="text" theme="base">
{intl.formatMessage({ id: "See map" })}
@@ -197,27 +239,23 @@ export default async function SelectHotel({
) : (
<div className={styles.mapContainer}>
<StaticMap
city={selectHotelParams.city}
city={city.name}
width={340}
height={180}
zoomLevel={11}
mapType="roadmap"
altText={`Map of ${selectHotelParams.city} city center`}
altText={`Map of ${city.name} city center`}
/>
</div>
)}
<HotelFilter filters={filterList} className={styles.filter} />
</div>
<div className={styles.hotelList}>
{isAllUnavailable && (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
/>
)}
<NoAvailabilityAlert
isAlternative={!!isAlternativeFor}
hotels={validHotels}
isAllUnavailable={isAllUnavailable}
/>
<HotelCardListing hotelData={validHotels} />
</div>
</main>

View File

@@ -19,5 +19,6 @@ export interface AlertProps extends VariantProps<typeof alertVariants> {
link?: {
url: string
title: string
keepSearchParams?: boolean
} | null
}

View File

@@ -64,7 +64,12 @@ export default function Alert({
) : null}
</div>
{link ? (
<Link color="burgundy" textDecoration="underline" href={link.url}>
<Link
color="burgundy"
textDecoration="underline"
href={link.url}
keepSearchParams={link.keepSearchParams}
>
{link.title}
</Link>
) : null}

View File

@@ -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`
}

View File

@@ -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",

View File

@@ -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}`
}

View File

@@ -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<typeof getHotelsInput> {}
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

View File

@@ -542,6 +542,8 @@ export type HotelsAvailability = z.infer<typeof hotelsAvailabilitySchema>
export type ProductType =
HotelsAvailability["data"][number]["attributes"]["productType"]
export type ProductTypePrices = z.infer<typeof productTypePriceSchema>
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({

View File

@@ -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<string, string | number | number[]> = {
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<string, string | number> = {
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()

View File

@@ -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"
)

View File

@@ -6,3 +6,11 @@ export type AvailabilityInput = {
children?: string
bookingCode?: string
}
export type AlternativeHotelsAvailabilityInput = {
roomStayStartDate: string
roomStayEndDate: string
adults: number
children?: string
bookingCode?: string
}

View File

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

View File

@@ -0,0 +1,7 @@
import type { HotelData } from "./hotelCardListingProps"
export type NoAvailabilityAlertProp = {
isAllUnavailable: boolean
isAlternative?: boolean
hotels: HotelData[]
}

View File

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

View File

@@ -5,5 +5,11 @@ export interface SelectHotelSearchParams {
fromDate: string
toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[]
[key: string]: string | string[] | Pick<Room, "adults" | "childrenInRoom">[]
}
export interface AlternativeHotelsSearchParams {
hotel: string
fromDate: string
toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[]
}

View File

@@ -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<typeof apiLocationsSchema> {}
@@ -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"
}

View File

@@ -29,6 +29,10 @@ const keyedSearchParams = new Map([
["child", "childrenInRoom"],
])
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
hotelId: string
} & PartialRoom
export function getKeyFromSearchParam(key: string): string {
return keyedSearchParams.get(key) || key
}
@@ -46,12 +50,12 @@ export function convertSearchParamsToObj<T extends PartialRoom>(
searchParams: Record<string, string>
) {
const searchParamsObject = Object.entries(searchParams).reduce<
T & PartialRoom
SelectHotelParams<T>
>((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<T extends PartialRoom>(
roomObject[index][roomObjectKey] = value
}
} else {
acc[firstKey] = value as T[keyof T]
return { ...acc, [firstKey]: value }
}
return acc
}, {} as T)
}, {} as SelectHotelParams<T>)
return searchParamsObject
}