Merged in feat/SW-1557-implement-booking-code-select (pull request #1304)

feat: SW-1577 Implemented booking code city search

* feat: SW-1577 Implemented booking code city search

* feat: SW-1557 Strict comparison

* feat: SW-1557 Review comments fix


Approved-by: Michael Zetterberg
Approved-by: Pontus Dreij
This commit is contained in:
Hrishikesh Vaipurkar
2025-02-13 09:24:47 +00:00
parent d46a85a529
commit eabe45b73c
35 changed files with 627 additions and 276 deletions

View File

@@ -46,6 +46,17 @@ export async function fetchAvailableHotels(
return enhanceHotels(availableHotels) return enhanceHotels(availableHotels)
} }
export async function fetchBookingCodeAvailableHotels(
input: AvailabilityInput
): Promise<NullableHotelData[]> {
const availableHotels =
await serverClient().hotel.availability.hotelsByCityWithBookingCode(input)
if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
export async function fetchAlternativeHotels( export async function fetchAlternativeHotels(
hotelId: string, hotelId: string,
input: AlternativeHotelsAvailabilityInput input: AlternativeHotelsAvailabilityInput

View File

@@ -26,6 +26,7 @@ interface HotelSearchDetails<T> {
adultsInRoom: number[] adultsInRoom: number[]
childrenInRoomString?: string childrenInRoomString?: string
childrenInRoom?: Child[] childrenInRoom?: Child[]
bookingCode?: string
} }
export async function getHotelSearchDetails< export async function getHotelSearchDetails<
@@ -101,5 +102,6 @@ export async function getHotelSearchDetails<
adultsInRoom, adultsInRoom,
childrenInRoomString, childrenInRoomString,
childrenInRoom, childrenInRoom,
bookingCode: selectHotelParams.bookingCode ?? undefined,
} }
} }

View File

@@ -20,6 +20,7 @@ export default function TabletCodeInput({
{...register("bookingCode.value", { {...register("bookingCode.value", {
onChange: (e) => updateValue(e.target.value), onChange: (e) => updateValue(e.target.value),
})} })}
autoComplete="off"
/> />
) )
} }

View File

