Merged in fix/destinations-speed-test (pull request #1704)

Feat(destination pages): Performance improvements

* fix/destinations: try cache full response

* Added more caching

* Removed unsed env car

* wip

* merge master

* wip

* wip

* wip

* Renaming


Approved-by: Michael Zetterberg
This commit is contained in:
Linus Flood
2025-04-02 11:37:22 +00:00
parent 961e8aea91
commit e4907d4b47
34 changed files with 381 additions and 290 deletions

View File

@@ -25,8 +25,9 @@ export async function GET(request: NextRequest) {
throw new Error("[WARMUP] Invalid language provided")
}
const hotels = await serverClient().hotel.hotels.getAllHotels.get({
const hotels = await serverClient().hotel.hotels.getDestinationsMapData({
lang: parsedLang.data,
warmup: true,
})
return NextResponse.json(hotels)
} catch (error) {

View File

@@ -19,13 +19,15 @@ import { getVisibleHotels } from "./utils"
import styles from "./hotelList.module.css"
import { AlertTypeEnum } from "@/types/enums/alert"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
export default function HotelList() {
const intl = useIntl()
const map = useMap()
const coreLib = useMapsLibrary("core")
const [visibleHotels, setVisibleHotels] = useState<HotelDataWithUrl[]>([])
const [visibleHotels, setVisibleHotels] = useState<
DestinationPagesHotelData[]
>([])
const { activeHotels, isLoading } = useDestinationDataStore((state) => ({
activeHotels: state.activeHotels,
isLoading: state.isLoading,
@@ -80,7 +82,7 @@ export default function HotelList() {
<HotelCardCarousel visibleHotels={visibleHotels} />
<ul className={styles.hotelList}>
{visibleHotels.map(({ hotel, url }) => (
<li key={hotel.operaId}>
<li key={hotel.id}>
<HotelListItem hotel={hotel} url={url} />
</li>
))}

View File

@@ -1,7 +1,7 @@
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
export function getVisibleHotels(
hotels: HotelDataWithUrl[],
hotels: DestinationPagesHotelData[],
map: google.maps.Map | null
) {
const bounds = map?.getBounds()

View File

@@ -19,15 +19,11 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListItem.module.css"
import type { Hotel } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
interface HotelListItemProps {
hotel: Hotel
url: string | null
}
export default function HotelListItem({ hotel, url }: HotelListItemProps) {
export default function HotelListItem(data: DestinationPagesHotelData) {
const intl = useIntl()
const { hotel, url } = data
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
@@ -35,7 +31,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
const { setHoveredMarker, activeMarker } = useDestinationPageHotelsMapStore()
useEffect(() => {
if (activeMarker === hotel.operaId) {
if (activeMarker === hotel.id) {
const element = itemRef.current
if (element) {
element.scrollIntoView({
@@ -45,13 +41,13 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
})
}
}
}, [activeMarker, hotel.operaId])
}, [activeMarker, hotel.id])
const handleMouseEnter = useCallback(() => {
if (hotel.operaId) {
setHoveredMarker(hotel.operaId)
if (hotel.id) {
setHoveredMarker(hotel.id)
}
}, [setHoveredMarker, hotel.operaId])
}, [setHoveredMarker, hotel.id])
const handleMouseLeave = useCallback(() => {
setHoveredMarker(null)
@@ -62,7 +58,7 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
ref={itemRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={`${styles.hotelListItem} ${activeMarker === hotel.operaId ? styles.activeCard : ""}`}
className={`${styles.hotelListItem} ${activeMarker === hotel.id ? styles.activeCard : ""}`}
>
<div className={styles.imageWrapper}>
<ImageGallery
@@ -74,19 +70,17 @@ export default function HotelListItem({ hotel, url }: HotelListItemProps) {
{ title: hotel.name }
)}
/>
{hotel.ratings?.tripAdvisor.rating && (
{hotel.tripadvisor && (
<div className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" />
<Caption color="burgundy">
{hotel.ratings.tripAdvisor.rating}
</Caption>
<Caption color="burgundy">{hotel.tripadvisor}</Caption>
</div>
)}
</div>
<div className={styles.content}>
<div className={styles.intro}>
<div className={styles.logo}>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<HotelLogo hotelId={hotel.id} hotelType={hotel.hotelType} />
</div>
<Subtitle type="one" asChild>
<h3>{hotel.name}</h3>

View File

@@ -1,5 +1,5 @@
import { env } from "@/env/server"
import { getAllHotels } from "@/lib/trpc/memoizedRequests"
import { getDestinationsMapData } from "@/lib/trpc/memoizedRequests"
import DynamicMap from "../../Map/DynamicMap"
import MapContent from "../../Map/MapContent"
@@ -8,7 +8,7 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "../../Map/utils"
import ActiveMapCard from "./ActiveMapCard"
export default async function OverviewMapContainer() {
const hotelData = await getAllHotels()
const hotelData = await getDestinationsMapData()
if (!hotelData) {
return null

View File

@@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard"
import styles from "./hotelCardCarousel.module.css"
import type { Hotel, HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
interface MapCardCarouselProps {
visibleHotels: HotelDataWithUrl[] | []
visibleHotels: DestinationPagesHotelData[]
}
export default function HotelCardCarousel({
visibleHotels,
@@ -22,13 +22,13 @@ export default function HotelCardCarousel({
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex(
({ hotel }) => hotel.operaId === activeMarker
({ hotel }) => hotel.id === activeMarker
)
const handleScrollSelect = useCallback(
(idx: number) => {
if (selectedHotelIdx !== -1) {
setActiveMarker(visibleHotels[idx]?.hotel.operaId)
setActiveMarker(visibleHotels[idx]?.hotel.id)
}
},
[setActiveMarker, visibleHotels, selectedHotelIdx]
@@ -44,15 +44,15 @@ export default function HotelCardCarousel({
>
<Carousel.Content className={styles.carouselContent}>
{visibleHotels.map(({ hotel, url }) => (
<Carousel.Item key={hotel.operaId} className={styles.item}>
<Carousel.Item key={hotel.id} className={styles.item}>
<HotelMapCard
className={cx(styles.carouselCard, {
[styles.noActiveHotel]: !activeMarker,
})}
tripadvisorRating={hotel.ratings?.tripAdvisor.rating}
tripadvisorRating={hotel.tripadvisor}
hotelName={hotel.name}
url={url}
image={getImage(hotel)}
image={getImage({ hotel, url })}
amenities={hotel.detailedFacilities.slice(0, 3)}
/>
</Carousel.Item>
@@ -62,11 +62,11 @@ export default function HotelCardCarousel({
)
}
function getImage(hotel: Hotel) {
function getImage(hotel: DestinationPagesHotelData) {
return {
src: hotel.galleryImages[0].imageSizes.medium,
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
alt:
hotel.galleryImages[0].metaData.altText ||
hotel.galleryImages[0].metaData.altText_En,
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
}
}

View File

@@ -25,19 +25,12 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { Hotel } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
interface HotelListingItemProps {
hotel: Hotel
url: string | null
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
export default function HotelListingItem(data: DestinationPagesHotelData) {
const intl = useIntl()
const params = useParams()
const { hotel, url } = data
const { setActiveMarker } = useDestinationPageHotelsMapStore()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
@@ -61,18 +54,16 @@ export default function HotelListingItem({
{ title: hotel.name }
)}
/>
{hotel.ratings?.tripAdvisor.rating && (
{hotel.tripadvisor && (
<div className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" />
<Caption color="burgundy">
{hotel.ratings.tripAdvisor.rating}
</Caption>
<Caption color="burgundy">{hotel.tripadvisor}</Caption>
</div>
)}
</div>
<div className={styles.content}>
<div className={styles.intro}>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<HotelLogo hotelId={hotel.id} hotelType={hotel.hotelType} />
<Subtitle type="one" asChild>
<h3>{hotel.name}</h3>
</Subtitle>
@@ -93,7 +84,7 @@ export default function HotelListingItem({
</Caption>
</div>
</div>
<Body>{hotel.hotelContent.texts.descriptions?.short}</Body>
<Body>{hotel.hotelContent?.texts.descriptions?.short}</Body>
<ul className={styles.amenityList}>
{amenities.map((amenity) => {
const Icon = (
@@ -112,7 +103,7 @@ export default function HotelListingItem({
<Link
href={mapUrl}
scroll={true}
onClick={() => setActiveMarker(hotel.operaId)}
onClick={() => setActiveMarker(hotel.id)}
>
{intl.formatMessage({ id: "See on map" })}
<MaterialIcon

View File

@@ -87,7 +87,7 @@ export default function HotelListing() {
<>
<ul className={styles.hotelList}>
{activeHotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<li key={hotel.id}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}

View File

@@ -29,10 +29,10 @@ import { getHotelMapMarkers, mapMarkerDataToGeoJson } from "./utils"
import styles from "./map.module.css"
import type { MapLocation } from "@/types/components/mapLocation"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
interface MapProps {
hotels: HotelDataWithUrl[]
hotels: DestinationPagesHotelData[]
mapId: string
apiKey: string
pageType: "city" | "country"
@@ -55,9 +55,7 @@ export default function Map({
() => searchParams.get("view") === "map",
[searchParams]
)
const activeHotel = hotels.find(
({ hotel }) => hotel.operaId === activeHotelId
)
const activeHotel = hotels.find(({ hotel }) => hotel.id === activeHotelId)
const rootDiv = useRef<HTMLDivElement | null>(null)
const [mapHeight, setMapHeight] = useState("0px")
const [scrollHeightWhenOpened, setScrollHeightWhenOpened] = useState(0)

View File

@@ -3,7 +3,7 @@ import type {
MarkerFeature,
MarkerGeojson,
} from "@/types/components/maps/destinationMarkers"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
const features = markers.map<MarkerFeature>(
@@ -28,7 +28,7 @@ export function mapMarkerDataToGeoJson(markers: DestinationMarker[]) {
return geoJson
}
export function getHotelMapMarkers(hotels: HotelDataWithUrl[]) {
export function getHotelMapMarkers(hotels: DestinationPagesHotelData[]) {
const markers = hotels
.map(({ hotel, url }) => ({
id: hotel.id,
@@ -41,20 +41,21 @@ export function getHotelMapMarkers(hotels: HotelDataWithUrl[]) {
}
: null,
url: url,
tripadvisor: hotel.ratings?.tripAdvisor.rating,
amenities: hotel.detailedFacilities.slice(0, 3),
image:
hotel.galleryImages && hotel.galleryImages[0]
? {
src: hotel.galleryImages[0].imageSizes.medium,
alt:
hotel.galleryImages[0].metaData.altText ||
hotel.galleryImages[0].metaData.altText_En,
}
: null,
tripadvisor: hotel.tripadvisor,
amenities: hotel.detailedFacilities,
image: getImage({ hotel, url }),
}))
.filter((item): item is DestinationMarker => !!item.coordinates)
return markers
}
function getImage(hotel: DestinationPagesHotelData) {
return {
src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
alt:
hotel.hotel.galleryImages?.[0]?.metaData.altText ||
hotel.hotel.galleryImages?.[0]?.metaData.altText_En,
}
}

View File

@@ -12,7 +12,7 @@ import {
import { trackAddAncillary } from "@/utils/tracking/myStay"
import { type AncillaryQuantityFormData,quantitySchema } from "../../schema"
import { type AncillaryQuantityFormData, quantitySchema } from "../../schema"
import styles from "./actionButtons.module.css"

View File

@@ -47,7 +47,7 @@ export default function PriceDetails({
const totalPrice = isBreakfast
? breakfastData!.priceAdult * breakfastData!.nrOfAdults +
breakfastData!.priceChild * breakfastData!.nrOfPayingChildren
breakfastData!.priceChild * breakfastData!.nrOfPayingChildren
: quantityWithCard && selectedAncillary
? selectedAncillary.price.total * quantityWithCard
: null
@@ -101,15 +101,15 @@ export default function PriceDetails({
const items = isBreakfast
? getBreakfastItems(selectedAncillary, breakfastData)
: [
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
{
title: selectedAncillary.title,
totalPrice: selectedAncillary.price.total,
currency: selectedAncillary.price.currency,
points: selectedAncillary.points,
quantityWithCard,
quantityWithPoints,
},
]
return (
<>

View File

@@ -2,4 +2,4 @@
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
width: 100%;
}
}

View File

@@ -1,4 +1,7 @@
import type { Product, RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
import type {
Product,
RateDefinition,
} from "@/types/trpc/routers/hotel/roomAvailability"
/**
* Get terms and rate title from the rate definitions when booking code rate
@@ -12,7 +15,7 @@ export function getRateDefinition(
product: Product,
rateDefinitions: RateDefinition[],
isUserLoggedIn: boolean,
isMainRoom: boolean,
isMainRoom: boolean
) {
return rateDefinitions.find((rateDefinition) => {
if ("member" in product && product.member && isUserLoggedIn && isMainRoom) {
@@ -28,4 +31,4 @@ export function getRateDefinition(
return rateDefinition.rateCode === product.public.rateCode
}
})
}
}

View File

@@ -8,7 +8,7 @@ import type {
export function isSelectedPriceProduct(
product: PriceProduct,
selectedRate: SelectedRate | null,
roomTypeCode: string,
roomTypeCode: string
) {
if (!selectedRate) {
return false
@@ -25,15 +25,15 @@ export function isSelectedPriceProduct(
selectedRatePublic = selectedRate.product.public
}
const selectedRateIsMember = (
member && selectedRateMember &&
(member.rateCode === selectedRateMember.rateCode)
)
const selectedRateIsMember =
member &&
selectedRateMember &&
member.rateCode === selectedRateMember.rateCode
const selectedRateIsPublic = (
standard && selectedRatePublic &&
(standard.rateCode === selectedRatePublic.rateCode)
)
const selectedRateIsPublic =
standard &&
selectedRatePublic &&
standard.rateCode === selectedRatePublic.rateCode
return !!(
(selectedRateIsMember || selectedRateIsPublic) &&
selectedRate.roomTypeCode === roomTypeCode
@@ -43,15 +43,15 @@ export function isSelectedPriceProduct(
export function isSelectedCorporateCheque(
product: CorporateChequeProduct,
selectedRate: SelectedRate | null,
roomTypeCode: string,
roomTypeCode: string
) {
if (!selectedRate || !("corporateCheque" in selectedRate.product)) {
return false
}
const isSameRateCode = (
product.corporateCheque.rateCode === selectedRate.product.corporateCheque.rateCode
)
const isSameRateCode =
product.corporateCheque.rateCode ===
selectedRate.product.corporateCheque.rateCode
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
return isSameRateCode && isSameRoomTypeCode
}
@@ -59,15 +59,14 @@ export function isSelectedCorporateCheque(
export function isSelectedVoucher(
product: VoucherProduct,
selectedRate: SelectedRate | null,
roomTypeCode: string,
roomTypeCode: string
) {
if (!selectedRate || !("voucher" in selectedRate.product)) {
return false
}
const isSameRateCode = (
const isSameRateCode =
product.voucher.rateCode === selectedRate.product.voucher.rateCode
)
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
return isSameRateCode && isSameRoomTypeCode
}
}

View File

@@ -1,25 +1,20 @@
import type {
RoomPackage,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { RoomPackage } from "@/types/components/hotelReservation/selectRate/roomFilter"
export function calculatePricePerNightPriceProduct(
pricePerNight: number,
requestedPricePerNight: number | undefined,
nights: number,
petRoomPackage?: RoomPackage,
petRoomPackage?: RoomPackage
) {
const totalPrice = petRoomPackage?.localPrice
? Math.floor(
pricePerNight + (petRoomPackage.localPrice.price / nights)
)
? Math.floor(pricePerNight + petRoomPackage.localPrice.price / nights)
: Math.floor(pricePerNight)
let totalRequestedPrice = undefined
if (requestedPricePerNight) {
if (petRoomPackage?.requestedPrice) {
totalRequestedPrice = Math.floor(
requestedPricePerNight +
(petRoomPackage.requestedPrice.price / nights)
requestedPricePerNight + petRoomPackage.requestedPrice.price / nights
)
} else {
totalRequestedPrice = Math.floor(requestedPricePerNight)

View File

@@ -5,4 +5,4 @@
gap: var(--Spacing-x1);
margin: 0;
padding: var(--Spacing-x2);
}
}

View File

@@ -6,8 +6,9 @@ import { useRatesStore } from "@/stores/select-rate"
import styles from "./rooms.module.css"
export default function ScrollToList() {
const { isSingleRoomAndHasSelection } = useRatesStore(state => ({
isSingleRoomAndHasSelection: state.booking.rooms.length === 1 && !!state.rateSummary.length,
const { isSingleRoomAndHasSelection } = useRatesStore((state) => ({
isSingleRoomAndHasSelection:
state.booking.rooms.length === 1 && !!state.rateSummary.length,
}))
useEffect(() => {

View File

@@ -5,6 +5,6 @@
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.roomList>li {
.roomList > li {
width: 100%;
}
}

View File

@@ -62,4 +62,4 @@
.Border-Divider-Default {
background-color: var(--Border-Divider-Default);
}
}

View File

@@ -168,13 +168,6 @@ export const env = createEnv({
// transform to boolean
.transform((s) => s === "true")
.default("false"),
CACHE_TIME_HOTELDATA: z
.number()
.default(30 * 60)
.transform((val) =>
process.env.CMS_ENVIRONMENT === "test" ? 5 * 60 : val
),
CACHE_TIME_HOTELS: z
.number()
.default(TWENTYFOUR_HOURS)
@@ -284,7 +277,6 @@ export const env = createEnv({
SAS_ENABLED: process.env.SAS,
SAS_POINT_TRANSFER_ENABLED: process.env.SAS_POINT_TRANSFER_ENABLED,
CACHE_TIME_HOTELDATA: process.env.CACHE_TIME_HOTELDATA,
CACHE_TIME_HOTELS: process.env.CACHE_TIME_HOTELS,
CACHE_TIME_CITY_SEARCH: process.env.CACHE_TIME_CITY_SEARCH,

View File

@@ -21,4 +21,4 @@ export default function useRateTitles() {
},
noPriceAvailable: intl.formatMessage({ id: "No prices available" }),
}
}
}

View File

@@ -131,9 +131,9 @@
"Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}": "Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
"Breakfast Restaurant": "Breakfast Restaurant",
"Breakfast buffet": "Breakfast buffet",
"Breakfast can only be added for the entire duration of the stay and for all guests.": "Breakfast can only be added for the entire duration of the stay and for all guests.",
"Breakfast can be added after booking for an additional fee.": "Breakfast can be added after booking for an additional fee.",
"Breakfast can be added after booking for an extra cost for adults and kids ages 4 and up.": "Breakfast can be added after booking for an extra cost for adults and kids ages 4 and up.",
"Breakfast can only be added for the entire duration of the stay and for all guests.": "Breakfast can only be added for the entire duration of the stay and for all guests.",
"Breakfast can only be added for the entire duration of the stayand for all guests.": "Breakfast can only be added for the entire duration of the stayand for all guests.",
"Breakfast charge": "Breakfast charge",
"Breakfast deal can be purchased at the hotel.": "Breakfast deal can be purchased at the hotel.",

View File

@@ -209,9 +209,11 @@ export const getHotelsByCityIdentifier = cache(
})
}
)
export const getAllHotels = cache(async function getMemoizedAllHotels() {
return serverClient().hotel.hotels.getAllHotels.get()
})
export const getDestinationsMapData = cache(
async function getMemoizedDestinationsMapData() {
return serverClient().hotel.hotels.getDestinationsMapData()
}
)
export const getDestinationCityPage = cache(
async function getMemoizedDestinationCityPage() {
return serverClient().contentstack.destinationCityPage.get()

View File

@@ -96,9 +96,10 @@ export const nearbyHotelIdsInput = z.object({
hotelId: z.string(),
})
export const getAllHotelsInput = z
export const getDestinationsMapDataInput = z
.object({
lang: z.nativeEnum(Lang),
warmup: z.boolean().optional(),
})
.optional()

View File

@@ -12,6 +12,10 @@ import {
includedSchema,
relationshipsSchema as hotelRelationshipsSchema,
} from "./schemas/hotel"
import { addressSchema } from "./schemas/hotel/address"
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
import { locationSchema } from "./schemas/hotel/location"
import { imageSchema } from "./schemas/image"
import { locationCitySchema } from "./schemas/location/city"
import { locationHotelSchema } from "./schemas/location/hotel"
import {
@@ -657,3 +661,40 @@ export const roomFeaturesSchema = z
.transform((data) => {
return data.data.attributes.roomFeatures
})
export const destinationPagesHotelDataSchema = z
.object({
data: z.object({
id: z.string(),
name: z.string(),
location: locationSchema,
cityIdentifier: z.string().optional(),
tripadvisor: z.number().optional(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
url: z.string().optional(),
hotelContent: z
.object({
texts: z.object({
descriptions: z.object({
short: z.string().optional(),
}),
}),
})
.optional(),
}),
})
.transform(({ data: { ...data } }) => {
return {
hotel: {
...data,
},
url: data.url ?? "",
}
})

View File

@@ -26,7 +26,7 @@ import {
breakfastPackageInputSchema,
cityCoordinatesInputSchema,
getAdditionalDataInputSchema,
getAllHotelsInput,
getDestinationsMapDataInput,
getHotelsByCityIdentifierInput,
getHotelsByCountryInput,
getHotelsByCSFilterInput,
@@ -70,7 +70,7 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
@@ -1292,45 +1292,59 @@ export const hotelQueryRouter = router({
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
}),
}),
getAllHotels: router({
get: serviceProcedure.input(getAllHotelsInput).query(async function ({
input,
ctx,
}) {
getDestinationsMapData: serviceProcedure
.input(getDestinationsMapDataInput)
.query(async function ({ input, ctx }) {
const lang = input?.lang ?? ctx.lang
const countries = await getCountries({
// Countries need to be in English regardless of incoming lang because
// we use the names as input for API endpoints.
lang: Lang.en,
serviceToken: ctx.serviceToken,
})
const warmup = input?.warmup ?? false
if (!countries) {
throw new Error("Unable to fetch countries")
const fetchHotels = async () => {
const countries = await getCountries({
// Countries need to be in English regardless of incoming lang because
// we use the names as input for API endpoints.
lang: Lang.en,
serviceToken: ctx.serviceToken,
})
if (!countries) {
throw new Error("Unable to fetch countries")
}
const countryNames = countries.data.map((country) => country.name)
const hotelData: DestinationPagesHotelData[] = (
await Promise.all(
countryNames.map(async (country) => {
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken: ctx.serviceToken,
})
const hotels = await getHotelsByHotelIds({
hotelIds,
lang: lang,
serviceToken: ctx.serviceToken,
})
return hotels
})
)
).flat()
return hotelData
}
const countryNames = countries.data.map((country) => country.name)
const hotelData: HotelDataWithUrl[] = (
await Promise.all(
countryNames.map(async (country) => {
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken: ctx.serviceToken,
})
if (warmup) {
return await fetchHotels()
}
const hotels = await getHotelsByHotelIds({
hotelIds,
lang: lang,
serviceToken: ctx.serviceToken,
})
return hotels
})
)
).flat()
return hotelData
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:getDestinationsMapData`,
fetchHotels,
"max"
)
}),
}),
}),
nearbyHotelIds: serviceProcedure
.input(nearbyHotelIdsInput)
.query(async function ({ ctx, input }) {
@@ -1341,74 +1355,81 @@ export const hotelQueryRouter = router({
const params: Record<string, string | number> = {
language: apiLang,
}
metrics.nearbyHotelIds.counter.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()
metrics.nearbyHotelIds.fail.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,
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${apiLang}:nearbyHotels:${hotelId}`,
async () => {
metrics.nearbyHotelIds.counter.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()
metrics.nearbyHotelIds.fail.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) {
metrics.nearbyHotelIds.fail.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()
}
metrics.nearbyHotelIds.success.add(1, {
hotelId,
})
)
return null
}
const apiJson = await apiResponse.json()
const validateHotelData = getNearbyHotelIdsSchema.safeParse(apiJson)
if (!validateHotelData.success) {
metrics.nearbyHotelIds.fail.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()
}
metrics.nearbyHotelIds.success.add(1, {
hotelId,
})
console.info(
"api.hotels.nearbyHotelIds success",
JSON.stringify({
query: { hotelId, params },
})
)
console.info(
"api.hotels.nearbyHotelIds success",
JSON.stringify({
query: { hotelId, params },
})
)
return validateHotelData.data.map((id: string) => parseInt(id, 10))
return validateHotelData.data.map((id: string) => parseInt(id, 10))
},
env.CACHE_TIME_HOTELS
)
}),
locations: router({
get: serviceProcedure.input(getLocationsInput).query(async function ({
@@ -1459,22 +1480,29 @@ export const hotelQueryRouter = router({
const { city, hotel } = input
async function fetchCoordinates(address: string) {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`
const response = await fetch(url)
const data = await response.json()
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`coordinates:${address}`,
async function () {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`
const response = await fetch(url)
const data = await response.json()
if (data.status !== "OK") {
console.error(`Geocode error: ${data.status}`)
return null
}
if (data.status !== "OK") {
console.error(`Geocode error: ${data.status}`)
return null
}
const location = data.results[0]?.geometry?.location
if (!location) {
console.error("No location found in geocode response")
return null
}
const location = data.results[0]?.geometry?.location
if (!location) {
console.error("No location found in geocode response")
return null
}
return location
return location
},
"1d"
)
}
let location = await fetchCoordinates(city)

View File

@@ -24,7 +24,7 @@ import { getHotel } from "./query"
import type { z } from "zod"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
@@ -529,19 +529,54 @@ export async function getHotelsByHotelIds({
lang: Lang
serviceToken: string
}) {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const hotelData = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
const hotelPage = hotelPages.find((page) => page.hotelId === hotelId)
return hotelData ? { ...hotelData, url: hotelPage?.url ?? null } : null
})
)
const cacheClient = await getCacheClient()
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
return await cacheClient.cacheOrGet(
cacheKey,
async () => {
const hotelPages = await getHotelPageUrls(lang)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const hotelResponse = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
if (!hotelResponse) {
throw new Error(`Hotel not found: ${hotelId}`)
}
const hotelPage = hotelPages.find((page) => page.hotelId === hotelId)
const { hotel, cities } = hotelResponse
const data: DestinationPagesHotelData = {
hotel: {
id: hotel.id,
galleryImages: hotel.galleryImages?.length
? [hotel.galleryImages[0]]
: [],
name: hotel.name,
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
detailedFacilities: hotel.detailedFacilities?.slice(0, 3) || [],
location: hotel.location,
hotelType: hotel.hotelType,
type: hotel.type,
address: hotel.address,
cityIdentifier: cities?.[0]?.cityIdentifier,
},
url: hotelPage?.url ?? "",
} satisfies DestinationPagesHotelData
return { ...data, url: hotelPage?.url ?? null }
})
)
return hotels.filter(
(hotel): hotel is DestinationPagesHotelData => !!hotel
)
},
"1d"
)
}
function findProduct(product: Products, rateDefinition: RateDefinition) {
@@ -697,10 +732,13 @@ export async function getSelectedRoomAvailability(
}
if (Array.isArray(product)) {
const redemptionProduct = userPoints ? product.find(
(r) => r.redemption.rateCode === rateDefinition.rateCode &&
r.redemption.localPrice.pointsPerStay <= userPoints
) : undefined
const redemptionProduct = userPoints
? product.find(
(r) =>
r.redemption.rateCode === rateDefinition.rateCode &&
r.redemption.localPrice.pointsPerStay <= userPoints
)
: undefined
if (!redemptionProduct) {
return null
}

View File

@@ -4,20 +4,20 @@ import type {
SortItem,
} from "@/types/components/destinationFilterAndSort"
import { SortOption } from "@/types/enums/destinationFilterAndSort"
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
import type { DestinationCityListItem } from "@/types/trpc/routers/contentstack/destinationCityPage"
const HOTEL_SORTING_STRATEGIES: Partial<
Record<SortOption, (a: HotelDataWithUrl, b: HotelDataWithUrl) => number>
Record<
SortOption,
(a: DestinationPagesHotelData, b: DestinationPagesHotelData) => number
>
> = {
[SortOption.Name]: function (a, b) {
return a.hotel.name.localeCompare(b.hotel.name)
},
[SortOption.TripAdvisorRating]: function (a, b) {
return (
(b.hotel.ratings?.tripAdvisor.rating ?? 0) -
(a.hotel.ratings?.tripAdvisor.rating ?? 0)
)
return (b.hotel.tripadvisor ?? 0) - (a.hotel.tripadvisor ?? 0)
},
[SortOption.Distance]: function (a, b) {
return a.hotel.location.distanceToCentre - b.hotel.location.distanceToCentre
@@ -48,7 +48,7 @@ const CITY_SORTING_STRATEGIES: Partial<
}
export function getFilteredHotels(
hotels: HotelDataWithUrl[],
hotels: DestinationPagesHotelData[],
filters: string[]
) {
if (filters.length) {
@@ -62,11 +62,11 @@ export function getFilteredHotels(
}
export function getFilteredCities(
filteredHotels: HotelDataWithUrl[],
filteredHotels: DestinationPagesHotelData[],
cities: DestinationCityListItem[]
) {
const filteredCityIdentifiers = filteredHotels.map(
(hotel) => hotel.cities[0].cityIdentifier
(hotel) => hotel.hotel.cityIdentifier
)
return cities.filter((city) =>
@@ -83,7 +83,7 @@ export function getSortedCities(
}
export function getSortedHotels(
hotels: HotelDataWithUrl[],
hotels: DestinationPagesHotelData[],
sortOption: SortOption
) {
const sortFn = HOTEL_SORTING_STRATEGIES[sortOption]
@@ -116,7 +116,7 @@ const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
]
export function getFiltersFromHotels(
hotels: HotelDataWithUrl[]
hotels: DestinationPagesHotelData[]
): CategorizedFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [] }

View File

@@ -10,10 +10,7 @@ export type RoomListItemProps = {
export type RoomListItemImageProps = Pick<
RoomConfiguration,
| "features"
| "roomType"
| "roomTypeCode"
| "roomsLeft"
"features" | "roomType" | "roomTypeCode" | "roomsLeft"
>
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>
@@ -30,9 +27,9 @@ export type CalculatePricesPerNightProps = {
export interface RoomSizeProps {
roomSize:
| {
max: number
min: number
}
| undefined
| {
max: number
min: number
}
| undefined
}

View File

@@ -1,6 +1,9 @@
import type { z } from "zod"
import type { hotelSchema } from "@/server/routers/hotels/output"
import type {
destinationPagesHotelDataSchema,
hotelSchema,
} from "@/server/routers/hotels/output"
import type { citySchema } from "@/server/routers/hotels/schemas/city"
import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel"
import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address"
@@ -75,3 +78,7 @@ export type AdditionalData = ReturnType<typeof transformAdditionalData>
export type ExtraPageSchema = z.output<typeof extraPageSchema>
export type HotelDataWithUrl = HotelData & { url: string }
export type DestinationPagesHotelData = z.output<
typeof destinationPagesHotelDataSchema
> & { url: string }

View File

@@ -1,9 +1,9 @@
import type { HotelDataWithUrl } from "@/types/hotel"
import type { DestinationPagesHotelData } from "@/types/hotel"
import type { SortItem } from "../components/destinationFilterAndSort"
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
export interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: HotelDataWithUrl[]
allHotels: DestinationPagesHotelData[]
allCities?: DestinationCityListItem[]
sortItems: SortItem[]
}

View File

@@ -3,7 +3,7 @@ import type {
SortItem,
} from "../components/destinationFilterAndSort"
import type { SortOption } from "../enums/destinationFilterAndSort"
import type { HotelDataWithUrl } from "../hotel"
import type { DestinationPagesHotelData } from "../hotel"
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
interface Actions {
@@ -25,8 +25,8 @@ export interface DestinationDataState {
actions: Actions
allCities: DestinationCityListItem[]
activeCities: DestinationCityListItem[]
allHotels: HotelDataWithUrl[]
activeHotels: HotelDataWithUrl[]
allHotels: DestinationPagesHotelData[]
activeHotels: DestinationPagesHotelData[]
pendingSort: SortOption
activeSort: SortOption
defaultSort: SortOption