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") 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, lang: parsedLang.data,
warmup: true,
}) })
return NextResponse.json(hotels) return NextResponse.json(hotels)
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,10 +11,10 @@ import HotelMapCard from "../HotelMapCard"
import styles from "./hotelCardCarousel.module.css" import styles from "./hotelCardCarousel.module.css"
import type { Hotel, HotelDataWithUrl } from "@/types/hotel" import type { DestinationPagesHotelData } from "@/types/hotel"
interface MapCardCarouselProps { interface MapCardCarouselProps {
visibleHotels: HotelDataWithUrl[] | [] visibleHotels: DestinationPagesHotelData[]
} }
export default function HotelCardCarousel({ export default function HotelCardCarousel({
visibleHotels, visibleHotels,
@@ -22,13 +22,13 @@ export default function HotelCardCarousel({
const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore() const { activeMarker, setActiveMarker } = useDestinationPageHotelsMapStore()
const selectedHotelIdx = visibleHotels.findIndex( const selectedHotelIdx = visibleHotels.findIndex(
({ hotel }) => hotel.operaId === activeMarker ({ hotel }) => hotel.id === activeMarker
) )
const handleScrollSelect = useCallback( const handleScrollSelect = useCallback(
(idx: number) => { (idx: number) => {
if (selectedHotelIdx !== -1) { if (selectedHotelIdx !== -1) {
setActiveMarker(visibleHotels[idx]?.hotel.operaId) setActiveMarker(visibleHotels[idx]?.hotel.id)
} }
}, },
[setActiveMarker, visibleHotels, selectedHotelIdx] [setActiveMarker, visibleHotels, selectedHotelIdx]
@@ -44,15 +44,15 @@ export default function HotelCardCarousel({
> >
<Carousel.Content className={styles.carouselContent}> <Carousel.Content className={styles.carouselContent}>
{visibleHotels.map(({ hotel, url }) => ( {visibleHotels.map(({ hotel, url }) => (
<Carousel.Item key={hotel.operaId} className={styles.item}> <Carousel.Item key={hotel.id} className={styles.item}>
<HotelMapCard <HotelMapCard
className={cx(styles.carouselCard, { className={cx(styles.carouselCard, {
[styles.noActiveHotel]: !activeMarker, [styles.noActiveHotel]: !activeMarker,
})} })}
tripadvisorRating={hotel.ratings?.tripAdvisor.rating} tripadvisorRating={hotel.tripadvisor}
hotelName={hotel.name} hotelName={hotel.name}
url={url} url={url}
image={getImage(hotel)} image={getImage({ hotel, url })}
amenities={hotel.detailedFacilities.slice(0, 3)} amenities={hotel.detailedFacilities.slice(0, 3)}
/> />
</Carousel.Item> </Carousel.Item>
@@ -62,11 +62,11 @@ export default function HotelCardCarousel({
) )
} }
function getImage(hotel: Hotel) { function getImage(hotel: DestinationPagesHotelData) {
return { return {
src: hotel.galleryImages[0].imageSizes.medium, src: hotel.hotel.galleryImages?.[0]?.imageSizes.medium,
alt: alt:
hotel.galleryImages[0].metaData.altText || hotel.hotel.galleryImages?.[0]?.metaData.altText ||
hotel.galleryImages[0].metaData.altText_En, 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 styles from "./hotelListingItem.module.css"
import type { Hotel } from "@/types/hotel" import type { DestinationPagesHotelData } from "@/types/hotel"
interface HotelListingItemProps { export default function HotelListingItem(data: DestinationPagesHotelData) {
hotel: Hotel
url: string | null
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
const intl = useIntl() const intl = useIntl()
const params = useParams() const params = useParams()
const { hotel, url } = data
const { setActiveMarker } = useDestinationPageHotelsMapStore() const { setActiveMarker } = useDestinationPageHotelsMapStore()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || []) const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5) const amenities = hotel.detailedFacilities.slice(0, 5)
@@ -61,18 +54,16 @@ export default function HotelListingItem({
{ title: hotel.name } { title: hotel.name }
)} )}
/> />
{hotel.ratings?.tripAdvisor.rating && ( {hotel.tripadvisor && (
<div className={styles.tripAdvisor}> <div className={styles.tripAdvisor}>
<TripadvisorIcon color="Icon/Interactive/Default" /> <TripadvisorIcon color="Icon/Interactive/Default" />
<Caption color="burgundy"> <Caption color="burgundy">{hotel.tripadvisor}</Caption>
{hotel.ratings.tripAdvisor.rating}
</Caption>
</div> </div>
)} )}
</div> </div>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.intro}> <div className={styles.intro}>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} /> <HotelLogo hotelId={hotel.id} hotelType={hotel.hotelType} />
<Subtitle type="one" asChild> <Subtitle type="one" asChild>
<h3>{hotel.name}</h3> <h3>{hotel.name}</h3>
</Subtitle> </Subtitle>
@@ -93,7 +84,7 @@ export default function HotelListingItem({
</Caption> </Caption>
</div> </div>
</div> </div>
<Body>{hotel.hotelContent.texts.descriptions?.short}</Body> <Body>{hotel.hotelContent?.texts.descriptions?.short}</Body>
<ul className={styles.amenityList}> <ul className={styles.amenityList}>
{amenities.map((amenity) => { {amenities.map((amenity) => {
const Icon = ( const Icon = (
@@ -112,7 +103,7 @@ export default function HotelListingItem({
<Link <Link
href={mapUrl} href={mapUrl}
scroll={true} scroll={true}
onClick={() => setActiveMarker(hotel.operaId)} onClick={() => setActiveMarker(hotel.id)}
> >
{intl.formatMessage({ id: "See on map" })} {intl.formatMessage({ id: "See on map" })}
<MaterialIcon <MaterialIcon

View File

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

View File

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

View File

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

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 * Get terms and rate title from the rate definitions when booking code rate
@@ -12,7 +15,7 @@ export function getRateDefinition(
product: Product, product: Product,
rateDefinitions: RateDefinition[], rateDefinitions: RateDefinition[],
isUserLoggedIn: boolean, isUserLoggedIn: boolean,
isMainRoom: boolean, isMainRoom: boolean
) { ) {
return rateDefinitions.find((rateDefinition) => { return rateDefinitions.find((rateDefinition) => {
if ("member" in product && product.member && isUserLoggedIn && isMainRoom) { if ("member" in product && product.member && isUserLoggedIn && isMainRoom) {

View File

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

View File

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

View File

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

View File

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

View File

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

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 ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}": "Breakfast ({totalChildren, plural, one {# child} other {# children}}) x {totalBreakfasts}",
"Breakfast Restaurant": "Breakfast Restaurant", "Breakfast Restaurant": "Breakfast Restaurant",
"Breakfast buffet": "Breakfast buffet", "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 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 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 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 charge": "Breakfast charge",
"Breakfast deal can be purchased at the hotel.": "Breakfast deal can be purchased at the hotel.", "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() { export const getDestinationsMapData = cache(
return serverClient().hotel.hotels.getAllHotels.get() async function getMemoizedDestinationsMapData() {
}) return serverClient().hotel.hotels.getDestinationsMapData()
}
)
export const getDestinationCityPage = cache( export const getDestinationCityPage = cache(
async function getMemoizedDestinationCityPage() { async function getMemoizedDestinationCityPage() {
return serverClient().contentstack.destinationCityPage.get() return serverClient().contentstack.destinationCityPage.get()

View File

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

View File

@@ -12,6 +12,10 @@ import {
includedSchema, includedSchema,
relationshipsSchema as hotelRelationshipsSchema, relationshipsSchema as hotelRelationshipsSchema,
} from "./schemas/hotel" } 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 { locationCitySchema } from "./schemas/location/city"
import { locationHotelSchema } from "./schemas/location/hotel" import { locationHotelSchema } from "./schemas/location/hotel"
import { import {
@@ -657,3 +661,40 @@ export const roomFeaturesSchema = z
.transform((data) => { .transform((data) => {
return data.data.attributes.roomFeatures 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, breakfastPackageInputSchema,
cityCoordinatesInputSchema, cityCoordinatesInputSchema,
getAdditionalDataInputSchema, getAdditionalDataInputSchema,
getAllHotelsInput, getDestinationsMapDataInput,
getHotelsByCityIdentifierInput, getHotelsByCityIdentifierInput,
getHotelsByCountryInput, getHotelsByCountryInput,
getHotelsByCSFilterInput, getHotelsByCSFilterInput,
@@ -70,7 +70,7 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType" import { HotelTypeEnum } from "@/types/enums/hotelType"
import { RateTypeEnum } from "@/types/enums/rateType" import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelDataWithUrl } from "@/types/hotel" import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel"
import type { import type {
HotelsAvailabilityInputSchema, HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema,
@@ -1292,12 +1292,13 @@ export const hotelQueryRouter = router({
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel) return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
}), }),
}), }),
getAllHotels: router({ getDestinationsMapData: serviceProcedure
get: serviceProcedure.input(getAllHotelsInput).query(async function ({ .input(getDestinationsMapDataInput)
input, .query(async function ({ input, ctx }) {
ctx,
}) {
const lang = input?.lang ?? ctx.lang const lang = input?.lang ?? ctx.lang
const warmup = input?.warmup ?? false
const fetchHotels = async () => {
const countries = await getCountries({ const countries = await getCountries({
// Countries need to be in English regardless of incoming lang because // Countries need to be in English regardless of incoming lang because
// we use the names as input for API endpoints. // we use the names as input for API endpoints.
@@ -1310,7 +1311,7 @@ export const hotelQueryRouter = router({
} }
const countryNames = countries.data.map((country) => country.name) const countryNames = countries.data.map((country) => country.name)
const hotelData: HotelDataWithUrl[] = ( const hotelData: DestinationPagesHotelData[] = (
await Promise.all( await Promise.all(
countryNames.map(async (country) => { countryNames.map(async (country) => {
const hotelIds = await getHotelIdsByCountry({ const hotelIds = await getHotelIdsByCountry({
@@ -1327,10 +1328,23 @@ export const hotelQueryRouter = router({
}) })
) )
).flat() ).flat()
return hotelData return hotelData
}
if (warmup) {
return await fetchHotels()
}
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:getDestinationsMapData`,
fetchHotels,
"max"
)
}), }),
}), }),
}),
nearbyHotelIds: serviceProcedure nearbyHotelIds: serviceProcedure
.input(nearbyHotelIdsInput) .input(nearbyHotelIdsInput)
.query(async function ({ ctx, input }) { .query(async function ({ ctx, input }) {
@@ -1341,6 +1355,10 @@ export const hotelQueryRouter = router({
const params: Record<string, string | number> = { const params: Record<string, string | number> = {
language: apiLang, language: apiLang,
} }
const cacheClient = await getCacheClient()
return cacheClient.cacheOrGet(
`${apiLang}:nearbyHotels:${hotelId}`,
async () => {
metrics.nearbyHotelIds.counter.add(1, { metrics.nearbyHotelIds.counter.add(1, {
hotelId, hotelId,
}) })
@@ -1409,6 +1427,9 @@ export const hotelQueryRouter = router({
) )
return validateHotelData.data.map((id: string) => parseInt(id, 10)) return validateHotelData.data.map((id: string) => parseInt(id, 10))
},
env.CACHE_TIME_HOTELS
)
}), }),
locations: router({ locations: router({
get: serviceProcedure.input(getLocationsInput).query(async function ({ get: serviceProcedure.input(getLocationsInput).query(async function ({
@@ -1459,6 +1480,10 @@ export const hotelQueryRouter = router({
const { city, hotel } = input const { city, hotel } = input
async function fetchCoordinates(address: string) { async function fetchCoordinates(address: string) {
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 url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`
const response = await fetch(url) const response = await fetch(url)
const data = await response.json() const data = await response.json()
@@ -1475,6 +1500,9 @@ export const hotelQueryRouter = router({
} }
return location return location
},
"1d"
)
} }
let location = await fetchCoordinates(city) let location = await fetchCoordinates(city)

View File

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

View File

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

View File

@@ -10,10 +10,7 @@ export type RoomListItemProps = {
export type RoomListItemImageProps = Pick< export type RoomListItemImageProps = Pick<
RoomConfiguration, RoomConfiguration,
| "features" "features" | "roomType" | "roomTypeCode" | "roomsLeft"
| "roomType"
| "roomTypeCode"
| "roomsLeft"
> >
type RoomPackagePriceSchema = z.output<typeof packagePriceSchema> type RoomPackagePriceSchema = z.output<typeof packagePriceSchema>

View File

@@ -1,6 +1,9 @@
import type { z } from "zod" 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 { citySchema } from "@/server/routers/hotels/schemas/city"
import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel" import type { attributesSchema } from "@/server/routers/hotels/schemas/hotel"
import type { addressSchema } from "@/server/routers/hotels/schemas/hotel/address" 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 ExtraPageSchema = z.output<typeof extraPageSchema>
export type HotelDataWithUrl = HotelData & { url: string } 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 { SortItem } from "../components/destinationFilterAndSort"
import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage" import type { DestinationCityListItem } from "../trpc/routers/contentstack/destinationCityPage"
export interface DestinationDataProviderProps extends React.PropsWithChildren { export interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: HotelDataWithUrl[] allHotels: DestinationPagesHotelData[]
allCities?: DestinationCityListItem[] allCities?: DestinationCityListItem[]
sortItems: SortItem[] sortItems: SortItem[]
} }

View File

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