diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/destination.module.css b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/destination.module.css new file mode 100644 index 000000000..38705cccd --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/destination.module.css @@ -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; + } +} diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/index.tsx b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/index.tsx new file mode 100644 index 000000000..3d0098302 --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/Destination/index.tsx @@ -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 ( + +
+ + {countryUrl && ( + + {intl.formatMessage({ id: "See destination" })} + + + )} +
+
+ ) +} diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/destinationsList.module.css b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/destinationsList.module.css new file mode 100644 index 000000000..a35b2e14c --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/destinationsList.module.css @@ -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; + } +} diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/index.tsx b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/index.tsx new file mode 100644 index 000000000..d8e89631d --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/DestinationsList/index.tsx @@ -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 ( +
+ + {accordionLeft.map((data) => ( + + ))} + + + + {accordionRight.map((data) => ( + + ))} + +
+ ) +} diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/hotelsSection.module.css b/components/ContentType/DestinationOverviewPage/HotelsSection/hotelsSection.module.css new file mode 100644 index 000000000..90e7767c1 --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/hotelsSection.module.css @@ -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; + } +} diff --git a/components/ContentType/DestinationOverviewPage/HotelsSection/index.tsx b/components/ContentType/DestinationOverviewPage/HotelsSection/index.tsx new file mode 100644 index 000000000..df0744b02 --- /dev/null +++ b/components/ContentType/DestinationOverviewPage/HotelsSection/index.tsx @@ -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 ( +
+ + {intl.formatMessage({ id: "Explore all our hotels" })} + + +
+ ) +} diff --git a/components/ContentType/DestinationOverviewPage/destinationOverviewPage.module.css b/components/ContentType/DestinationOverviewPage/destinationOverviewPage.module.css index 6fb0b4091..7d9f620b6 100644 --- a/components/ContentType/DestinationOverviewPage/destinationOverviewPage.module.css +++ b/components/ContentType/DestinationOverviewPage/destinationOverviewPage.module.css @@ -1,6 +1,7 @@ .pageContainer { position: relative; display: grid; + width: 100%; max-width: var(--max-width); } diff --git a/components/ContentType/DestinationOverviewPage/index.tsx b/components/ContentType/DestinationOverviewPage/index.tsx index 689190397..9ba454b1c 100644 --- a/components/ContentType/DestinationOverviewPage/index.tsx +++ b/components/ContentType/DestinationOverviewPage/index.tsx @@ -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 ? ( ) : null} -

Destination Overview Page

+ {destinationsData && } diff --git a/components/TempDesignSystem/Accordion/AccordionItem/accordionItem.ts b/components/TempDesignSystem/Accordion/AccordionItem/accordionItem.ts index 59024169a..ba984bd65 100644 --- a/components/TempDesignSystem/Accordion/AccordionItem/accordionItem.ts +++ b/components/TempDesignSystem/Accordion/AccordionItem/accordionItem.ts @@ -9,4 +9,5 @@ export interface AccordionItemProps title: string icon?: IconName trackingId?: string + subtitle?: string } diff --git a/components/TempDesignSystem/Accordion/AccordionItem/index.tsx b/components/TempDesignSystem/Accordion/AccordionItem/index.tsx index 20d6768a3..694ad764b 100644 --- a/components/TempDesignSystem/Accordion/AccordionItem/index.tsx +++ b/components/TempDesignSystem/Accordion/AccordionItem/index.tsx @@ -22,6 +22,7 @@ export default function AccordionItem({ variant, className, trackingId, + subtitle, }: AccordionItemProps) { const contentRef = useRef(null) const detailsRef = useRef(null) @@ -55,9 +56,7 @@ export default function AccordionItem({
  • - {IconComp && ( - - )} + {IconComp && } {variant === "sidepeek" ? ( ) : ( - - {title} - +
    + {subtitle ? ( + + {title} + + ) : ( + + {title} + + )} + {subtitle && {subtitle}} +
    )} 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 } diff --git a/server/routers/contentstack/destinationOverviewPage/output.ts b/server/routers/contentstack/destinationOverviewPage/output.ts index eddd8d13e..9bd481bdb 100644 --- a/server/routers/contentstack/destinationOverviewPage/output.ts +++ b/server/routers/contentstack/destinationOverviewPage/output.ts @@ -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({ diff --git a/server/routers/contentstack/destinationOverviewPage/query.ts b/server/routers/contentstack/destinationOverviewPage/query.ts index f7680cbd2..d2a8b6ba8 100644 --- a/server/routers/contentstack/destinationOverviewPage/query.ts +++ b/server/routers/contentstack/destinationOverviewPage/query.ts @@ -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)) + }), + }), }) diff --git a/server/routers/contentstack/destinationOverviewPage/telemetry.ts b/server/routers/contentstack/destinationOverviewPage/telemetry.ts index 84d93764a..aef79e497 100644 --- a/server/routers/contentstack/destinationOverviewPage/telemetry.ts +++ b/server/routers/contentstack/destinationOverviewPage/telemetry.ts @@ -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" +) diff --git a/server/routers/contentstack/destinationOverviewPage/utils.ts b/server/routers/contentstack/destinationOverviewPage/utils.ts new file mode 100644 index 000000000..2500d4ebe --- /dev/null +++ b/server/routers/contentstack/destinationOverviewPage/utils.ts @@ -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( + 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 +} diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 38531c501..5da29e7e6 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -52,7 +52,6 @@ import { getHotelIdsByCityId, getHotelIdsByCountry, getLocations, - TWENTYFOUR_HOURS, } from "./utils" import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts index a75555341..0355bf16a 100644 --- a/server/routers/hotels/utils.ts +++ b/server/routers/hotels/utils.ts @@ -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 }) ) diff --git a/types/components/destinationOverviewPage/destinationsList/destinationsData.ts b/types/components/destinationOverviewPage/destinationsList/destinationsData.ts new file mode 100644 index 000000000..219fc07ac --- /dev/null +++ b/types/components/destinationOverviewPage/destinationsList/destinationsData.ts @@ -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"] +} diff --git a/types/trpc/routers/contentstack/destinationOverviewPage.ts b/types/trpc/routers/contentstack/destinationOverviewPage.ts index b541697b9..41d9818cd 100644 --- a/types/trpc/routers/contentstack/destinationOverviewPage.ts +++ b/types/trpc/routers/contentstack/destinationOverviewPage.ts @@ -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 {} + +export interface GetCountryPageUrlData + extends z.input {}