@@ -170,6 +170,7 @@ export default function BookingCode() {
id="booking-code" id="booking-code"
onChange={(event) => updateBookingCodeFormValue(event.target.value)} onChange={(event) => updateBookingCodeFormValue(event.target.value)}
defaultValue={bookingCode?.value} defaultValue={bookingCode?.value}
autoComplete="off"
/> />
{codeError?.message ? ( {codeError?.message ? (
<Caption color="red" className={styles.error}> <Caption color="red" className={styles.error}>

View File

@@ -92,6 +92,15 @@
gap: var(--Spacing-x-one-and-half); gap: var(--Spacing-x-one-and-half);
} }
.strikedText {
text-decoration: line-through;
}
@media screen and (min-width: 768px) and (max-width: 1024px) {
.imageContainer {
height: 180px;
}
}
@media screen and (min-width: 1367px) { @media screen and (min-width: 1367px) {
.card.pageListing { .card.pageListing {
flex-direction: row; flex-direction: row;

View File

@@ -8,6 +8,7 @@ import { selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map" import { useHotelsMapStore } from "@/stores/hotels-map"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data" import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { PriceTagIcon } from "@/components/Icons"
import HotelLogo from "@/components/Icons/Logos" import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery" import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -36,6 +37,7 @@ function HotelCard({
isUserLoggedIn, isUserLoggedIn,
state = "default", state = "default",
type = HotelCardListingTypeEnum.PageListing, type = HotelCardListingTypeEnum.PageListing,
bookingCode = "",
}: HotelCardProps) { }: HotelCardProps) {
const params = useParams() const params = useParams()
const lang = params.lang as Lang const lang = params.lang as Lang
@@ -71,6 +73,7 @@ function HotelCard({
const galleryImages = mapApiImagesToGalleryImages( const galleryImages = mapApiImagesToGalleryImages(
hotelData.galleryImages || [] hotelData.galleryImages || []
) )
const fullPrice = hotel.price?.public?.rateType?.toLowerCase() === "regular"
return ( return (
<article <article
@@ -161,9 +164,18 @@ function HotelCard({
<NoPriceAvailableCard /> <NoPriceAvailableCard />
) : ( ) : (
<> <>
{!isUserLoggedIn && price.public && ( {bookingCode && (
<HotelPriceCard productTypePrices={price.public} /> <span
className={`${styles.bookingCode} ${fullPrice ? styles.strikedText : ""}`}
>
<PriceTagIcon height={20} width={20} />
{bookingCode}
</span>
)} )}
{(!isUserLoggedIn || (bookingCode && !fullPrice)) &&
price.public && (
<HotelPriceCard productTypePrices={price.public} />
)}
{price.member && ( {price.member && (
<HotelPriceCard <HotelPriceCard
productTypePrices={price.member} productTypePrices={price.member}

View File

@@ -14,6 +14,8 @@ export function getHotelPins(hotels: HotelData[]): HotelPin[] {
name: hotel.hotelData.name, name: hotel.hotelData.name,
publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null, publicPrice: hotel.price?.public?.localPrice.pricePerNight ?? null,
memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null, memberPrice: hotel.price?.member?.localPrice.pricePerNight ?? null,
rateType:
hotel.price?.public?.rateType ?? hotel.price?.member?.rateType ?? null,
currency: currency:
hotel.price?.public?.localPrice.currency || hotel.price?.public?.localPrice.currency ||
hotel.price?.member?.localPrice.currency || hotel.price?.member?.localPrice.currency ||

View File

@@ -4,6 +4,7 @@ import { useSession } from "next-auth/react"
import { useEffect, useMemo } from "react" import { useEffect, useMemo } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { useHotelFilterStore } from "@/stores/hotel-filters" import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map" import { useHotelsMapStore } from "@/stores/hotels-map"
@@ -36,27 +37,40 @@ export default function HotelCardListing({
const { activeHotelCard } = useHotelsMapStore() const { activeHotelCard } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 }) const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const sortBy = useMemo( const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
() => searchParams.get("sort") ?? DEFAULT_SORT,
[searchParams] const bookingCode = searchParams.get("bookingCode")
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
) )
const sortedHotels = useMemo(() => { const sortedHotels = useMemo(() => {
if (!hotelData) return [] if (!hotelData) return []
return getSortedHotels({ hotels: hotelData, sortBy }) return getSortedHotels({ hotels: hotelData, sortBy, bookingCode })
}, [hotelData, sortBy]) }, [hotelData, sortBy, bookingCode])
const hotels = useMemo(() => { const hotels = useMemo(() => {
if (activeFilters.length === 0) return sortedHotels const updatedHotelsList = bookingCode
? sortedHotels.filter(
(hotel) =>
!hotel.price ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.price?.public?.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.price?.public?.rateType?.toLowerCase()
)
: sortedHotels
return sortedHotels.filter((hotel) => if (activeFilters.length === 0) return updatedHotelsList
return updatedHotelsList.filter((hotel) =>
activeFilters.every((appliedFilterId) => activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some( hotel.hotelData.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId (facility) => facility.id.toString() === appliedFilterId
) )
) )
) )
}, [activeFilters, sortedHotels]) }, [activeFilters, sortedHotels, bookingCode, activeCodeFilter])
useEffect(() => { useEffect(() => {
setResultCount(hotels?.length ?? 0) setResultCount(hotels?.length ?? 0)
@@ -79,6 +93,7 @@ export default function HotelCardListing({
hotel.hotelData.name === activeHotelCard ? "active" : "default" hotel.hotelData.name === activeHotelCard ? "active" : "default"
} }
type={type} type={type}
bookingCode={bookingCode}
/> />
</div> </div>
)) ))

View File

@@ -4,14 +4,18 @@ import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotel
export function getSortedHotels({ export function getSortedHotels({
hotels, hotels,
sortBy, sortBy,
bookingCode,
}: { }: {
hotels: HotelData[] hotels: HotelData[]
sortBy: string sortBy: string
bookingCode: string | null
}) { }) {
const getPricePerNight = (hotel: HotelData): number => const getPricePerNight = (hotel: HotelData): number =>
hotel.price?.member?.localPrice?.pricePerNight ?? hotel.price?.member?.localPrice?.pricePerNight ??
hotel.price?.public?.localPrice?.pricePerNight ?? hotel.price?.public?.localPrice?.pricePerNight ??
Infinity Infinity
const availableHotels = hotels.filter((hotel) => !!hotel?.price)
const unAvailableHotels = hotels.filter((hotel) => !hotel?.price)
const sortingStrategies: Record< const sortingStrategies: Record<
string, string,
@@ -29,7 +33,27 @@ export function getSortedHotels({
b.hotelData.location.distanceToCentre, b.hotelData.location.distanceToCentre,
} }
return [...hotels].sort( const sortStrategy =
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance] sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
)
if (bookingCode) {
const bookingCodeHotels = hotels.filter(
(hotel) =>
(hotel?.price?.public?.rateType?.toLowerCase() !== "regular" ||
hotel?.price?.member?.rateType?.toLowerCase() !== "regular") &&
!!hotel?.price
)
const regularHotels = hotels.filter(
(hotel) => hotel?.price?.public?.rateType?.toLowerCase() === "regular"
)
return [...bookingCodeHotels]
.sort(sortStrategy)
.concat([...regularHotels].sort(sortStrategy))
.concat([...unAvailableHotels].sort(sortStrategy))
}
return [...availableHotels]
.sort(sortStrategy)
.concat([...unAvailableHotels].sort(sortStrategy))
} }

View File

@@ -0,0 +1,15 @@
.bookingCodeFilter {
display: flex;
justify-content: flex-end;
width: 100%;
}
.bookingCodeFilterSelect {
min-width: 200px;
}
@media screen and (max-width: 767px) {
.bookingCodeFilter {
margin-bottom: var(--Spacing-x3);
}
}

View File

@@ -0,0 +1,56 @@
"use client"
import { useIntl } from "react-intl"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { PriceTagIcon } from "@/components/Icons"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./bookingCodeFilter.module.css"
import type { Key } from "react"
export default function BookingCodeFilter() {
const intl = useIntl()
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const setFilter = useBookingCodeFilterStore((state) => state.setFilter)
const bookingCodeFilterItems = [
{
label: intl.formatMessage({ id: "Discounted rooms" }),
value: "discounted",
},
{
label: intl.formatMessage({ id: "Full price rooms" }),
value: "regular",
},
{
label: intl.formatMessage({ id: "See all" }),
value: "all",
},
]
function updateFilter(selectedFilter: Key) {
setFilter(selectedFilter as string)
}
return (
<>
<div className={styles.bookingCodeFilter}>
<Select
aria-label="Booking Code Filter"
className={styles.bookingCodeFilterSelect}
name="bookingCodeFilter"
onSelect={updateFilter}
label=""
items={bookingCodeFilterItems}
defaultSelectedKey={activeCodeFilter}
optionsIcon={<PriceTagIcon />}
/>
</div>
</>
)
}

View File

@@ -55,6 +55,7 @@ export default function HotelSorter({ discreet }: HotelSorterProps) {
items={sortItems} items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT} defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })} label={intl.formatMessage({ id: "Sort by" })}
aria-label={intl.formatMessage({ id: "Sort by" })}
name="sort" name="sort"
showRadioButton showRadioButton
discreet={discreet} discreet={discreet}

View File

@@ -8,6 +8,7 @@ import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
import { import {
fetchAlternativeHotels, fetchAlternativeHotels,
fetchAvailableHotels, fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels, getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils" import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
@@ -19,10 +20,7 @@ import { getHotelPins } from "../../HotelCardDialogListing/utils"
import SelectHotelMap from "." import SelectHotelMap from "."
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
HotelData,
NullableHotelData,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map" import type { SelectHotelMapContainerProps } from "@/types/components/hotelReservation/selectHotel/map"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import { import {
@@ -60,6 +58,7 @@ export async function SelectHotelMapContainer({
childrenInRoom, childrenInRoom,
childrenInRoomString, childrenInRoomString,
hotel: isAlternativeFor, hotel: isAlternativeFor,
bookingCode,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) return notFound()
@@ -71,17 +70,29 @@ export async function SelectHotelMapContainer({
roomStayEndDate: selectHotelParams.toDate, roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0], adults: adultsInRoom[0],
children: childrenInRoomString, children: childrenInRoomString,
bookingCode,
}) })
) )
: safeTry( : bookingCode
fetchAvailableHotels({ ? safeTry(
cityId: city.id, fetchBookingCodeAvailableHotels({
roomStayStartDate: selectHotelParams.fromDate, cityId: city.id,
roomStayEndDate: selectHotelParams.toDate, roomStayStartDate: selectHotelParams.fromDate,
adults: adultsInRoom[0], roomStayEndDate: selectHotelParams.toDate,
children: childrenInRoomString, adults: adultsInRoom[0],
}) children: childrenInRoomString,
) bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
})
)
const [hotels] = await fetchAvailableHotelsPromise const [hotels] = await fetchAvailableHotelsPromise
@@ -142,6 +153,7 @@ export async function SelectHotelMapContainer({
hotels={validHotels} hotels={validHotels}
filterList={filterList} filterList={filterList}
cityCoordinates={cityCoordinates} cityCoordinates={cityCoordinates}
bookingCode={bookingCode ?? ""}
/> />
<Suspense fallback={null}> <Suspense fallback={null}>
<TrackingSDK <TrackingSDK

View File

@@ -5,6 +5,7 @@ import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts" import { useMediaQuery } from "usehooks-ts"
import { selectHotel } from "@/constants/routes/hotelReservation" import { selectHotel } from "@/constants/routes/hotelReservation"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { useHotelFilterStore } from "@/stores/hotel-filters" import { useHotelFilterStore } from "@/stores/hotel-filters"
import { useHotelsMapStore } from "@/stores/hotels-map" import { useHotelsMapStore } from "@/stores/hotels-map"
@@ -18,6 +19,7 @@ import useLang from "@/hooks/useLang"
import { useScrollToTop } from "@/hooks/useScrollToTop" import { useScrollToTop } from "@/hooks/useScrollToTop"
import { debounce } from "@/utils/debounce" import { debounce } from "@/utils/debounce"
import BookingCodeFilter from "../../BookingCodeFilter"
import FilterAndSortModal from "../../FilterAndSortModal" import FilterAndSortModal from "../../FilterAndSortModal"
import HotelListing from "../HotelListing" import HotelListing from "../HotelListing"
import { getVisibleHotels } from "./utils" import { getVisibleHotels } from "./utils"
@@ -35,6 +37,7 @@ export default function SelectHotelContent({
mapId, mapId,
hotels, hotels,
filterList, filterList,
bookingCode,
}: Omit<SelectHotelMapProps, "apiKey">) { }: Omit<SelectHotelMapProps, "apiKey">) {
const lang = useLang() const lang = useLang()
const intl = useIntl() const intl = useIntl()
@@ -53,6 +56,9 @@ export default function SelectHotelContent({
elementRef: listingContainerRef, elementRef: listingContainerRef,
refScrollable: true, refScrollable: true,
}) })
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
const coordinates = useMemo( const coordinates = useMemo(
() => () =>
@@ -72,15 +78,23 @@ export default function SelectHotelContent({
} }
}, [activeHotelCard, activeHotelPin]) }, [activeHotelCard, activeHotelPin])
const filteredHotelPins = useMemo( const filteredHotelPins = useMemo(() => {
() => const updatedHotelsList = bookingCode
hotelPins.filter((hotel) => ? hotelPins.filter(
activeFilters.every((filterId) => (hotel) =>
hotel.facilityIds.includes(Number(filterId)) !hotel.publicPrice ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.rateType?.toLowerCase()
) )
), : hotelPins
[activeFilters, hotelPins] return updatedHotelsList.filter((hotel) =>
) activeFilters.every((filterId) =>
hotel.facilityIds.includes(Number(filterId))
)
)
}, [activeFilters, hotelPins, bookingCode, activeCodeFilter])
const getHotelCards = useCallback(() => { const getHotelCards = useCallback(() => {
const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map) const visibleHotels = getVisibleHotels(hotels, filteredHotelPins, map)
@@ -143,6 +157,7 @@ export default function SelectHotelContent({
filters={filterList} filters={filterList}
setShowSkeleton={setShowSkeleton} setShowSkeleton={setShowSkeleton}
/> />
{bookingCode ? <BookingCodeFilter /> : null}
</div> </div>
{showSkeleton ? ( {showSkeleton ? (

View File

@@ -13,6 +13,7 @@ export default function SelectHotelMap({
hotels, hotels,
filterList, filterList,
cityCoordinates, cityCoordinates,
bookingCode,
}: SelectHotelMapProps) { }: SelectHotelMapProps) {
return ( return (
<APIProvider apiKey={apiKey}> <APIProvider apiKey={apiKey}>
@@ -22,6 +23,7 @@ export default function SelectHotelMap({
mapId={mapId} mapId={mapId}
hotels={hotels} hotels={hotels}
filterList={filterList} filterList={filterList}
bookingCode={bookingCode}
/> />
</APIProvider> </APIProvider>
) )

View File

@@ -12,6 +12,7 @@ import {
import { import {
fetchAlternativeHotels, fetchAlternativeHotels,
fetchAvailableHotels, fetchAvailableHotels,
fetchBookingCodeAvailableHotels,
getFiltersFromHotels, getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils" import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
@@ -27,6 +28,7 @@ import { safeTry } from "@/utils/safeTry"
import { convertObjToSearchParams } from "@/utils/url" import { convertObjToSearchParams } from "@/utils/url"
import HotelCardListing from "../HotelCardListing" import HotelCardListing from "../HotelCardListing"
import BookingCodeFilter from "./BookingCodeFilter"
import HotelCount from "./HotelCount" import HotelCount from "./HotelCount"
import HotelFilter from "./HotelFilter" import HotelFilter from "./HotelFilter"
import HotelSorter from "./HotelSorter" import HotelSorter from "./HotelSorter"
@@ -74,6 +76,7 @@ export default async function SelectHotel({
childrenInRoomString, childrenInRoomString,
childrenInRoom, childrenInRoom,
hotel: isAlternativeFor, hotel: isAlternativeFor,
bookingCode,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) return notFound()
@@ -85,17 +88,29 @@ export default async function SelectHotel({
roomStayEndDate: selectHotelParams.toDate, roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0], adults: adultsInRoom[0],
children: childrenInRoomString, children: childrenInRoomString,
bookingCode,
}) })
) )
: safeTry( : bookingCode
fetchAvailableHotels({ ? safeTry(
cityId: city.id, fetchBookingCodeAvailableHotels({
roomStayStartDate: selectHotelParams.fromDate, cityId: city.id,
roomStayEndDate: selectHotelParams.toDate, roomStayStartDate: selectHotelParams.fromDate,
adults: adultsInRoom[0], roomStayEndDate: selectHotelParams.toDate,
children: childrenInRoomString, adults: adultsInRoom[0],
}) children: childrenInRoomString,
) bookingCode,
})
)
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom[0],
children: childrenInRoomString,
})
)
const [hotels] = await hotelsPromise const [hotels] = await hotelsPromise
@@ -204,6 +219,7 @@ export default async function SelectHotel({
</div> </div>
</header> </header>
<main className={styles.main}> <main className={styles.main}>
{bookingCode ? <BookingCodeFilter /> : null}
<div className={styles.sideBar}> <div className={styles.sideBar}>
{hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available {hotels && hotels.length > 0 ? ( // TODO: Temp fix until API returns hotels that are not available
<Link <Link

View File

@@ -75,6 +75,9 @@
@media (min-width: 768px) { @media (min-width: 768px) {
.main { .main {
padding: var(--Spacing-x5) 0; padding: var(--Spacing-x5) 0;
flex-direction: row;
gap: var(--Spacing-x5);
flex-wrap: wrap;
} }
.headerContent { .headerContent {
@@ -125,10 +128,6 @@
border-radius: var(--Corner-radius-Medium); border-radius: var(--Corner-radius-Medium);
border: 1px solid var(--Base-Border-Subtle); border: 1px solid var(--Base-Border-Subtle);
} }
.main {
flex-direction: row;
gap: var(--Spacing-x5);
}
.buttonContainer { .buttonContainer {
display: none; display: none;
} }

View File

@@ -40,6 +40,7 @@ export default function Select({
showRadioButton = false, showRadioButton = false,
discreet = false, discreet = false,
isNestedInModal = false, isNestedInModal = false,
optionsIcon,
}: SelectProps) { }: SelectProps) {
const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined) const [rootDiv, setRootDiv] = useState<SelectPortalContainer>(undefined)
const setOverflowVisible = useSetOverflowVisibleOnRA(isNestedInModal) const setOverflowVisible = useSetOverflowVisibleOnRA(isNestedInModal)
@@ -89,7 +90,12 @@ export default function Select({
{label} {label}
{discreet && `:`} {discreet && `:`}
</Label> </Label>
{selectedText && <Body>{selectedText}</Body>} {selectedText && (
<Body className={optionsIcon ? styles.iconLabel : ""}>
{optionsIcon ? optionsIcon : null}
{selectedText}
</Body>
)}
</> </>
)} )}
</SelectValue> </SelectValue>
@@ -114,11 +120,12 @@ export default function Select({
{items.map((item) => ( {items.map((item) => (
<ListBoxItem <ListBoxItem
aria-label={item.label} aria-label={item.label}
className={`${styles.listBoxItem} ${showRadioButton && styles.showRadioButton}`} className={`${styles.listBoxItem} ${showRadioButton && styles.showRadioButton} ${optionsIcon && styles.iconLabel}`}
id={item.value} id={item.value}
key={item.label} key={item.label}
data-testid={item.label} data-testid={item.label}
> >
{optionsIcon ? optionsIcon : null}
{item.label} {item.label}
</ListBoxItem> </ListBoxItem>
))} ))}

View File

@@ -40,6 +40,11 @@
pointer-events: none; pointer-events: none;
} }
.iconLabel {
display: flex;
gap: var(--Spacing-x-half);
}
.input { .input {
align-items: center; align-items: center;
background-color: var(--UI-Opacity-White-100); background-color: var(--UI-Opacity-White-100);

View File

@@ -1,3 +1,4 @@
import type { ReactElement } from "react"
import type { Key } from "react-aria-components" import type { Key } from "react-aria-components"
export interface SelectProps export interface SelectProps
@@ -12,6 +13,7 @@ export interface SelectProps
showRadioButton?: boolean showRadioButton?: boolean
discreet?: boolean discreet?: boolean
isNestedInModal?: boolean isNestedInModal?: boolean
optionsIcon?: ReactElement
} }
export type SelectPortalContainer = HTMLDivElement | undefined export type SelectPortalContainer = HTMLDivElement | undefined

View File

@@ -161,6 +161,7 @@
"Dimensions": "Dimensioner", "Dimensions": "Dimensioner",
"Discard changes": "Kassér ændringer", "Discard changes": "Kassér ændringer",
"Discard unsaved changes?": "Slette ændringer, der ikke er gemt?", "Discard unsaved changes?": "Slette ændringer, der ikke er gemt?",
"Discounted rooms": "Værelser med rabat",
"Discover": "Opdag", "Discover": "Opdag",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Opdag {name}", "Discover {name}": "Opdag {name}",
@@ -222,6 +223,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "Fra", "From": "Fra",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Fuld pris værelser",
"Garage": "Garage", "Garage": "Garage",
"Get inspired": "Bliv inspireret", "Get inspired": "Bliv inspireret",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -485,6 +487,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Søge", "Search": "Søge",
"See all": "Se alle",
"See all photos": "Se alle billeder", "See all photos": "Se alle billeder",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "Se destination", "See destination": "Se destination",

View File

@@ -162,6 +162,7 @@
"Dimensions": "Abmessungen", "Dimensions": "Abmessungen",
"Discard changes": "Änderungen verwerfen", "Discard changes": "Änderungen verwerfen",
"Discard unsaved changes?": "Nicht gespeicherte Änderungen verwerfen?", "Discard unsaved changes?": "Nicht gespeicherte Änderungen verwerfen?",
"Discounted rooms": "Zimmer mit Rabatt",
"Discover": "Entdecken", "Discover": "Entdecken",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Entdecken Sie {name}", "Discover {name}": "Entdecken Sie {name}",
@@ -223,6 +224,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "Fromm", "From": "Fromm",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Zimmer zum vollen Preis",
"Garage": "Garage", "Garage": "Garage",
"Get inspired": "Lassen Sie sich inspieren", "Get inspired": "Lassen Sie sich inspieren",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -486,6 +488,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Suchen", "Search": "Suchen",
"See all": "Alle anzeigen",
"See all photos": "Alle Fotos ansehen", "See all photos": "Alle Fotos ansehen",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "Siehe Ziel", "See destination": "Siehe Ziel",

View File

@@ -165,6 +165,7 @@
"Dimensions": "Dimensions", "Dimensions": "Dimensions",
"Discard changes": "Discard changes", "Discard changes": "Discard changes",
"Discard unsaved changes?": "Discard unsaved changes?", "Discard unsaved changes?": "Discard unsaved changes?",
"Discounted rooms": "Discounted rooms",
"Discover": "Discover", "Discover": "Discover",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Discover {name}", "Discover {name}": "Discover {name}",
@@ -226,6 +227,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "From", "From": "From",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Full price rooms",
"Garage": "Garage", "Garage": "Garage",
"Get inspired": "Get inspired", "Get inspired": "Get inspired",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -491,6 +493,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Search", "Search": "Search",
"See all": "See all",
"See all photos": "See all photos", "See all photos": "See all photos",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "See destination", "See destination": "See destination",

View File

@@ -161,6 +161,7 @@
"Dimensions": "Mitat", "Dimensions": "Mitat",
"Discard changes": "Hylkää muutokset", "Discard changes": "Hylkää muutokset",
"Discard unsaved changes?": "Hylkäätkö tallentamattomat muutokset?", "Discard unsaved changes?": "Hylkäätkö tallentamattomat muutokset?",
"Discounted rooms": "Alennetut huoneet",
"Discover": "Löydä", "Discover": "Löydä",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Tutustu {name}", "Discover {name}": "Tutustu {name}",
@@ -222,6 +223,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "From", "From": "From",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Täyshintaiset huoneet",
"Garage": "Autotalli", "Garage": "Autotalli",
"Get inspired": "Inspiroidu", "Get inspired": "Inspiroidu",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -486,6 +488,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Haku", "Search": "Haku",
"See all": "Katso kaikki",
"See all photos": "Katso kaikki kuvat", "See all photos": "Katso kaikki kuvat",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "Katso kohde", "See destination": "Katso kohde",

View File

@@ -160,6 +160,7 @@
"Dimensions": "Dimensjoner", "Dimensions": "Dimensjoner",
"Discard changes": "Forkaste endringer", "Discard changes": "Forkaste endringer",
"Discard unsaved changes?": "Forkaste endringer som ikke er lagret?", "Discard unsaved changes?": "Forkaste endringer som ikke er lagret?",
"Discounted rooms": "Rabatterte rom",
"Discover": "Oppdag", "Discover": "Oppdag",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Oppdag {name}", "Discover {name}": "Oppdag {name}",
@@ -221,6 +222,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "Fra", "From": "Fra",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Full pris rom",
"Garage": "Garasje", "Garage": "Garasje",
"Get inspired": "Bli inspirert", "Get inspired": "Bli inspirert",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -484,6 +486,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Søk", "Search": "Søk",
"See all": "Se alle",
"See all photos": "Se alle bilder", "See all photos": "Se alle bilder",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "Se destinasjon", "See destination": "Se destinasjon",

View File

@@ -160,6 +160,7 @@
"Dimensions": "Dimensioner", "Dimensions": "Dimensioner",
"Discard changes": "Ignorera ändringar", "Discard changes": "Ignorera ändringar",
"Discard unsaved changes?": "Vill du ignorera ändringar som inte har sparats?", "Discard unsaved changes?": "Vill du ignorera ändringar som inte har sparats?",
"Discounted rooms": "Rabatterade rum",
"Discover": "Upptäck", "Discover": "Upptäck",
"Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.", "Discover the little extra touches to make your upcoming stay even more unforgettable.": "Discover the little extra touches to make your upcoming stay even more unforgettable.",
"Discover {name}": "Upptäck {name}", "Discover {name}": "Upptäck {name}",
@@ -221,6 +222,7 @@
"Friends with Benefits": "Friends with Benefits", "Friends with Benefits": "Friends with Benefits",
"From": "Från", "From": "Från",
"Full circle": "Full circle", "Full circle": "Full circle",
"Full price rooms": "Fullpris rum",
"Garage": "Garage", "Garage": "Garage",
"Get inspired": "Bli inspirerad", "Get inspired": "Bli inspirerad",
"Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.", "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.": "Get inspired and start dreaming beyond your next trip. Explore more Scandic destinations.",
@@ -484,6 +486,7 @@
"Scandic Friends Mastercard": "Scandic Friends Mastercard", "Scandic Friends Mastercard": "Scandic Friends Mastercard",
"Scandic Friends Point Shop": "Scandic Friends Point Shop", "Scandic Friends Point Shop": "Scandic Friends Point Shop",
"Search": "Sök", "Search": "Sök",
"See all": "Se alla",
"See all photos": "Se alla foton", "See all photos": "Se alla foton",
"See alternative hotels": "See alternative hotels", "See alternative hotels": "See alternative hotels",
"See destination": "Se destination", "See destination": "Se destination",

View File

@@ -39,7 +39,7 @@ export namespace endpoints {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/city/${cityId}` return `${base.path.availability}/${version}/${base.enitity.Availabilities}/city/${cityId}`
} }
export function hotels() { export function hotels() {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel` return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotels`
} }
export function hotel(hotelId: string) { export function hotel(hotelId: string) {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}` return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}`

View File

@@ -32,6 +32,15 @@ export const metrics = {
fail: meter.createCounter("trpc.hotel.availability.hotels-fail"), fail: meter.createCounter("trpc.hotel.availability.hotels-fail"),
success: meter.createCounter("trpc.hotel.availability.hotels-success"), success: meter.createCounter("trpc.hotel.availability.hotels-success"),
}, },
hotelsAvailabilityBookingCode: {
counter: meter.createCounter("trpc.hotel.availability.hotels-booking-code"),
fail: meter.createCounter(
"trpc.hotel.availability.hotels-booking-code-fail"
),
success: meter.createCounter(
"trpc.hotel.availability.hotels-booking-code-success"
),
},
hotelsByHotelIdAvailability: { hotelsByHotelIdAvailability: {
counter: meter.createCounter("trpc.hotel.availability.hotels-by-hotel-id"), counter: meter.createCounter("trpc.hotel.availability.hotels-by-hotel-id"),
fail: meter.createCounter( fail: meter.createCounter(

View File

@@ -64,6 +64,10 @@ import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { HotelDataWithUrl } from "@/types/hotel" import type { HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations" import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
@@ -203,6 +207,251 @@ export const getHotel = cache(
} }
) )
export const getHotelsAvailabilityByCity = async (
input: HotelsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
) => {
const {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params: Record<string, string | number> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: apiLang,
}
metrics.hotelsAvailability.counter.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsAvailability start",
JSON.stringify({ query: { cityId, params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Availability.city(cityId),
{
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsAvailability.fail.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.hotelsAvailability error",
JSON.stringify({
query: { cityId, params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsAvailability.fail.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.hotelsAvailability validation error",
JSON.stringify({
query: { cityId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
metrics.hotelsAvailability.success.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsAvailability success",
JSON.stringify({
query: { cityId, params: params },
})
)
return {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
}
export const getHotelsAvailabilityByHotelIds = async (
input: HotelsByHotelIdsAvailabilityInputSchema,
apiLang: string,
serviceToken: string
) => {
const {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
**/
const params = new URLSearchParams([
["roomStayStartDate", roomStayStartDate],
["roomStayEndDate", roomStayEndDate],
["adults", adults.toString()],
["children", children ?? ""],
["bookingCode", bookingCode],
["language", apiLang],
])
hotelIds.forEach((hotelId) => params.append("hotelIds", hotelId.toString()))
metrics.hotelsByHotelIdAvailability.counter.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(),
{
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsByHotelIdAvailability.fail.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 = hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsByHotelIdAvailability.fail.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()
}
metrics.hotelsByHotelIdAvailability.success.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
),
}
}
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
availability: router({ availability: router({
hotelsByCity: serviceProcedure hotelsByCity: serviceProcedure
@@ -210,239 +459,14 @@ export const hotelQueryRouter = router({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { lang } = ctx const { lang } = ctx
const apiLang = toApiLang(lang) const apiLang = toApiLang(lang)
const { return getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params: Record<string, string | number> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: apiLang,
}
metrics.hotelsAvailability.counter.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsAvailability start",
JSON.stringify({ query: { cityId, params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Availability.city(cityId),
{
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsAvailability.fail.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.hotelsAvailability error",
JSON.stringify({
query: { cityId, params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsAvailability.fail.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.hotelsAvailability validation error",
JSON.stringify({
query: { cityId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
metrics.hotelsAvailability.success.add(1, {
cityId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsAvailability success",
JSON.stringify({
query: { cityId, params: params },
})
)
return {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
}), }),
hotelsByHotelIds: serviceProcedure hotelsByHotelIds: serviceProcedure
.input(getHotelsByHotelIdsAvailabilityInputSchema) .input(getHotelsByHotelIdsAvailabilityInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { lang } = ctx const { lang } = ctx
const apiLang = toApiLang(lang) const apiLang = toApiLang(lang)
const { return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params: Record<string, string | number | number[]> = {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: apiLang,
}
metrics.hotelsByHotelIdAvailability.counter.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(),
{
cache: undefined,
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_CITY_SEARCH,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.hotelsByHotelIdAvailability.fail.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 =
hotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
metrics.hotelsByHotelIdAvailability.fail.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()
}
metrics.hotelsByHotelIdAvailability.success.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 rooms: serviceProcedure
.input(roomsAvailabilityInputSchema) .input(roomsAvailabilityInputSchema)
@@ -793,6 +817,70 @@ export const hotelQueryRouter = router({
bedTypes, bedTypes,
} }
}), }),
hotelsByCityWithBookingCode: serviceProcedure
.input(hotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
metrics.hotelsAvailabilityBookingCode.counter.add(1, {
...input,
})
const bookingCodeAvailabilityResponse =
await getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
if (!bookingCodeAvailabilityResponse) {
metrics.hotelsAvailabilityBookingCode.fail.add(1, {
...input,
error_type: "unknown",
})
return null
}
// Get regular availability of hotels which don't have availability with booking code.
const unavailableHotelIds =
bookingCodeAvailabilityResponse?.availability
.filter((hotel) => {
return hotel.status === "NotAvailable"
})
.flatMap((hotel) => {
return hotel.hotelId
})
// All hotels have availability with booking code no need to fetch regular prices.
// return response as is without any filtering as below.
if (!unavailableHotelIds || !unavailableHotelIds.length) {
return bookingCodeAvailabilityResponse
}
const unavailableHotelsInput = {
...input,
bookingCode: "",
hotelIds: unavailableHotelIds,
}
const unavailableHotels = await getHotelsAvailabilityByHotelIds(
unavailableHotelsInput,
apiLang,
ctx.serviceToken
)
metrics.hotelsAvailabilityBookingCode.success.add(1, {
...input,
})
console.info("api.hotels.hotelsAvailabilityBookingCode success")
// No regular rates available due to network or API failure (no need to filter & merge).
if (!unavailableHotels) {
return bookingCodeAvailabilityResponse
}
// Filtering the response hotels to merge bookingCode rates and regular rates in single response.
return {
availability: bookingCodeAvailabilityResponse.availability
.filter((hotel) => {
return hotel.status === "Available"
})
.concat(unavailableHotels.availability),
}
}),
}), }),
rates: router({ rates: router({
get: publicProcedure get: publicProcedure

View File

@@ -0,0 +1,13 @@
import { create } from "zustand"
interface BookingCodeFilterState {
activeCodeFilter: string
setFilter: (filter: string) => void
}
export const useBookingCodeFilterStore = create<BookingCodeFilterState>(
(set) => ({
activeCodeFilter: "discounted",
setFilter: (filter) => set({ activeCodeFilter: filter }),
})
)

View File

@@ -8,4 +8,5 @@ export type HotelCardProps = {
isUserLoggedIn: boolean isUserLoggedIn: boolean
type?: HotelCardListingTypeEnum type?: HotelCardListingTypeEnum
state?: "default" | "active" state?: "default" | "active"
bookingCode?: string | null
} }

View File

@@ -22,6 +22,7 @@ export interface SelectHotelMapProps {
hotels: HotelData[] hotels: HotelData[]
filterList: CategorizedFilters filterList: CategorizedFilters
cityCoordinates: Coordinates cityCoordinates: Coordinates
bookingCode: string | undefined
} }
type ImageSizes = z.infer<typeof imageSchema>["imageSizes"] type ImageSizes = z.infer<typeof imageSchema>["imageSizes"]
@@ -32,6 +33,7 @@ export type HotelPin = {
coordinates: Coordinates coordinates: Coordinates
publicPrice: number | null publicPrice: number | null
memberPrice: number | null memberPrice: number | null
rateType: string | null
currency: string currency: string
images: { images: {
imageSizes: ImageSizes imageSizes: ImageSizes

View File

@@ -5,6 +5,7 @@ export interface SelectHotelSearchParams {
fromDate: string fromDate: string
toDate: string toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[] rooms: Pick<Room, "adults" | "childrenInRoom">[]
bookingCode: string
} }
export interface AlternativeHotelsSearchParams { export interface AlternativeHotelsSearchParams {
@@ -12,4 +13,5 @@ export interface AlternativeHotelsSearchParams {
fromDate: string fromDate: string
toDate: string toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[] rooms: Pick<Room, "adults" | "childrenInRoom">[]
bookingCode: string
} }

View File

@@ -25,6 +25,7 @@ export interface SelectRateSearchParams {
fromDate: string fromDate: string
toDate: string toDate: string
rooms: Room[] rooms: Room[]
bookingCode?: string
} }
export interface Rate { export interface Rate {

View File

@@ -1,10 +1,20 @@
import type { z } from "zod" import type { z } from "zod"
import type {
getHotelsByHotelIdsAvailabilityInputSchema,
hotelsAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output" import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType" import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type { productTypePriceSchema } from "@/server/routers/hotels/schemas/productTypePrice" import type { productTypePriceSchema } from "@/server/routers/hotels/schemas/productTypePrice"
export type HotelsAvailability = z.output<typeof hotelsAvailabilitySchema> export type HotelsAvailability = z.output<typeof hotelsAvailabilitySchema>
export type HotelsAvailabilityInputSchema = z.output<
typeof hotelsAvailabilityInputSchema
>
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema
>
export type ProductType = z.output<typeof productTypeSchema> export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema> export type ProductTypePrices = z.output<typeof productTypePriceSchema>