Merged in feat/SW-1454-hotel-listing-city-page (pull request #1250)

feat(SW-1454): added hotel listing

* feat(SW-1454): added hotel listing


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-02-05 13:10:28 +00:00
parent f3e6318d49
commit e3b1bfc414
27 changed files with 522 additions and 103 deletions

View File

@@ -19,7 +19,9 @@ export default async function CityListing({ cities }: CityListingProps) {
<div className={styles.listHeader}> <div className={styles.listHeader}>
<Subtitle type="two"> <Subtitle type="two">
{intl.formatMessage( {intl.formatMessage(
{ id: `{count} Locations` }, {
id: `{count, plural, one {{count} Location} other {{count} Locations}}`,
},
{ count: cities.length } { count: cities.length }
)} )}
</Subtitle> </Subtitle>

View File

@@ -16,7 +16,9 @@
.mainSection { .mainSection {
grid-area: mainSection; grid-area: mainSection;
padding-bottom: var(--Spacing-x7); padding-bottom: var(--Spacing-x7);
min-height: 500px; /* This is a temporary value because of no content atm */ max-width: var(--max-width-page);
width: 100%;
margin: 0 auto;
} }
.sidebar { .sidebar {

View File

@@ -7,9 +7,9 @@ import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/Bread
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
import { getIntl } from "@/i18n"
import ExperienceList from "../ExperienceList" import ExperienceList from "../ExperienceList"
import HotelListing from "../HotelListing"
import SidebarContentWrapper from "../SidebarContentWrapper" import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek" import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap" import StaticMap from "../StaticMap"
@@ -20,16 +20,13 @@ import styles from "./destinationCityPage.module.css"
import { PageContentTypeEnum } from "@/types/requests/contentType" import { PageContentTypeEnum } from "@/types/requests/contentType"
export default async function DestinationCityPage() { export default async function DestinationCityPage() {
const [intl, pageData] = await Promise.all([ const pageData = await getDestinationCityPage()
getIntl(),
getDestinationCityPage(),
])
if (!pageData) { if (!pageData) {
return null return null
} }
const { tracking, destinationCityPage } = pageData const { tracking, destinationCityPage, hotels } = pageData
const { const {
images, images,
heading, heading,
@@ -55,8 +52,7 @@ export default async function DestinationCityPage() {
/> />
</header> </header>
<main className={styles.mainSection}> <main className={styles.mainSection}>
{/* TODO: Add hotel listing by cityIdentifier */} <HotelListing hotels={hotels} />
{">>>> MAIN CONTENT <<<<"}
</main> </main>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<SidebarContentWrapper> <SidebarContentWrapper>

View File

@@ -0,0 +1,54 @@
.container {
background-color: var(--Base-Surface-Primary-light-Normal);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-Medium);
overflow: hidden;
}
.content {
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x3);
align-content: start;
justify-items: start;
}
.intro {
display: grid;
gap: var(--Spacing-x-half);
}
.captions {
display: flex;
gap: var(--Spacing-x1);
}
.amenityList {
display: flex;
gap: var(--Spacing-x-one-and-half);
flex-wrap: wrap;
color: var(--UI-Text-Medium-contrast);
font-family: var(--typography-Body-Regular-fontFamily);
font-size: var(--typography-Caption-Underline-fontSize);
}
.amenityItem {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
}
.ctaWrapper {
justify-self: stretch;
}
@media screen and (min-width: 768px) {
.container {
display: grid;
grid-template-columns: minmax(250px, 350px) auto;
}
.ctaWrapper {
justify-self: end;
}
}

View File

@@ -0,0 +1,101 @@
"use client"
import Link from "next/link"
import { useIntl } from "react-intl"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
import { ChevronRightSmallIcon } from "@/components/Icons"
import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { getSingleDecimal } from "@/utils/numberFormatting"
import styles from "./hotelListingItem.module.css"
import type { Hotel } from "@/types/hotel"
interface HotelListingItemProps {
hotel: Hotel
url: string | null
}
export default function HotelListingItem({
hotel,
url,
}: HotelListingItemProps) {
const intl = useIntl()
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const amenities = hotel.detailedFacilities.slice(0, 5)
return (
<article className={styles.container}>
<ImageGallery
images={galleryImages}
title={intl.formatMessage(
{ id: "{title} - Image gallery" },
{ title: hotel.name }
)}
/>
<div className={styles.content}>
<div className={styles.intro}>
<HotelLogo hotelId={hotel.operaId} hotelType={hotel.hotelType} />
<Subtitle type="one" asChild>
<h3>{hotel.name}</h3>
</Subtitle>
<div className={styles.captions}>
<Caption color="uiTextPlaceholder">
{hotel.address.streetAddress}
</Caption>
<Divider variant="vertical" color="beige" />
<Caption color="uiTextPlaceholder">
{intl.formatMessage(
{ id: "{number} km to city center" },
{
number: getSingleDecimal(
hotel.location.distanceToCentre / 1000
),
}
)}
</Caption>
</div>
</div>
<Body>{hotel.hotelContent.texts.descriptions.short}</Body>
<ul className={styles.amenityList}>
{amenities.map((amenity) => {
const IconComponent = mapFacilityToIcon(amenity.id)
return (
<li className={styles.amenityItem} key={amenity.id}>
{IconComponent && (
<IconComponent color="grey80" width={20} height={20} />
)}
{amenity.name}
</li>
)
})}
</ul>
<Button intent="text" variant="icon" theme="base">
{intl.formatMessage({ id: "See on map" })}
<ChevronRightSmallIcon />
</Button>
{url && (
<>
<Divider variant="horizontal" color="primaryLightSubtle" />
<div className={styles.ctaWrapper}>
<Button intent="tertiary" theme="base" size="small" asChild>
<Link href={url}>
{intl.formatMessage({ id: "See hotel details" })}
</Link>
</Button>
</div>
</>
)}
</div>
</article>
)
}

View File

@@ -0,0 +1,32 @@
.container {
--scroll-margin-top: calc(
var(--booking-widget-mobile-height) + var(--Spacing-x2)
);
position: relative;
display: grid;
gap: var(--Spacing-x2);
scroll-margin-top: var(--scroll-margin-top);
}
.listHeader {
display: flex;
justify-content: space-between;
}
.hotelList {
list-style: none;
display: grid;
gap: var(--Spacing-x2);
}
.hotelList:not(.allVisible) li:nth-child(n + 6) {
display: none;
}
@media screen and (min-width: 768px) {
.container {
--scroll-margin-top: calc(
var(--booking-widget-desktop-height) + var(--Spacing-x2)
);
}
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useRef, useState } from "react"
import { useIntl } from "react-intl"
import { BackToTopButton } from "@/components/TempDesignSystem/BackToTopButton"
import ShowMoreButton from "@/components/TempDesignSystem/ShowMoreButton"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { useScrollToTop } from "@/hooks/useScrollToTop"
import HotelListingItem from "./HotelListingItem"
import styles from "./hotelListing.module.css"
import type { HotelData } from "@/types/hotel"
interface HotelListingProps {
hotels: (HotelData & { url: string | null })[]
}
export default function HotelListing({ hotels }: HotelListingProps) {
const intl = useIntl()
const scrollRef = useRef<HTMLDivElement>(null)
const showToggleButton = hotels.length > 5
const [allHotelsVisible, setAllHotelsVisible] = useState(!showToggleButton)
const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 300,
elementRef: scrollRef,
})
function handleShowMore() {
if (scrollRef.current && allHotelsVisible) {
scrollRef.current.scrollIntoView({ behavior: "smooth" })
}
setAllHotelsVisible((state) => !state)
}
return (
<section className={styles.container} ref={scrollRef}>
<div className={styles.listHeader}>
<Subtitle type="two">
{intl.formatMessage(
{
id: `{count, plural, one {{count} Hotel} other {{count} Hotels}}`,
},
{ count: hotels.length }
)}
</Subtitle>
</div>
<ul
className={`${styles.hotelList} ${allHotelsVisible ? styles.allVisible : ""}`}
>
{hotels.map(({ hotel, url }) => (
<li key={hotel.name}>
<HotelListingItem hotel={hotel} url={url} />
</li>
))}
</ul>
{showToggleButton ? (
<ShowMoreButton
loadMoreData={handleShowMore}
showLess={allHotelsVisible}
textShowMore={intl.formatMessage({
id: "Show more",
})}
textShowLess={intl.formatMessage({
id: "Show less",
})}
/>
) : null}
{showBackToTop && (
<BackToTopButton position="center" onClick={scrollToTop} />
)}
</section>
)
}

View File

@@ -51,6 +51,7 @@ export default function SelectHotelContent({
const { showBackToTop, scrollToTop } = useScrollToTop({ const { showBackToTop, scrollToTop } = useScrollToTop({
threshold: 490, threshold: 490,
elementRef: listingContainerRef, elementRef: listingContainerRef,
refScrollable: true,
}) })
const coordinates = useMemo( const coordinates = useMemo(

View File

@@ -13,6 +13,7 @@
} }
.triggerArea { .triggerArea {
position: relative;
display: flex; display: flex;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
@@ -21,6 +22,7 @@
.image { .image {
width: 100%; width: 100%;
height: 100%;
object-fit: cover; object-fit: cover;
} }

View File

@@ -35,6 +35,11 @@
right: 32px; right: 32px;
} }
.center {
left: 50%;
transform: translateX(-50%);
}
@media (min-width: 768px) { @media (min-width: 768px) {
.backToTopButtonText { .backToTopButtonText {
display: initial; display: initial;

View File

@@ -14,7 +14,7 @@ export function BackToTopButton({
position, position,
}: { }: {
onClick: () => void onClick: () => void
position: "left" | "right" position: "left" | "right" | "center"
}) { }) {
const intl = useIntl() const intl = useIntl()
return ( return (

View File

@@ -7,6 +7,7 @@ export const backToTopButtonVariants = cva(styles.backToTopButton, {
position: { position: {
left: styles.left, left: styles.left,
right: styles.right, right: styles.right,
center: styles.center,
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -3,29 +3,43 @@ import { type RefObject, useEffect, useState } from "react"
interface UseScrollToTopProps { interface UseScrollToTopProps {
threshold: number threshold: number
elementRef?: RefObject<HTMLElement> elementRef?: RefObject<HTMLElement>
refScrollable?: boolean
} }
export function useScrollToTop({ threshold, elementRef }: UseScrollToTopProps) { export function useScrollToTop({
threshold,
elementRef,
refScrollable,
}: UseScrollToTopProps) {
const [showBackToTop, setShowBackToTop] = useState(false) const [showBackToTop, setShowBackToTop] = useState(false)
useEffect(() => { useEffect(() => {
const element = elementRef?.current ?? window const element =
refScrollable && elementRef?.current ? elementRef?.current : window
function handleScroll() { function handleScroll() {
const scrollTop = elementRef?.current let position = window.scrollY
? elementRef.current.scrollTop if (elementRef?.current) {
: window.scrollY position = refScrollable
setShowBackToTop(scrollTop > threshold) ? elementRef.current.scrollTop
: elementRef.current.getBoundingClientRect().top * -1
}
setShowBackToTop(position > threshold)
} }
element.addEventListener("scroll", handleScroll, { passive: true }) element.addEventListener("scroll", handleScroll, { passive: true })
return () => element.removeEventListener("scroll", handleScroll) return () => element.removeEventListener("scroll", handleScroll)
}, [threshold, elementRef]) }, [threshold, elementRef, refScrollable])
function scrollToTop() { function scrollToTop() {
if (elementRef?.current) if (elementRef?.current) {
elementRef.current.scrollTo({ top: 0, behavior: "smooth" }) if (refScrollable) {
else window.scrollTo({ top: 0, behavior: "smooth" }) elementRef.current.scrollTo({ top: 0, behavior: "smooth" })
}
window.scrollTo({ top: elementRef.current.offsetTop, behavior: "smooth" })
} else {
window.scrollTo({ top: 0, behavior: "smooth" })
}
} }
return { showBackToTop, scrollToTop } return { showBackToTop, scrollToTop }

View File

@@ -565,7 +565,8 @@
"{card} ending with {cardno}": "{card} slutter med {cardno}", "{card} ending with {cardno}": "{card} slutter med {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}",
"{count} Locations": "{count} steder", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {# hotel} other {# hoteller}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {# sted} other {# steder}}",
"{count} lowercase letter": "{count} lille bogstav", "{count} lowercase letter": "{count} lille bogstav",
"{count} number": "{count} nummer", "{count} number": "{count} nummer",
"{count} special character": "{count} speciel karakter", "{count} special character": "{count} speciel karakter",

View File

@@ -563,7 +563,8 @@
"{card} ending with {cardno}": "{card} endet mit {cardno}", "{card} ending with {cardno}": "{card} endet mit {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} aus {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} aus {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} aus {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} aus {checkOutTime}",
"{count} Locations": "{count} Standorte", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {{count} Hotel} other {{count} Hotels}}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {# Standort} other {# Standorte}}",
"{count} lowercase letter": "{count} Kleinbuchstabe", "{count} lowercase letter": "{count} Kleinbuchstabe",
"{count} number": "{count} nummer", "{count} number": "{count} nummer",
"{count} special character": "{count} sonderzeichen", "{count} special character": "{count} sonderzeichen",

View File

@@ -615,7 +615,8 @@
"{card} ending with {cardno}": "{card} ending with {cardno}", "{card} ending with {cardno}": "{card} ending with {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} from {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} from {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} from {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} from {checkOutTime}",
"{count} Locations": "{count} Locations", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {{count} Hotel} other {{count} Hotels}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {{count} Location} other {{count} Locations}}",
"{count} lowercase letter": "{count} lowercase letter", "{count} lowercase letter": "{count} lowercase letter",
"{count} number": "{count} number", "{count} number": "{count} number",
"{count} special character": "{count} special character", "{count} special character": "{count} special character",

View File

@@ -563,7 +563,8 @@
"{card} ending with {cardno}": "{card} päättyen {cardno}", "{card} ending with {cardno}": "{card} päättyen {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} alkaen {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} alkaen {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} alkaen {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} alkaen {checkOutTime}",
"{count} Locations": "{count} sijaintia", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {# hotelli} other {# hotellit}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {# sijainti} other {# sijainnit}}",
"{count} lowercase letter": "{count} pien kirjain", "{count} lowercase letter": "{count} pien kirjain",
"{count} number": "{count} määrä", "{count} number": "{count} määrä",
"{count} special character": "{count} erikoishahmo", "{count} special character": "{count} erikoishahmo",

View File

@@ -564,7 +564,8 @@
"{card} ending with {cardno}": "{card} slutter med {cardno}", "{card} ending with {cardno}": "{card} slutter med {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} fra {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} fra {checkOutTime}",
"{count} Locations": "{count} steder", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {# hotell} other {# hoteller}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {# sted} other {# steder}}",
"{count} lowercase letter": "{count} liten bokstav", "{count} lowercase letter": "{count} liten bokstav",
"{count} number": "{count} antall", "{count} number": "{count} antall",
"{count} special character": "{count} spesiell karakter", "{count} special character": "{count} spesiell karakter",

View File

@@ -566,7 +566,8 @@
"{card} ending with {cardno}": "{card} som slutar på {cardno}", "{card} ending with {cardno}": "{card} som slutar på {cardno}",
"{checkInDate} from {checkInTime}": "{checkInDate} från {checkInTime}", "{checkInDate} from {checkInTime}": "{checkInDate} från {checkInTime}",
"{checkOutDate} from {checkOutTime}": "{checkOutDate} från {checkOutTime}", "{checkOutDate} from {checkOutTime}": "{checkOutDate} från {checkOutTime}",
"{count} Locations": "{count} platser", "{count, plural, one {{count} Hotel} other {{count} Hotels}}": "{count, plural, one {# hotell} other {# hotell}}",
"{count, plural, one {{count} Location} other {{count} Locations}}": "{count, plural, one {# plats} other {# platser}}",
"{count} lowercase letter": "{count} liten bokstav", "{count} lowercase letter": "{count} liten bokstav",
"{count} number": "{count} nummer", "{count} number": "{count} nummer",
"{count} special character": "{count} speciell karaktär", "{count} special character": "{count} speciell karaktär",

View File

@@ -4,7 +4,7 @@ import {
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql" } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc" import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc" import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
@@ -20,7 +20,7 @@ import {
getDestinationCityPageRefsSuccessCounter, getDestinationCityPageRefsSuccessCounter,
getDestinationCityPageSuccessCounter, getDestinationCityPageSuccessCounter,
} from "./telemetry" } from "./telemetry"
import { generatePageTags } from "./utils" import { generatePageTags, getHotelListData } from "./utils"
import type { import type {
GetDestinationCityPageData, GetDestinationCityPageData,
@@ -28,8 +28,8 @@ import type {
} from "@/types/trpc/routers/contentstack/destinationCityPage" } from "@/types/trpc/routers/contentstack/destinationCityPage"
export const destinationCityPageQueryRouter = router({ export const destinationCityPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => { get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid } = ctx const { lang, uid, serviceToken } = ctx
getDestinationCityPageRefsCounter.add(1, { lang, uid }) getDestinationCityPageRefsCounter.add(1, { lang, uid })
console.info( console.info(
@@ -128,27 +128,31 @@ export const destinationCityPageQueryRouter = router({
throw notFoundError throw notFoundError
} }
const validatedDestinationCityPage = destinationCityPageSchema.safeParse( const validatedResponse = destinationCityPageSchema.safeParse(response.data)
response.data
)
if (!validatedDestinationCityPage.success) { if (!validatedResponse.success) {
getDestinationCityPageFailCounter.add(1, { getDestinationCityPageFailCounter.add(1, {
lang, lang,
uid: `${uid}`, uid: `${uid}`,
error_type: "validation_error", error_type: "validation_error",
error: JSON.stringify(validatedDestinationCityPage.error), error: JSON.stringify(validatedResponse.error),
}) })
console.error( console.error(
"contentstack.destinationCityPage validation error", "contentstack.destinationCityPage validation error",
JSON.stringify({ JSON.stringify({
query: { lang, uid }, query: { lang, uid },
error: validatedDestinationCityPage.error, error: validatedResponse.error,
}) })
) )
return null return null
} }
const hotels = await getHotelListData(
lang,
serviceToken,
validatedResponse.data.destinationCityPage.destination_settings.city
)
getDestinationCityPageSuccessCounter.add(1, { lang, uid: `${uid}` }) getDestinationCityPageSuccessCounter.add(1, { lang, uid: `${uid}` })
console.info( console.info(
"contentstack.destinationCityPage success", "contentstack.destinationCityPage success",
@@ -157,6 +161,9 @@ export const destinationCityPageQueryRouter = router({
}) })
) )
return validatedDestinationCityPage.data return {
...validatedResponse.data,
hotels,
}
}), }),
}) })

View File

@@ -1,5 +1,10 @@
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag" import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { getHotel } from "../../hotels/query"
import { getHotelIdsByCityIdentifier } from "../../hotels/utils"
import { getHotelPageUrl } from "../hotelPage/utils"
import type { HotelData } from "@/types/hotel"
import type { System } from "@/types/requests/system" import type { System } from "@/types/requests/system"
import type { GetDestinationCityPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCityPage" import type { GetDestinationCityPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
@@ -29,3 +34,29 @@ export function getConnections({
return connections return connections
} }
export async function getHotelListData(
lang: Lang,
serviceToken: string,
cityIdentifier: string
) {
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const [hotelData, url] = await Promise.all([
getHotel({ hotelId, language: lang }, serviceToken),
getHotelPageUrl(lang, hotelId),
])
return hotelData ? { ...hotelData, url } : null
})
)
return hotels.filter(
(hotel): hotel is HotelData & { url: string | null } => !!hotel
)
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env/server"
import { import {
GetDestinationCountryPage, GetDestinationCountryPage,
GetDestinationCountryPageRefs, GetDestinationCountryPageRefs,
@@ -10,7 +9,6 @@ import { toApiLang } from "@/server/utils"
import { generateTag } from "@/utils/generateTag" import { generateTag } from "@/utils/generateTag"
import { getCitiesByCountry } from "../../hotels/utils"
import { import {
destinationCountryPageRefsSchema, destinationCountryPageRefsSchema,
destinationCountryPageSchema, destinationCountryPageSchema,
@@ -23,10 +21,9 @@ import {
getDestinationCountryPageRefsSuccessCounter, getDestinationCountryPageRefsSuccessCounter,
getDestinationCountryPageSuccessCounter, getDestinationCountryPageSuccessCounter,
} from "./telemetry" } from "./telemetry"
import { generatePageTags, getCityListDataByCityIdentifier } from "./utils" import { generatePageTags, getCityPages } from "./utils"
import { ApiCountry } from "@/types/enums/country" import { ApiCountry } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { import type {
GetDestinationCountryPageData, GetDestinationCountryPageData,
GetDestinationCountryPageRefsSchema, GetDestinationCountryPageRefsSchema,
@@ -155,44 +152,10 @@ export const destinationCountryPageQueryRouter = router({
return null return null
} }
const params = new URLSearchParams({ const cities = await getCityPages(
language: apiLang,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const selectedCountry =
validatedResponse.data.destinationCountryPage.destination_settings.country
const apiCountry = ApiCountry[lang][selectedCountry]
const cities = await getCitiesByCountry(
[apiCountry],
options,
params,
lang, lang,
true, serviceToken,
"destinationCountryPage" validatedResponse.data.destinationCountryPage.destination_settings.country
)
const cityPages = await Promise.all(
cities[apiCountry].map(async (city) => {
if (!city.cityIdentifier) {
return null
}
const data = await getCityListDataByCityIdentifier(
lang,
city.cityIdentifier
)
return data ? { ...data, cityName: city.name } : null
})
) )
getDestinationCountryPageSuccessCounter.add(1, { lang, uid: `${uid}` }) getDestinationCountryPageSuccessCounter.add(1, { lang, uid: `${uid}` })
@@ -205,10 +168,12 @@ export const destinationCountryPageQueryRouter = router({
return { return {
...validatedResponse.data, ...validatedResponse.data,
translatedCountry: apiCountry, translatedCountry:
cities: cityPages ApiCountry[lang][
.flat() validatedResponse.data.destinationCountryPage.destination_settings
.filter((city): city is NonNullable<typeof city> => !!city), .country
],
cities,
} }
}), }),
}) })

View File

@@ -1,8 +1,11 @@
import { env } from "@/env/server"
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql" import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
import { request } from "@/lib/graphql/request" import { request } from "@/lib/graphql/request"
import { toApiLang } from "@/server/utils"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag" import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { getCitiesByCountry } from "../../hotels/utils"
import { destinationCityListDataSchema } from "../destinationCityPage/output" import { destinationCityListDataSchema } from "../destinationCityPage/output"
import { import {
getCityListDataCounter, getCityListDataCounter,
@@ -10,6 +13,8 @@ import {
getCityListDataSuccessCounter, getCityListDataSuccessCounter,
} from "./telemetry" } from "./telemetry"
import { ApiCountry, type Country } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { System } from "@/types/requests/system" import type { System } from "@/types/requests/system"
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage" import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { GetDestinationCountryPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCountryPage" import type { GetDestinationCountryPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCountryPage"
@@ -108,3 +113,47 @@ export async function getCityListDataByCityIdentifier(
return validatedResponse.data return validatedResponse.data
} }
export async function getCityPages(
lang: Lang,
serviceToken: string,
country: Country
) {
const apiLang = toApiLang(lang)
const params = new URLSearchParams({
language: apiLang,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const apiCountry = ApiCountry[lang][country]
const cities = await getCitiesByCountry([apiCountry], options, params, lang)
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
const cityPages = await Promise.all(
publishedCities.map(async (city) => {
if (!city.cityIdentifier) {
return null
}
const data = await getCityListDataByCityIdentifier(
lang,
city.cityIdentifier
)
return data ? { ...data, cityName: city.name } : null
})
)
return cityPages
.flat()
.filter((city): city is NonNullable<typeof city> => !!city)
}

View File

@@ -251,16 +251,23 @@ export const packagesSchema = z
}) })
.transform(({ data }) => data?.attributes.packages) .transform(({ data }) => data?.attributes.packages)
export const getHotelIdsByCityIdSchema = z export const getHotelIdsSchema = z
.object({ .object({
data: z.array( data: z.array(
z.object({ z.object({
// We only care about the hotel id attributes: z.object({
isPublished: z.boolean(),
}),
id: z.string(), id: z.string(),
}) })
), ),
}) })
.transform((data) => data.data.map((hotel) => hotel.id)) .transform(({ data }) => {
const filteredHotels = data.filter(
(hotel) => !!hotel.attributes.isPublished
)
return filteredHotels.map((hotel) => hotel.id)
})
export const getNearbyHotelIdsSchema = z export const getNearbyHotelIdsSchema = z
.object({ .object({

View File

@@ -91,7 +91,7 @@ export const getHotel = cache(
}) })
console.info( console.info(
"api.hotels.hotelData start", "api.hotels.hotelData start",
JSON.stringify({ query: { hotelId, params } }) JSON.stringify({ query: { hotelId, params: params.toString() } })
) )
const apiResponse = await api.get( const apiResponse = await api.get(
@@ -126,7 +126,7 @@ export const getHotel = cache(
console.error( console.error(
"api.hotels.hotelData error", "api.hotels.hotelData error",
JSON.stringify({ JSON.stringify({
query: { hotelId, params }, query: { hotelId, params: params.toString() },
error: { error: {
status: apiResponse.status, status: apiResponse.status,
statusText: apiResponse.statusText, statusText: apiResponse.statusText,
@@ -151,7 +151,7 @@ export const getHotel = cache(
console.error( console.error(
"api.hotels.hotelData validation error", "api.hotels.hotelData validation error",
JSON.stringify({ JSON.stringify({
query: { hotelId, params }, query: { hotelId, params: params.toString() },
error: validateHotelData.error, error: validateHotelData.error,
}) })
) )
@@ -165,7 +165,7 @@ export const getHotel = cache(
console.info( console.info(
"api.hotels.hotelData success", "api.hotels.hotelData success",
JSON.stringify({ JSON.stringify({
query: { hotelId, params: params }, query: { hotelId, params: params.toString() },
}) })
) )
const hotelData = validateHotelData.data const hotelData = validateHotelData.data

View File

@@ -30,7 +30,7 @@ export const restaurantsOverviewPageSchema = z.object({
}) })
export const extraPageSchema = z.object({ export const extraPageSchema = z.object({
elevatorPitch: z.string().optional(), elevatorPitch: z.string().default(""),
mainBody: z.string().optional(), mainBody: z.string().optional(),
}) })

View File

@@ -1,14 +1,17 @@
import deepmerge from "deepmerge" import deepmerge from "deepmerge"
import { unstable_cache } from "next/cache" import { unstable_cache } from "next/cache"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api" import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { metrics } from "./metrics" import { metrics } from "./metrics"
import { import {
citiesByCountrySchema, citiesByCountrySchema,
citiesSchema, citiesSchema,
countriesSchema, countriesSchema,
getHotelIdsByCityIdSchema, getHotelIdsSchema,
locationsSchema, locationsSchema,
} from "./output" } from "./output"
@@ -17,10 +20,9 @@ import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { RequestOptionsWithOutBody } from "@/types/fetch" import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { import type {
CitiesGroupedByCountry, CitiesGroupedByCountry,
Countries, CityLocation,
HotelLocation, HotelLocation,
} from "@/types/trpc/routers/hotel/locations" } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints" import type { Endpoint } from "@/lib/api/endpoints"
export function getPoiGroupByCategoryName(category: string | undefined) { export function getPoiGroupByCategoryName(category: string | undefined) {
@@ -305,11 +307,11 @@ export async function getHotelIdsByCityId(
}) })
) )
return null return []
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson) const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) { if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, { metrics.hotelIds.fail.add(1, {
params: params.toString(), params: params.toString(),
@@ -323,19 +325,22 @@ export async function getHotelIdsByCityId(
error: validatedHotelIds.error, error: validatedHotelIds.error,
}) })
) )
return null return []
} }
metrics.hotelIds.success.add(1, { cityId }) metrics.hotelIds.success.add(1, { cityId })
console.info( console.info(
"api.hotel.hotel-ids success", "api.hotel.hotel-ids success",
JSON.stringify({ params: params.toString() }) JSON.stringify({
params: params.toString(),
response: validatedHotelIds.data,
})
) )
return validatedHotelIds.data return validatedHotelIds.data
}, },
[`hotelsByCityId`, params.toString()], [`hotelsByCityId`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS } { revalidate: env.CACHE_TIME_HOTELS }
)(params) )(params)
} }
@@ -376,11 +381,11 @@ export async function getHotelIdsByCountry(
}) })
) )
return null return []
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson) const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) { if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, { metrics.hotelIds.fail.add(1, {
country, country,
@@ -394,7 +399,7 @@ export async function getHotelIdsByCountry(
error: validatedHotelIds.error, error: validatedHotelIds.error,
}) })
) )
return null return []
} }
metrics.hotelIds.success.add(1, { country }) metrics.hotelIds.success.add(1, { country })
@@ -406,6 +411,69 @@ export async function getHotelIdsByCountry(
return validatedHotelIds.data return validatedHotelIds.data
}, },
[`hotelsByCountry`, params.toString()], [`hotelsByCountry`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS } { revalidate: env.CACHE_TIME_HOTELS }
)(params) )(params)
} }
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const apiLang = toApiLang(Lang.en)
const cityId = await getCityIdByCityIdentifier(cityIdentifier, serviceToken)
if (!cityId) {
return []
}
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: cityId,
onlyBasicInfo: "true",
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIds = await getHotelIdsByCityId(cityId, options, hotelIdsParams)
return hotelIds
}
export async function getCityIdByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const lang = Lang.en
const apiLang = toApiLang(lang)
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const params = new URLSearchParams({
language: apiLang,
})
const locations = await getLocations(lang, options, params, null)
if (!locations || "error" in locations) {
return null
}
const cityId = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === cityIdentifier)?.id
return cityId ?? null
}