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 (
+
+
+
+ {cities.map((city) => (
+ -
+
+ {`${city.name} (${city.hotelCount})`}
+
+
+ ))}
+
+ {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 {}