Merged in feat/SW-1444-destination-page-add-destination-list-component (pull request #1240)

feat/SW-1444 destination page add destination list component

* feat(SW-1444): add list component

* feat(SW-1444): add subtitle to accordion

* feat(SW-1444): refactor component structure

* feat(SW-1444): add desktop breakpoint

* feat(SW-1444): fix typo

* feat(SW-1444): add props

* feat(SW-1444): add query

* feat(SW-1444): updated query

* feat(SW-1444): display data

* feat(SW-1444): fix merge hickup

* feat(SW-1444): change var name

* feat(SW-1444): remove unsued translations

* feat(SW-1444): use country as title

* feat(SW-1444): sort hotels in query

* feat(SW-1444): make responsive

* feat(SW-1444): fetch country url

* feat(SW-1444): update logging

* feat(SW-1444): remove spread


Approved-by: Erik Tiekstra
This commit is contained in:
Fredrik Thorsson
2025-02-04 14:17:12 +00:00
parent 4ed4b3585b
commit b85a3a57ec
27 changed files with 489 additions and 21 deletions

View File

@@ -0,0 +1,19 @@
.container {
display: grid;
gap: var(--Spacing-x3);
}
.citiesList {
column-count: 2;
list-style-type: none;
margin-bottom: var(--Spacing-x-half);
}
.citiesList li {
margin-bottom: var(--Spacing-x-one-and-half);
}
@media screen and (min-width: 1367px) {
.citiesList {
column-count: 3;
}
}

View File

@@ -0,0 +1,50 @@
import { ChevronRightSmallIcon } from "@/components/Icons"
import AccordionItem from "@/components/TempDesignSystem/Accordion/AccordionItem"
import Link from "@/components/TempDesignSystem/Link"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import styles from "./destination.module.css"
import type { DestinationProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
export default async function Destination({
country,
countryUrl,
numberOfHotels,
cities,
}: DestinationProps) {
const intl = await getIntl()
const accordionSubtitle = intl.formatMessage(
{
id: "{amount, plural, one {# hotel} other {# hotels}}",
},
{ amount: numberOfHotels }
)
return (
<AccordionItem title={country} subtitle={accordionSubtitle}>
<div className={styles.container}>
<ul className={styles.citiesList}>
{cities.map((city) => (
<li key={city.id}>
<Link
href={city.url ? city.url : ""}
color="baseTextMediumContrast"
textDecoration="underline"
>
{`${city.name} (${city.hotelCount})`}
</Link>
</li>
))}
</ul>
{countryUrl && (
<Link href={countryUrl} variant="icon" color="burgundy" weight="bold">
{intl.formatMessage({ id: "See destination" })}
<ChevronRightSmallIcon color="burgundy" />
</Link>
)}
</div>
</AccordionItem>
)
}

View File

@@ -0,0 +1,27 @@
.listContainer {
display: flex;
flex-direction: column;
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
}
.accordion {
flex: 1;
height: fit-content;
}
@media screen and (min-width: 768px) {
.listContainer {
gap: var(--Spacing-x3);
background-color: transparent;
flex-direction: row;
}
.accordion {
background-color: var(--Base-Surface-Primary-light-Normal);
}
.divider {
display: none;
}
}

View File

@@ -0,0 +1,44 @@
import Accordion from "@/components/TempDesignSystem/Accordion"
import Divider from "@/components/TempDesignSystem/Divider"
import Destination from "./Destination"
import styles from "./destinationsList.module.css"
import type { DestinationsListProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
export default function DestinationsList({
destinations,
}: DestinationsListProps) {
const middleIndex = Math.ceil(destinations.length / 2)
const accordionLeft = destinations.slice(0, middleIndex)
const accordionRight = destinations.slice(middleIndex)
return (
<div className={styles.listContainer}>
<Accordion className={styles.accordion}>
{accordionLeft.map((data) => (
<Destination
key={data.country}
country={data.country}
countryUrl={data.countryUrl}
numberOfHotels={data.numberOfHotels}
cities={data.cities}
/>
))}
</Accordion>
<Divider color="subtle" className={styles.divider} />
<Accordion className={styles.accordion}>
{accordionRight.map((data) => (
<Destination
key={data.country}
country={data.country}
countryUrl={data.countryUrl}
numberOfHotels={data.numberOfHotels}
cities={data.cities}
/>
))}
</Accordion>
</div>
)
}

View File

@@ -0,0 +1,12 @@
.container {
display: grid;
gap: var(--Spacing-x4);
padding: var(--Spacing-x5) var(--Spacing-x2) var(--Spacing-x7);
}
@media screen and (min-width: 768px) {
.container {
gap: var(--Spacing-x7);
padding: var(--Spacing-x5) 9.625rem var(--Spacing-x9) 9.625rem;
}
}

View File

@@ -0,0 +1,23 @@
import Title from "@/components/TempDesignSystem/Text/Title"
import { getIntl } from "@/i18n"
import DestinationsList from "./DestinationsList"
import styles from "./hotelsSection.module.css"
import type { HotelsSectionProps } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
export default async function HotelsSection({
destinations,
}: HotelsSectionProps) {
const intl = await getIntl()
return (
<section className={styles.container}>
<Title level="h4" as="h2" textAlign="center">
{intl.formatMessage({ id: "Explore all our hotels" })}
</Title>
<DestinationsList destinations={destinations} />
</section>
)
}

View File

@@ -1,6 +1,7 @@
.pageContainer {
position: relative;
display: grid;
width: 100%;
max-width: var(--max-width);
}

View File

@@ -1,16 +1,23 @@
import { Suspense } from "react"
import { env } from "@/env/server"
import { getDestinationOverviewPage } from "@/lib/trpc/memoizedRequests"
import {
getDestinationOverviewPage,
getDestinationsList,
} from "@/lib/trpc/memoizedRequests"
import TrackingSDK from "@/components/TrackingSDK"
import HotelsSection from "./HotelsSection"
import OverviewMapContainer from "./OverviewMapContainer"
import styles from "./destinationOverviewPage.module.css"
export default async function DestinationOverviewPage() {
const pageData = await getDestinationOverviewPage()
const [pageData, destinationsData] = await Promise.all([
getDestinationOverviewPage(),
getDestinationsList(),
])
if (!pageData) {
return null
@@ -27,7 +34,7 @@ export default async function DestinationOverviewPage() {
{googleMapsApiKey ? (
<OverviewMapContainer apiKey={googleMapsApiKey} mapId={googleMapId} />
) : null}
<h1>Destination Overview Page</h1>
{destinationsData && <HotelsSection destinations={destinationsData} />}
</div>
<Suspense fallback={null}>
<TrackingSDK pageData={tracking} />

View File

@@ -9,4 +9,5 @@ export interface AccordionItemProps
title: string
icon?: IconName
trackingId?: string
subtitle?: string
}

View File

@@ -22,6 +22,7 @@ export default function AccordionItem({
variant,
className,
trackingId,
subtitle,
}: AccordionItemProps) {
const contentRef = useRef<HTMLDivElement>(null)
const detailsRef = useRef<HTMLDetailsElement>(null)
@@ -55,9 +56,7 @@ export default function AccordionItem({
<li className={accordionItemVariants({ className, variant, theme })}>
<details ref={detailsRef} onToggle={toggleAccordion}>
<summary className={styles.summary}>
{IconComp && (
<IconComp className={styles.icon} color="baseTextHighcontrast" />
)}
{IconComp && <IconComp color="baseTextHighcontrast" />}
{variant === "sidepeek" ? (
<Subtitle
className={styles.title}
@@ -67,13 +66,18 @@ export default function AccordionItem({
{title}
</Subtitle>
) : (
<Body
textTransform="bold"
color="baseTextHighContrast"
className={styles.title}
>
{title}
</Body>
<div className={styles.title}>
{subtitle ? (
<Subtitle type="two" color="baseTextHighContrast">
{title}
</Subtitle>
) : (
<Body textTransform="bold" color="baseTextHighContrast">
{title}
</Body>
)}
{subtitle && <Body color="baseTextHighContrast">{subtitle}</Body>}
</div>
)}
<ChevronDownIcon
className={styles.chevron}

View File

@@ -154,6 +154,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore all our hotels": "Udforsk alle vores hoteller",
"Explore nearby": "Udforsk i nærheden",
"Explore {city}": "Udforsk {city}",
"Extra bed (child) × {count}": "Ekstra seng (barn) × {count}",
@@ -396,6 +397,7 @@
"Search": "Søge",
"See all FAQ": "Se alle FAQ",
"See all photos": "Se alle billeder",
"See destination": "Se destination",
"See details": "Se detaljer",
"See hotel details": "Se hoteloplysninger",
"See less FAQ": "Se mindre FAQ",

View File

@@ -153,6 +153,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Explore all our hotels": "Entdecken Sie alle unsere Hotels",
"Explore nearby": "Erkunden Sie die Umgebung",
"Explore {city}": "Erkunden Sie {city}",
"Extra bed (child) × {count}": "Ekstra seng (Kind) × {count}",
@@ -395,6 +396,7 @@
"Search": "Suchen",
"See all FAQ": "Siehe alle FAQ",
"See all photos": "Alle Fotos ansehen",
"See destination": "Siehe Ziel",
"See details": "Siehe Einzelheiten",
"See hotel details": "Hotelinformationen ansehen",
"See less FAQ": "Weniger anzeigen FAQ",

View File

@@ -166,6 +166,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Explore all levels and benefits",
"Explore all our hotels": "Explore all our hotels",
"Explore nearby": "Explore nearby",
"Explore {city}": "Explore {city}",
"Extra bed (child) × {count}": "Extra bed (child) × {count}",
@@ -438,6 +439,7 @@
"See all FAQ": "See all FAQ",
"See all photos": "See all photos",
"See alternative hotels": "See alternative hotels",
"See destination": "See destination",
"See details": "See details",
"See hotel details": "See hotel details",
"See less FAQ": "See less FAQ",

View File

@@ -154,6 +154,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore all our hotels": "Tutustu kaikkiin hotelleihimme",
"Explore nearby": "Tutustu lähialueeseen",
"Explore {city}": "Tutustu {city}",
"Extra bed (child) × {count}": "Lisävuode (lasta) × {count}",
@@ -398,6 +399,7 @@
"Search": "Haku",
"See all FAQ": "Katso kaikki UKK",
"See all photos": "Katso kaikki kuvat",
"See destination": "Katso kohde",
"See details": "Katso tiedot",
"See hotel details": "Katso hotellin tiedot",
"See less FAQ": "Katso vähemmän UKK",

View File

@@ -153,6 +153,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Explore all our hotels": "Utforsk alle våre hoteller",
"Explore nearby": "Utforsk i nærheten",
"Explore {city}": "Utforsk {city}",
"Extra bed (child) × {count}": "Ekstra seng (barn) × {count}",
@@ -396,6 +397,7 @@
"Search": "Søk",
"See all FAQ": "Se alle FAQ",
"See all photos": "Se alle bilder",
"See destination": "Se destinasjon",
"See details": "Se detaljer",
"See hotel details": "Se hotellinformasjon",
"See less FAQ": "Se mindre FAQ",

View File

@@ -153,6 +153,7 @@
"Events that make an impression": "Events that make an impression",
"Expiration Date: {expirationDate}": "Expiration Date: {expirationDate}",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Explore all our hotels": "Utforska alla våra hotell",
"Explore nearby": "Utforska i närheten",
"Explore {city}": "Utforska {city}",
"Extra bed (child) × {count}": "Extra säng (barn) × {count}",
@@ -396,6 +397,7 @@
"Search": "Sök",
"See all FAQ": "Se alla FAQ",
"See all photos": "Se alla foton",
"See destination": "Se destination",
"See details": "Se detaljer",
"See hotel details": "Se hotellinformation",
"See less FAQ": "See färre FAQ",

View File

@@ -23,6 +23,20 @@ query GetDestinationOverviewPageRefs($locale: String!, $uid: String!) {
}
}
query GetCountryPageUrl($locale: String!, $country: String!) {
all_destination_country_page(
where: { destination_settings: { country: $country } }
locale: $locale
) {
items {
url
system {
...System
}
}
}
}
query GetDaDeEnUrlsDestinationOverviewPage($uid: String!) {
de: destination_overview_page(locale: "de", uid: $uid) {
url

View File

@@ -188,6 +188,11 @@ export const getDestinationOverviewPage = cache(
return serverClient().contentstack.destinationOverviewPage.get()
}
)
export const getDestinationsList = cache(
async function getMemoizedDestinationsList() {
return serverClient().contentstack.destinationOverviewPage.destinations.get()
}
)
export const getDestinationCountryPage = cache(
async function getMemoizedDestinationCountryPage() {
return serverClient().contentstack.destinationCountryPage.get()

View File

@@ -172,14 +172,17 @@ export const destinationCountryPageQueryRouter = router({
const selectedCountry =
validatedResponse.data.destinationCountryPage.destination_settings.country
const apiCountry = ApiCountry[lang][selectedCountry]
const cities = await getCitiesByCountry([apiCountry], options, params, lang)
const publishedCities = cities[apiCountry].filter(
(city) => city.isPublished
const cities = await getCitiesByCountry(
[apiCountry],
options,
params,
lang,
true,
"destinationCountryPage"
)
const cityPages = await Promise.all(
publishedCities.map(async (city) => {
cities[apiCountry].map(async (city) => {
if (!city.cityIdentifier) {
return null
}

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { removeMultipleSlashes } from "@/utils/url"
import { systemSchema } from "../schemas/system"
export const destinationOverviewPageSchema = z.object({
@@ -17,6 +19,27 @@ export const destinationOverviewPageSchema = z.object({
}),
})
export const countryPageUrlSchema = z
.object({
all_destination_country_page: z.object({
items: z.array(
z
.object({
url: z.string(),
system: systemSchema,
})
.transform((data) => {
return {
url: removeMultipleSlashes(`/${data.system.locale}/${data.url}`),
}
})
),
}),
})
.transform(
({ all_destination_country_page }) => all_destination_country_page.items[0]
)
/** REFS */
export const destinationOverviewPageRefsSchema = z.object({
destination_overview_page: z.object({

View File

@@ -1,13 +1,25 @@
import { env } from "@/env/server"
import {
GetDestinationOverviewPage,
GetDestinationOverviewPageRefs,
} from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import {
contentstackExtendedProcedureUID,
router,
serviceProcedure,
} from "@/server/trpc"
import { toApiLang } from "@/server/utils"
import { generateTag } from "@/utils/generateTag"
import {
getCitiesByCountry,
getCountries,
getHotelIdsByCityId,
} from "../../hotels/utils"
import { getCityListDataByCityIdentifier } from "../destinationCountryPage/utils"
import {
destinationOverviewPageRefsSchema,
destinationOverviewPageSchema,
@@ -20,11 +32,14 @@ import {
getDestinationOverviewPageRefsSuccessCounter,
getDestinationOverviewPageSuccessCounter,
} from "./telemetry"
import { getCountryPageUrl } from "./utils"
import type { DestinationsData } from "@/types/components/destinationOverviewPage/destinationsList/destinationsData"
import {
TrackingChannelEnum,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type {
GetDestinationOverviewPageData,
GetDestinationOverviewPageRefsSchema,
@@ -187,4 +202,90 @@ export const destinationOverviewPageQueryRouter = router({
tracking,
}
}),
destinations: router({
get: serviceProcedure.query(async function ({ ctx }) {
const apiLang = toApiLang(ctx.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 ${ctx.serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const countries = await getCountries(options, params, ctx.lang)
if (!countries) {
return null
}
const countryNames = countries.data.map((country) => country.name)
const citiesByCountry = await getCitiesByCountry(
countryNames,
options,
params,
ctx.lang,
true
)
const destinations: DestinationsData = await Promise.all(
Object.entries(citiesByCountry).map(async ([country, cities]) => {
const citiesWithHotelCount = await Promise.all(
cities.map(async (city) => {
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: city.id,
onlyBasicInfo: "true",
})
const hotels = await getHotelIdsByCityId(
city.id,
options,
hotelIdsParams
)
let cityUrl
if (city.cityIdentifier) {
cityUrl = await getCityListDataByCityIdentifier(
ctx.lang,
city.cityIdentifier
)
}
return {
id: city.id,
name: city.name,
hotelIds: hotels,
hotelCount: hotels?.length ?? 0,
url: cityUrl?.url,
}
})
)
const countryUrl = await getCountryPageUrl(ctx.lang, country)
return {
country,
countryUrl: countryUrl?.url,
numberOfHotels: citiesWithHotelCount.reduce(
(acc, city) => acc + city.hotelCount,
0
),
cities: citiesWithHotelCount,
}
})
)
return destinations.sort((a, b) => a.country.localeCompare(b.country))
}),
}),
})

View File

@@ -21,3 +21,15 @@ export const getDestinationOverviewPageSuccessCounter = meter.createCounter(
export const getDestinationOverviewPageFailCounter = meter.createCounter(
"trpc.contentstack.destinationOverviewPage.get-fail"
)
export const getCountryPageUrlCounter = meter.createCounter(
"trpc.contentstack.getCountryPageUrl"
)
export const getCountryPageUrlSuccessCounter = meter.createCounter(
"trpc.contentstack.getCountryPageUrl-success"
)
export const getCountryPageUrlFailCounter = meter.createCounter(
"trpc.contentstack.getCountryPageUrl-fail"
)

View File

@@ -0,0 +1,76 @@
import { GetCountryPageUrl } from "@/lib/graphql/Query/DestinationOverviewPage/DestinationOverviewPage.graphql"
import { request } from "@/lib/graphql/request"
import { countryPageUrlSchema } from "./output"
import {
getCountryPageUrlCounter,
getCountryPageUrlFailCounter,
getCountryPageUrlSuccessCounter,
} from "./telemetry"
import type { GetCountryPageUrlData } from "@/types/trpc/routers/contentstack/destinationOverviewPage"
import type { Lang } from "@/constants/languages"
export async function getCountryPageUrl(lang: Lang, country: string) {
getCountryPageUrlCounter.add(1, { lang, country })
console.info(
"contentstack.countryPageUrl start",
JSON.stringify({ query: { lang, country } })
)
const tag = `${lang}:country_page_url:${country}`
const response = await request<GetCountryPageUrlData>(
GetCountryPageUrl,
{
locale: lang,
country,
},
{
cache: "force-cache",
next: {
tags: [tag],
},
}
)
if (!response.data) {
getCountryPageUrlFailCounter.add(1, {
lang,
country,
error_type: "not_found",
error: `Country page not found for country: ${country}`,
})
console.error(
"contentstack.countryPageUrl not found error",
JSON.stringify({ query: { lang, country } })
)
return null
}
const validatedCountryPageUrl = countryPageUrlSchema.safeParse(response.data)
if (!validatedCountryPageUrl.success) {
getCountryPageUrlFailCounter.add(1, {
lang,
country,
error_type: "validation_error",
error: JSON.stringify(validatedCountryPageUrl.error),
})
console.error(
"contentstack.countryPageUrl validation error",
JSON.stringify({
query: { lang, country },
error: validatedCountryPageUrl.error,
})
)
return null
}
getCountryPageUrlSuccessCounter.add(1, { lang, country })
console.info(
"contentstack.countryPageUrl success",
JSON.stringify({ query: { lang, country } })
)
return validatedCountryPageUrl.data
}

View File

@@ -52,7 +52,6 @@ import {
getHotelIdsByCityId,
getHotelIdsByCountry,
getLocations,
TWENTYFOUR_HOURS,
} from "./utils"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"

View File

@@ -126,6 +126,7 @@ export async function getCitiesByCountry(
options: RequestOptionsWithOutBody,
params: URLSearchParams,
lang: Lang,
onlyPublished = false, // false by default as it might be used in other places
affix: string = locationsAffix
) {
return unstable_cache(
@@ -155,7 +156,10 @@ export async function getCitiesByCountry(
return null
}
citiesGroupedByCountry[country] = citiesByCountry.data.data
const cities = onlyPublished
? citiesByCountry.data.data.filter((city) => city.isPublished)
: citiesByCountry.data.data
citiesGroupedByCountry[country] = cities
return true
})
)

View File

@@ -0,0 +1,27 @@
export type DestinationsData = {
country: string
countryUrl: string | undefined
numberOfHotels: number
cities: {
id: string
name: string
hotelIds: string[] | null
hotelCount: number
url: string | undefined
}[]
}[]
export type HotelsSectionProps = {
destinations: DestinationsData
}
export type DestinationsListProps = {
destinations: DestinationsData
}
export type DestinationProps = {
country: string
countryUrl: string | undefined
numberOfHotels: number
cities: DestinationsData[number]["cities"]
}

View File

@@ -1,6 +1,7 @@
import type { z } from "zod"
import type {
countryPageUrlSchema,
destinationOverviewPageRefsSchema,
destinationOverviewPageSchema,
} from "@/server/routers/contentstack/destinationOverviewPage/output"
@@ -15,3 +16,6 @@ export interface GetDestinationOverviewPageRefsSchema
export interface DestinationOverviewPageRefs
extends z.output<typeof destinationOverviewPageRefsSchema> {}
export interface GetCountryPageUrlData
extends z.input<typeof countryPageUrlSchema> {}