diff --git a/components/HotelReservation/HotelCard/index.tsx b/components/HotelReservation/HotelCard/index.tsx
index 5ba27540b..03406d824 100644
--- a/components/HotelReservation/HotelCard/index.tsx
+++ b/components/HotelReservation/HotelCard/index.tsx
@@ -7,6 +7,7 @@ import { selectRate } from "@/constants/routes/hotelReservation"
import { useHotelsMapStore } from "@/stores/hotels-map"
import { mapFacilityToIcon } from "@/components/ContentType/HotelPage/data"
+import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
@@ -18,7 +19,6 @@ import getSingleDecimal from "@/utils/numberFormatting"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
-import HotelLogo from "./HotelLogo"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
diff --git a/components/HotelReservation/HotelCard/HotelLogo/index.tsx b/components/Icons/Logos/index.tsx
similarity index 90%
rename from components/HotelReservation/HotelCard/HotelLogo/index.tsx
rename to components/Icons/Logos/index.tsx
index c305e1838..8a6eefecb 100644
--- a/components/HotelReservation/HotelCard/HotelLogo/index.tsx
+++ b/components/Icons/Logos/index.tsx
@@ -8,7 +8,7 @@ import {
ScandicLogoIcon,
} from "@/components/Icons"
-import type { HotelLogoProps } from "@/types/components/hotelReservation/selectHotel/hotelLogoProps"
+import type { HotelLogoProps } from "@/types/components/hotelLogo"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { SignatureHotelEnum } from "@/types/enums/signatureHotel"
diff --git a/components/TempDesignSystem/Form/Country/countries.ts b/components/TempDesignSystem/Form/Country/countries.ts
index 6349414b0..f9a0e72d9 100644
--- a/components/TempDesignSystem/Form/Country/countries.ts
+++ b/components/TempDesignSystem/Form/Country/countries.ts
@@ -52,7 +52,7 @@ export const countriesMap = {
"Congo, The Democratic Republic of the": "CD",
"Cook Islands": "CK",
"Costa Rica": "CR",
- 'Cote D"Ivoire': "CI",
+ "Côte d'Ivoire": "CI",
Croatia: "HR",
Cuba: "CU",
Curacao: "CW",
@@ -109,7 +109,6 @@ export const countriesMap = {
"Isle of Man": "IM",
Israel: "IL",
Italy: "IT",
- "Ivory Coast": "CI",
Jamaica: "JM",
Japan: "JP",
Jersey: "JE",
@@ -171,7 +170,7 @@ export const countriesMap = {
Oman: "OM",
Pakistan: "PK",
Palau: "PW",
- "Palestinian Territory, Occupied": "PS",
+ Palestine: "PS",
Panama: "PA",
"Papua New Guinea": "PG",
Paraguay: "PY",
@@ -215,7 +214,6 @@ export const countriesMap = {
Sudan: "SD",
Suriname: "SR",
"Svalbard and Jan Mayen": "SJ",
- Swaziland: "SZ",
Sweden: "SE",
Switzerland: "CH",
"Syrian Arab Republic": "SY",
diff --git a/env/client.ts b/env/client.ts
index 467100c01..fc57c579a 100644
--- a/env/client.ts
+++ b/env/client.ts
@@ -5,10 +5,18 @@ export const env = createEnv({
client: {
NEXT_PUBLIC_NODE_ENV: z.enum(["development", "test", "production"]),
NEXT_PUBLIC_PORT: z.string().default("3000"),
+ NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE: z
+ .string()
+ // only allow "true" or "false"
+ .refine((s) => s === "true" || s === "false")
+ // transform to boolean
+ .transform((s) => s === "true"),
},
emptyStringAsUndefined: true,
runtimeEnv: {
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_PORT: process.env.NEXT_PUBLIC_PORT,
+ NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE:
+ process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE,
},
})
diff --git a/env/server.ts b/env/server.ts
index 3ff18ae24..c1befd972 100644
--- a/env/server.ts
+++ b/env/server.ts
@@ -185,7 +185,7 @@ export const env = createEnv({
process.env.GOOGLE_STATIC_MAP_SIGNATURE_SECRET,
GOOGLE_STATIC_MAP_ID: process.env.GOOGLE_STATIC_MAP_ID,
GOOGLE_DYNAMIC_MAP_ID: process.env.GOOGLE_DYNAMIC_MAP_ID,
- HIDE_FOR_NEXT_RELEASE: process.env.HIDE_FOR_NEXT_RELEASE,
+ HIDE_FOR_NEXT_RELEASE: process.env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE,
USE_NEW_REWARDS_ENDPOINT: process.env.USE_NEW_REWARDS_ENDPOINT,
USE_NEW_REWARD_MODEL: process.env.USE_NEW_REWARD_MODEL,
ENABLE_BOOKING_FLOW: process.env.ENABLE_BOOKING_FLOW,
diff --git a/hooks/useStickyPosition.ts b/hooks/useStickyPosition.ts
index 2c7fdf6ea..08fce174b 100644
--- a/hooks/useStickyPosition.ts
+++ b/hooks/useStickyPosition.ts
@@ -1,9 +1,10 @@
"use client"
-import { useEffect } from "react"
+import { useEffect, useState } from "react"
+import { env } from "@/env/client"
import useStickyPositionStore, {
- StickyElementNameEnum,
+ type StickyElementNameEnum,
} from "@/stores/sticky-position"
import { debounce } from "@/utils/debounce"
@@ -44,6 +45,15 @@ export default function useStickyPosition({
getAllElements,
} = useStickyPositionStore()
+ /* Used for Current mobile header since that doesn't use this hook.
+ *
+ * Instead, calculate if the mobile header is shown and add the height of
+ * that "manually" to all offsets using this hook.
+ *
+ * TODO: Remove this and just use 0 when the current header has been removed.
+ */
+ const [baseTopOffset, setBaseTopOffset] = useState(0)
+
useEffect(() => {
if (ref && name) {
// Register the sticky element with the given ref, name, and group.
@@ -79,19 +89,28 @@ export default function useStickyPosition({
const topOffset = stickyElements
.slice(0, index)
.filter((el) => el.group !== currentGroup)
- .reduce((acc, el) => acc + el.height, 0)
+ .reduce((acc, el) => acc + el.height, baseTopOffset)
// Apply the calculated top offset to the current element's style.
// This positions the element at the correct location within the document.
ref.current.style.top = `${topOffset}px`
}
}
- }, [stickyElements, ref])
+ }, [baseTopOffset, stickyElements, ref])
useEffect(() => {
if (!resizeObserver) {
const debouncedResizeHandler = debounce(() => {
updateHeights()
+
+ // Only do this special handling if we have the current header
+ if (env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE) {
+ if (document.body.clientWidth > 950) {
+ setBaseTopOffset(0)
+ } else {
+ setBaseTopOffset(52.41) // The height of current mobile header
+ }
+ }
}, 100)
resizeObserver = new ResizeObserver(debouncedResizeHandler)
diff --git a/i18n/dictionaries/da.json b/i18n/dictionaries/da.json
index 70281af0e..4378a7b9a 100644
--- a/i18n/dictionaries/da.json
+++ b/i18n/dictionaries/da.json
@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Kreative rum til møder",
"Credit card": "Kreditkort",
"Credit card deleted successfully": "Kreditkort blev slettet",
+ "Crib (child)": "Kørestol (barn)",
"Currency Code": "DKK",
"Current password": "Nuværende kodeord",
"Customer service": "Kundeservice",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Udløber tidligst {date}",
"Explore all levels and benefits": "Udforsk alle niveauer og fordele",
"Explore nearby": "Udforsk i nærheden",
+ "Extra bed (child)": "Ekstra seng (barn)",
+ "Extra bed will be provided additionally": "Der vil blive stillet en ekstra seng til rådighed",
"Extras to your booking": "Tillæg til din booking",
"FAQ": "Ofte stillede spørgsmål",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kreditkort. Prøv venligst igen senere.",
@@ -486,6 +489,7 @@
"Your level": "Dit niveau",
"Your points to spend": "Dine brugbare point",
"Your room": "Dit værelse",
+ "Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Zip code": "Postnummer",
"Zoo": "Zoo",
"Zoom in": "Zoom ind",
@@ -532,7 +536,7 @@
"room type": "værelsestype",
"room types": "værelsestyper",
"signup.terms": "Ved at tilmelde dig accepterer du Scandic Friends
vilkår og betingelser. Dit medlemskab er gyldigt indtil videre, og du kan til enhver tid opsige dit medlemskab ved at sende en e-mail til Scandics kundeservice",
- "signupPage.terms": "Ved at acceptere
vilkårene og betingelserne for Scandic Friends, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med
Scandics privatlivspolitik.",
+ "signupPage.terms": "Ved at acceptere
vilkårene og betingelserne for Scandic Friends, forstår jeg, at mine personlige oplysninger vil blive behandlet i overensstemmelse med
Scandics privatlivspolitik.",
"special character": "speciel karakter",
"spendable points expiring by": "{points} Brugbare point udløber den {date}",
"to": "til",
diff --git a/i18n/dictionaries/de.json b/i18n/dictionaries/de.json
index 6fd41b9fe..03fb803f9 100644
--- a/i18n/dictionaries/de.json
+++ b/i18n/dictionaries/de.json
@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Kreative Räume für Meetings",
"Credit card": "Kreditkarte",
"Credit card deleted successfully": "Kreditkarte erfolgreich gelöscht",
+ "Crib (child)": "Kinderbett (Kind)",
"Currency Code": "EUR",
"Current password": "Aktuelles Passwort",
"Customer service": "Kundendienst",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Läuft frühestens am {date} ab",
"Explore all levels and benefits": "Entdecken Sie alle Levels und Vorteile",
"Explore nearby": "Erkunden Sie die Umgebung",
+ "Extra bed (child)": "Ekstra seng (Kind)",
+ "Extra bed will be provided additionally": "Ein zusätzliches Bett wird bereitgestellt",
"Extras to your booking": "Extras zu Ihrer Buchung",
"FAQ": "Häufig gestellte Fragen",
"Failed to delete credit card, please try again later.": "Kreditkarte konnte nicht gelöscht werden. Bitte versuchen Sie es später noch einmal.",
@@ -485,6 +488,7 @@
"Your level": "Dein level",
"Your points to spend": "Meine Punkte",
"Your room": "Ihr Zimmer",
+ "Your selected bed type will be provided based on availability": "Ihre ausgewählte Bettart wird basierend auf der Verfügbarkeit bereitgestellt",
"Zip code": "PLZ",
"Zoo": "Zoo",
"Zoom in": "Vergrößern",
diff --git a/i18n/dictionaries/en.json b/i18n/dictionaries/en.json
index efd37360f..a0be98b36 100644
--- a/i18n/dictionaries/en.json
+++ b/i18n/dictionaries/en.json
@@ -112,6 +112,7 @@
"Creative spaces for meetings": "Creative spaces for meetings",
"Credit card": "Credit card",
"Credit card deleted successfully": "Credit card deleted successfully",
+ "Crib (child)": "Crib (child)",
"Currency Code": "EUR",
"Current password": "Current password",
"Customer service": "Customer service",
@@ -150,6 +151,8 @@
"Expires at the earliest": "Expires at the earliest {date}",
"Explore all levels and benefits": "Explore all levels and benefits",
"Explore nearby": "Explore nearby",
+ "Extra bed (child)": "Extra bed (child)",
+ "Extra bed will be provided additionally": "Extra bed will be provided additionally",
"Extras to your booking": "Extras to your booking",
"FAQ": "FAQ",
"Failed to delete credit card, please try again later.": "Failed to delete credit card, please try again later.",
@@ -529,6 +532,7 @@
"Your level": "Your level",
"Your points to spend": "Your points to spend",
"Your room": "Your room",
+ "Your selected bed type will be provided based on availability": "Your selected bed type will be provided based on availability",
"Zip code": "Zip code",
"Zoo": "Zoo",
"Zoom in": "Zoom in",
diff --git a/i18n/dictionaries/fi.json b/i18n/dictionaries/fi.json
index 593cbdb6d..1f6ba0145 100644
--- a/i18n/dictionaries/fi.json
+++ b/i18n/dictionaries/fi.json
@@ -105,6 +105,7 @@
"Creative spaces for meetings": "Luovia tiloja kokouksille",
"Credit card": "Luottokortti",
"Credit card deleted successfully": "Luottokortti poistettu onnistuneesti",
+ "Crib (child)": "Körkkä (lasta)",
"Currency Code": "EUR",
"Current password": "Nykyinen salasana",
"Customer service": "Asiakaspalvelu",
@@ -142,6 +143,8 @@
"Expires at the earliest": "Päättyy aikaisintaan {date}",
"Explore all levels and benefits": "Tutustu kaikkiin tasoihin ja etuihin",
"Explore nearby": "Tutustu lähialueeseen",
+ "Extra bed (child)": "Lisävuode (lasta)",
+ "Extra bed will be provided additionally": "Lisävuode toimitetaan erikseen",
"Extras to your booking": "Varauksessa lisäpalveluita",
"FAQ": "Usein kysytyt kysymykset",
"Failed to delete credit card, please try again later.": "Luottokortin poistaminen epäonnistui, yritä myöhemmin uudelleen.",
@@ -412,7 +415,7 @@
"Surprise!": "Yllätys!",
"TUI Points": "TUI Points",
"Tell us what information and updates you'd like to receive, and how, by clicking the link below.": "Kerro meille, mitä tietoja ja päivityksiä haluat saada ja miten, napsauttamalla alla olevaa linkkiä.",
- "Terms and conditions": "Käyttöehdot",
+ "Terms and conditions": "Säännöt ja ehdot",
"Thank you": "Kiitos",
"The new price is": "Uusi hinta on",
"The price has increased": "Hinta on noussut",
@@ -484,6 +487,7 @@
"Your level": "Tasosi",
"Your points to spend": "Käytettävissä olevat pisteesi",
"Your room": "Sinun huoneesi",
+ "Your selected bed type will be provided based on availability": "Valitun vuodetyypin toimitetaan saatavuuden mukaan",
"Zip code": "Postinumero",
"Zoo": "Eläintarha",
"Zoom in": "Lähennä",
diff --git a/i18n/dictionaries/no.json b/i18n/dictionaries/no.json
index 61d44b3e9..2bf19706c 100644
--- a/i18n/dictionaries/no.json
+++ b/i18n/dictionaries/no.json
@@ -104,6 +104,7 @@
"Country is required": "Land kreves",
"Creative spaces for meetings": "Kreative rom for møter",
"Credit card deleted successfully": "Kredittkort slettet",
+ "Crib (child)": "Kørestol (barn)",
"Currency Code": "NOK",
"Current password": "Nåværende passord",
"Customer service": "Kundeservice",
@@ -141,6 +142,8 @@
"Expires at the earliest": "Utløper tidligst {date}",
"Explore all levels and benefits": "Utforsk alle nivåer og fordeler",
"Explore nearby": "Utforsk i nærheten",
+ "Extra bed (child)": "Ekstra seng (barn)",
+ "Extra bed will be provided additionally": "Ekstra seng vil bli tilgjengelig",
"Extras to your booking": "Tilvalg til bestillingen din",
"FAQ": "Ofte stilte spørsmål",
"Failed to delete credit card, please try again later.": "Kunne ikke slette kredittkortet, prøv igjen senere.",
@@ -484,6 +487,7 @@
"Your level": "Ditt nivå",
"Your points to spend": "Dine brukbare poeng",
"Your room": "Rommet ditt",
+ "Your selected bed type will be provided based on availability": "Din valgte sengtype vil blive stillet til rådighed baseret på tilgængelighed",
"Zip code": "Post kode",
"Zoo": "Dyrehage",
"Zoom in": "Zoom inn",
diff --git a/i18n/dictionaries/sv.json b/i18n/dictionaries/sv.json
index 245883877..8940f9d42 100644
--- a/i18n/dictionaries/sv.json
+++ b/i18n/dictionaries/sv.json
@@ -104,6 +104,7 @@
"Country is required": "Land är obligatoriskt",
"Creative spaces for meetings": "Kreativa utrymmen för möten",
"Credit card deleted successfully": "Kreditkort har tagits bort",
+ "Crib (child)": "Spjälsäng (barn)",
"Currency Code": "SEK",
"Current password": "Nuvarande lösenord",
"Customer service": "Kundservice",
@@ -141,6 +142,8 @@
"Expires at the earliest": "Löper ut tidigast {date}",
"Explore all levels and benefits": "Utforska alla nivåer och fördelar",
"Explore nearby": "Utforska i närheten",
+ "Extra bed (child)": "Extra säng (barn)",
+ "Extra bed will be provided additionally": "Extra säng kommer att tillhandahållas",
"Extras to your booking": "Extra tillval till din bokning",
"FAQ": "FAQ",
"Failed to delete credit card, please try again later.": "Det gick inte att ta bort kreditkortet, försök igen senare.",
@@ -484,6 +487,7 @@
"Your level": "Din nivå",
"Your points to spend": "Dina spenderbara poäng",
"Your room": "Ditt rum",
+ "Your selected bed type will be provided based on availability": "Din valda sängtyp kommer att tillhandahållas baserat på tillgänglighet",
"Zip code": "Postnummer",
"Zoo": "Djurpark",
"Zoom in": "Zooma in",
diff --git a/lib/api/index.ts b/lib/api/index.ts
index 62ae6a8a2..5842b5597 100644
--- a/lib/api/index.ts
+++ b/lib/api/index.ts
@@ -30,20 +30,11 @@ const wrappedFetch = fetchRetry(fetch, {
export async function get(
endpoint: Endpoint,
options: RequestOptionsWithOutBody,
- params: Record
= {}
+ params = {}
) {
const url = new URL(env.API_BASEURL)
url.pathname = endpoint
- const searchParams = new URLSearchParams()
- Object.entries(params).forEach(([key, value]) => {
- if (Array.isArray(value)) {
- value.forEach((val) => searchParams.append(key, val))
- } else {
- searchParams.set(key, value)
- }
- })
-
- url.search = searchParams.toString()
+ url.search = new URLSearchParams(params).toString()
return wrappedFetch(
url,
merge.all([defaultOptions, { method: "GET" }, options])
diff --git a/lib/graphql/Fragments/Blocks/HotelListing.graphql b/lib/graphql/Fragments/Blocks/HotelListing.graphql
new file mode 100644
index 000000000..dc6cb8451
--- /dev/null
+++ b/lib/graphql/Fragments/Blocks/HotelListing.graphql
@@ -0,0 +1,24 @@
+fragment HotelListing on HotelListing {
+ heading
+ location_filter {
+ city_denmark
+ city_finland
+ city_germany
+ city_norway
+ city_poland
+ city_sweden
+ country
+ excluded
+ }
+ manual_filter {
+ hotels
+ }
+ content_type
+}
+
+fragment HotelListing_ContentPage on ContentPageBlocksHotelListing {
+ __typename
+ hotel_listing {
+ ...HotelListing
+ }
+}
diff --git a/lib/graphql/Query/ContentPage/ContentPage.graphql b/lib/graphql/Query/ContentPage/ContentPage.graphql
index c67738c05..7b7f98511 100644
--- a/lib/graphql/Query/ContentPage/ContentPage.graphql
+++ b/lib/graphql/Query/ContentPage/ContentPage.graphql
@@ -4,6 +4,7 @@
#import "../../Fragments/Blocks/CardsGrid.graphql"
#import "../../Fragments/Blocks/Content.graphql"
#import "../../Fragments/Blocks/DynamicContent.graphql"
+#import "../../Fragments/Blocks/HotelListing.graphql"
#import "../../Fragments/Blocks/Shortcuts.graphql"
#import "../../Fragments/Blocks/Table.graphql"
#import "../../Fragments/Blocks/TextCols.graphql"
@@ -65,6 +66,7 @@ query GetContentPageBlocksBatch2($locale: String!, $uid: String!) {
content_page(uid: $uid, locale: $locale) {
blocks {
__typename
+ ...HotelListing_ContentPage
...Shortcuts_ContentPage
...Table_ContentPage
...TextCols_ContentPage
diff --git a/lib/graphql/Query/HotelPage/HotelPageUrl.graphql b/lib/graphql/Query/HotelPage/HotelPageUrl.graphql
new file mode 100644
index 000000000..fdb103679
--- /dev/null
+++ b/lib/graphql/Query/HotelPage/HotelPageUrl.graphql
@@ -0,0 +1,12 @@
+#import "../../Fragments/System.graphql"
+
+query GetHotelPageUrl($locale: String!, $hotelId: String!) {
+ all_hotel_page(locale: $locale, where: { hotel_page_id: $hotelId }) {
+ items {
+ url
+ system {
+ ...System
+ }
+ }
+ }
+}
diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts
index 22d1ac385..bd9367019 100644
--- a/lib/trpc/memoizedRequests/index.ts
+++ b/lib/trpc/memoizedRequests/index.ts
@@ -8,6 +8,7 @@ import type {
} from "@/types/requests/packages"
import type { Lang } from "@/constants/languages"
import type {
+ GetHotelsInput,
GetRoomsAvailabilityInput,
GetSelectedRoomAvailabilityInput,
HotelDataInput,
@@ -62,6 +63,12 @@ export const getUserTracking = cache(async function getMemoizedUserTracking() {
return serverClient().user.tracking()
})
+export const getHotels = cache(async function getMemoizedHotels(
+ input: GetHotelsInput
+) {
+ return serverClient().hotel.hotels.get(input)
+})
+
export const getHotelData = cache(async function getMemoizedHotelData(
input: HotelDataInput
) {
diff --git a/server/routers/contentstack/contentPage/output.ts b/server/routers/contentstack/contentPage/output.ts
index 459f6f40e..039cfb1d1 100644
--- a/server/routers/contentstack/contentPage/output.ts
+++ b/server/routers/contentstack/contentPage/output.ts
@@ -18,6 +18,7 @@ import {
dynamicContentRefsSchema,
dynamicContentSchema as blockDynamicContentSchema,
} from "../schemas/blocks/dynamicContent"
+import { hotelListingSchema } from "../schemas/blocks/hotelListing"
import {
shortcutsRefsSchema,
shortcutsSchema,
@@ -103,6 +104,12 @@ export const contentPageAccordion = z
})
.merge(accordionSchema)
+export const contentPageHotelListing = z
+ .object({
+ __typename: z.literal(ContentPageEnum.ContentStack.blocks.HotelListing),
+ })
+ .merge(hotelListingSchema)
+
export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageAccordion,
contentPageCards,
@@ -112,6 +119,7 @@ export const blocksSchema = z.discriminatedUnion("__typename", [
contentPageTable,
contentPageTextCols,
contentPageUspGrid,
+ contentPageHotelListing,
])
export const contentPageSidebarContent = z
diff --git a/server/routers/contentstack/hotelPage/output.ts b/server/routers/contentstack/hotelPage/output.ts
index 3ed665165..bbedd39a0 100644
--- a/server/routers/contentstack/hotelPage/output.ts
+++ b/server/routers/contentstack/hotelPage/output.ts
@@ -2,6 +2,8 @@ import { z } from "zod"
import { discriminatedUnionArray } from "@/lib/discriminatedUnion"
+import { removeMultipleSlashes } from "@/utils/url"
+
import {
activitiesCardRefSchema,
activitiesCardSchema,
@@ -58,3 +60,26 @@ export const hotelPageRefsSchema = z.object({
url: z.string(),
}),
})
+
+export const hotelPageUrlSchema = z
+ .object({
+ all_hotel_page: z.object({
+ items: z
+ .array(
+ z.object({
+ url: z.string(),
+ system: systemSchema,
+ })
+ )
+ .max(1),
+ }),
+ })
+ .transform((data) => {
+ const page = data.all_hotel_page.items[0]
+ if (!page) {
+ return null
+ }
+
+ const lang = page.system.locale
+ return removeMultipleSlashes(`/${lang}/${page.url}`)
+ })
diff --git a/server/routers/contentstack/hotelPage/query.ts b/server/routers/contentstack/hotelPage/query.ts
index 9bec62769..c7795710a 100644
--- a/server/routers/contentstack/hotelPage/query.ts
+++ b/server/routers/contentstack/hotelPage/query.ts
@@ -1,5 +1,3 @@
-import { metrics } from "@opentelemetry/api"
-
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
@@ -8,20 +6,13 @@ import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
import { hotelPageSchema } from "./output"
+import {
+ getHotelPageCounter,
+ getHotelPageFailCounter,
+ getHotelPageSuccessCounter,
+} from "./telemetry"
-import { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
-
-// OpenTelemetry metrics
-const meter = metrics.getMeter("trpc.contentstack.hotelPage")
-const getHotelPageCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get"
-)
-const getHotelPageSuccessCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get-success"
-)
-const getHotelPageFailCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get-fail"
-)
+import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
export const hotelPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
diff --git a/server/routers/contentstack/hotelPage/telemetry.ts b/server/routers/contentstack/hotelPage/telemetry.ts
new file mode 100644
index 000000000..81f967a6b
--- /dev/null
+++ b/server/routers/contentstack/hotelPage/telemetry.ts
@@ -0,0 +1,33 @@
+import { metrics } from "@opentelemetry/api"
+
+const meter = metrics.getMeter("trpc.contentstack.hotelPage")
+
+export const getHotelPageRefsCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get"
+)
+export const getHotelPageRefsFailCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get-fail"
+)
+export const getHotelPageRefsSuccessCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get-success"
+)
+
+export const getHotelPageCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get"
+)
+export const getHotelPageSuccessCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get-success"
+)
+export const getHotelPageFailCounter = meter.createCounter(
+ "trpc.contentstack.hotelPage.get-fail"
+)
+
+export const getHotelPageUrlCounter = meter.createCounter(
+ "trpc.contentstack.hotelPageUrl.get"
+)
+export const getHotelPageUrlSuccessCounter = meter.createCounter(
+ "trpc.contentstack.hotelPageUrl.get-success"
+)
+export const getHotelPageUrlFailCounter = meter.createCounter(
+ "trpc.contentstack.hotelPageUrl.get-fail"
+)
diff --git a/server/routers/contentstack/hotelPage/utils.ts b/server/routers/contentstack/hotelPage/utils.ts
index 1b3a0adfe..4090d933f 100644
--- a/server/routers/contentstack/hotelPage/utils.ts
+++ b/server/routers/contentstack/hotelPage/utils.ts
@@ -1,37 +1,32 @@
-import { metrics } from "@opentelemetry/api"
-
-import { Lang } from "@/constants/languages"
import { GetHotelPageRefs } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
+import { GetHotelPageUrl } from "@/lib/graphql/Query/HotelPage/HotelPageUrl.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
-import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
+import {
+ generateHotelUrlTag,
+ generateTag,
+ generateTagsFromSystem,
+} from "@/utils/generateTag"
-import { hotelPageRefsSchema } from "./output"
+import { hotelPageRefsSchema, hotelPageUrlSchema } from "./output"
+import {
+ getHotelPageRefsCounter,
+ getHotelPageRefsFailCounter,
+ getHotelPageRefsSuccessCounter,
+ getHotelPageUrlCounter,
+ getHotelPageUrlFailCounter,
+ getHotelPageUrlSuccessCounter,
+} from "./telemetry"
import { HotelPageEnum } from "@/types/enums/hotelPage"
-import { System } from "@/types/requests/system"
-import {
+import type { System } from "@/types/requests/system"
+import type {
GetHotelPageRefsSchema,
+ GetHotelPageUrlData,
HotelPageRefs,
} from "@/types/trpc/routers/contentstack/hotelPage"
-
-const meter = metrics.getMeter("trpc.hotelPage")
-// OpenTelemetry metrics: HotelPage
-
-export const getHotelPageCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get"
-)
-
-const getHotelPageRefsCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get"
-)
-const getHotelPageRefsFailCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get-fail"
-)
-const getHotelPageRefsSuccessCounter = meter.createCounter(
- "trpc.contentstack.hotelPage.get-success"
-)
+import type { Lang } from "@/constants/languages"
export async function fetchHotelPageRefs(lang: Lang, uid: string) {
getHotelPageRefsCounter.add(1, { lang, uid })
@@ -140,3 +135,64 @@ export function getConnections({ hotel_page }: HotelPageRefs) {
}
return connections
}
+
+export async function getHotelPageUrl(lang: Lang, hotelId: string) {
+ getHotelPageUrlCounter.add(1, { lang, hotelId })
+ console.info(
+ "contentstack.hotelPageUrl start",
+ JSON.stringify({ query: { lang, hotelId } })
+ )
+ const response = await request(
+ GetHotelPageUrl,
+ {
+ locale: lang,
+ hotelId,
+ },
+ {
+ cache: "force-cache",
+ next: {
+ tags: [generateHotelUrlTag(lang, hotelId)],
+ },
+ }
+ )
+
+ if (!response.data) {
+ getHotelPageUrlFailCounter.add(1, {
+ lang,
+ hotelId,
+ error_type: "not_found",
+ error: `Hotel page not found for hotelId: ${hotelId}`,
+ })
+ console.error(
+ "contentstack.hotelPageUrl not found error",
+ JSON.stringify({ query: { lang, hotelId } })
+ )
+ return null
+ }
+
+ const validatedHotelPageUrl = hotelPageUrlSchema.safeParse(response.data)
+
+ if (!validatedHotelPageUrl.success) {
+ getHotelPageUrlFailCounter.add(1, {
+ lang,
+ hotelId,
+ error_type: "validation_error",
+ error: JSON.stringify(validatedHotelPageUrl.error),
+ })
+ console.error(
+ "contentstack.hotelPageUrl validation error",
+ JSON.stringify({
+ query: { lang, hotelId },
+ error: validatedHotelPageUrl.error,
+ })
+ )
+ return null
+ }
+ getHotelPageUrlSuccessCounter.add(1, { lang, hotelId })
+ console.info(
+ "contentstack.hotelPageUrl success",
+ JSON.stringify({ query: { lang, hotelId } })
+ )
+
+ return validatedHotelPageUrl.data
+}
diff --git a/server/routers/contentstack/schemas/blocks/hotelListing.ts b/server/routers/contentstack/schemas/blocks/hotelListing.ts
new file mode 100644
index 000000000..e228c95b1
--- /dev/null
+++ b/server/routers/contentstack/schemas/blocks/hotelListing.ts
@@ -0,0 +1,62 @@
+import { z } from "zod"
+
+import { BlocksEnums } from "@/types/enums/blocks"
+import { Country } from "@/types/enums/country"
+
+export const locationFilterSchema = z
+ .object({
+ country: z.nativeEnum(Country).nullable(),
+ city_denmark: z.string().optional().nullable(),
+ city_finland: z.string().optional().nullable(),
+ city_germany: z.string().optional().nullable(),
+ city_poland: z.string().optional().nullable(),
+ city_norway: z.string().optional().nullable(),
+ city_sweden: z.string().optional().nullable(),
+ excluded: z.array(z.string()),
+ })
+ .transform((data) => {
+ const cities = [
+ data.city_denmark,
+ data.city_finland,
+ data.city_germany,
+ data.city_poland,
+ data.city_norway,
+ data.city_sweden,
+ ].filter((city): city is string => Boolean(city))
+
+ // When there are multiple city values, we return null as the filter is invalid.
+ if (cities.length > 1) {
+ return null
+ }
+
+ return {
+ country: cities.length ? null : data.country,
+ city: cities.length ? cities[0] : null,
+ excluded: data.excluded,
+ }
+ })
+
+export const hotelListingSchema = z.object({
+ typename: z
+ .literal(BlocksEnums.block.HotelListing)
+ .default(BlocksEnums.block.HotelListing),
+ hotel_listing: z
+ .object({
+ heading: z.string().optional(),
+ location_filter: locationFilterSchema,
+ manual_filter: z
+ .object({
+ hotels: z.array(z.string()),
+ })
+ .transform((data) => ({ hotels: data.hotels.filter(Boolean) })),
+ content_type: z.enum(["hotel", "restaurant", "meeting"]),
+ })
+ .transform(({ heading, location_filter, manual_filter, content_type }) => {
+ return {
+ heading,
+ locationFilter: location_filter,
+ hotelsToInclude: manual_filter.hotels,
+ contentType: content_type,
+ }
+ }),
+})
diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts
index 4a66009b3..17270a2c6 100644
--- a/server/routers/hotels/input.ts
+++ b/server/routers/hotels/input.ts
@@ -1,6 +1,7 @@
import { z } from "zod"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
+import { Country } from "@/types/enums/country"
export const getHotelsAvailabilityInputSchema = z.object({
cityId: z.string(),
@@ -53,6 +54,18 @@ export const getHotelDataInputSchema = z.object({
export type HotelDataInput = z.input
+export const getHotelsInput = z.object({
+ locationFilter: z
+ .object({
+ city: z.string().nullable(),
+ country: z.nativeEnum(Country).nullable(),
+ excluded: z.array(z.string()),
+ })
+ .nullable(),
+ hotelsToInclude: z.array(z.string()),
+})
+export interface GetHotelsInput extends z.infer {}
+
export const getBreakfastPackageInputSchema = z.object({
adults: z.number().min(1, { message: "at least one adult is required" }),
fromDate: z
diff --git a/server/routers/hotels/output.ts b/server/routers/hotels/output.ts
index bf6fbeca7..2621d5ce8 100644
--- a/server/routers/hotels/output.ts
+++ b/server/routers/hotels/output.ts
@@ -124,7 +124,7 @@ const hotelContentSchema = z.object({
})
const detailedFacilitySchema = z.object({
- id: z.nativeEnum(FacilityEnum),
+ id: z.number(),
name: z.string(),
public: z.boolean(),
sortOrder: z.number(),
@@ -389,6 +389,9 @@ const hotelFactsSchema = z.object({
yearBuilt: z.string(),
})
+type DetailedFacility = { id: FacilityEnum } & z.infer<
+ typeof detailedFacilitySchema
+>
export const hotelAttributesSchema = z.object({
accessibilityElevatorPitchText: z.string().optional(),
address: addressSchema,
@@ -396,11 +399,15 @@ export const hotelAttributesSchema = z.object({
cityName: z.string(),
conferencesAndMeetings: facilitySchema.optional(),
contactInformation: contactInformationSchema,
- detailedFacilities: z
- .array(detailedFacilitySchema)
- .transform((facilities) =>
- facilities.sort((a, b) => b.sortOrder - a.sortOrder)
- ),
+ detailedFacilities: z.array(detailedFacilitySchema).transform(
+ (facilities) =>
+ facilities
+ // Filter away facilities with ID:s that we don't recognize
+ .filter(
+ (f) => f.id !== undefined && f.id !== null && f.id in FacilityEnum
+ )
+ .sort((a, b) => b.sortOrder - a.sortOrder) as DetailedFacility[]
+ ),
gallery: gallerySchema.optional(),
galleryImages: z.array(imageSchema).optional(),
healthAndWellness: facilitySchema.optional(),
@@ -870,3 +877,14 @@ export const getRoomPackagesSchema = z
.optional(),
})
.transform((data) => data.data?.attributes?.packages ?? [])
+
+export const getHotelIdsByCityIdSchema = z
+ .object({
+ data: z.array(
+ z.object({
+ // We only care about the hotel id
+ id: z.string(),
+ })
+ ),
+ })
+ .transform((data) => data.data.map((hotel) => hotel.id))
diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts
index 45c1f4780..86d615ebd 100644
--- a/server/routers/hotels/query.ts
+++ b/server/routers/hotels/query.ts
@@ -1,9 +1,9 @@
-import { metrics } from "@opentelemetry/api"
-
+import { ApiLang } from "@/constants/languages"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { badRequestError } from "@/server/errors/trpc"
import {
+ contentStackBaseWithServiceProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -13,12 +13,14 @@ import { toApiLang } from "@/server/utils"
import { cache } from "@/utils/cache"
+import { getHotelPageUrl } from "../contentstack/hotelPage/utils"
import { getVerifiedUser, parsedUser } from "../user/query"
import {
getBreakfastPackageInputSchema,
getCityCoordinatesInputSchema,
getHotelDataInputSchema,
getHotelsAvailabilityInputSchema,
+ getHotelsInput,
getRatesInputSchema,
getRoomPackagesInputSchema,
getRoomsAvailabilityInputSchema,
@@ -33,10 +35,35 @@ import {
getRoomPackagesSchema,
getRoomsAvailabilitySchema,
} from "./output"
+import {
+ breakfastPackagesCounter,
+ breakfastPackagesFailCounter,
+ breakfastPackagesSuccessCounter,
+ getHotelCounter,
+ getHotelFailCounter,
+ getHotelsCounter,
+ getHotelsFailCounter,
+ getHotelsSuccessCounter,
+ getHotelSuccessCounter,
+ getPackagesCounter,
+ getPackagesFailCounter,
+ getPackagesSuccessCounter,
+ hotelsAvailabilityCounter,
+ hotelsAvailabilityFailCounter,
+ hotelsAvailabilitySuccessCounter,
+ roomsAvailabilityCounter,
+ roomsAvailabilityFailCounter,
+ roomsAvailabilitySuccessCounter,
+ selectedRoomAvailabilityCounter,
+ selectedRoomAvailabilityFailCounter,
+ selectedRoomAvailabilitySuccessCounter,
+} from "./telemetry"
import tempRatesData from "./tempRatesData.json"
import {
getCitiesByCountry,
getCountries,
+ getHotelIdsByCityId,
+ getHotelIdsByCountry,
getLocations,
TWENTYFOUR_HOURS,
} from "./utils"
@@ -45,68 +72,21 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
-
-const meter = metrics.getMeter("trpc.hotels")
-const getHotelCounter = meter.createCounter("trpc.hotel.get")
-const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success")
-const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
-
-const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
-const getPackagesSuccessCounter = meter.createCounter(
- "trpc.hotel.packages.get-success"
-)
-const getPackagesFailCounter = meter.createCounter(
- "trpc.hotel.packages.get-fail"
-)
-
-const hotelsAvailabilityCounter = meter.createCounter(
- "trpc.hotel.availability.hotels"
-)
-const hotelsAvailabilitySuccessCounter = meter.createCounter(
- "trpc.hotel.availability.hotels-success"
-)
-const hotelsAvailabilityFailCounter = meter.createCounter(
- "trpc.hotel.availability.hotels-fail"
-)
-
-const roomsAvailabilityCounter = meter.createCounter(
- "trpc.hotel.availability.rooms"
-)
-const roomsAvailabilitySuccessCounter = meter.createCounter(
- "trpc.hotel.availability.rooms-success"
-)
-const roomsAvailabilityFailCounter = meter.createCounter(
- "trpc.hotel.availability.rooms-fail"
-)
-
-const selectedRoomAvailabilityCounter = meter.createCounter(
- "trpc.hotel.availability.room"
-)
-const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
- "trpc.hotel.availability.room-success"
-)
-const selectedRoomAvailabilityFailCounter = meter.createCounter(
- "trpc.hotel.availability.room-fail"
-)
-
-const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast")
-const breakfastPackagesSuccessCounter = meter.createCounter(
- "trpc.package.breakfast-success"
-)
-const breakfastPackagesFailCounter = meter.createCounter(
- "trpc.package.breakfast-fail"
-)
+import type { Hotel } from "@/types/hotel"
+import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage"
+import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
export const getHotelData = cache(
async (input: HotelDataInput, serviceToken: string) => {
const { hotelId, language, isCardOnlyPayment } = input
- const params: Record = {
+ const includes = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City",
+ const params = new URLSearchParams({
hotelId,
language,
- }
+ })
- params.include = ["RoomCategories", "Restaurants"] // "RoomCategories","NearbyHotels","Restaurants","City",
+ includes.forEach((include) => params.append("include", include))
getHotelCounter.add(1, {
hotelId,
@@ -695,6 +675,202 @@ export const hotelQueryRouter = router({
return getHotelData(input, ctx.serviceToken)
}),
}),
+ hotels: router({
+ get: contentStackBaseWithServiceProcedure
+ .input(getHotelsInput)
+ .query(async function ({ ctx, input }) {
+ const { locationFilter, hotelsToInclude } = input
+
+ const language = ctx.lang
+ const apiLang = toApiLang(language)
+ 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: TWENTYFOUR_HOURS,
+ },
+ }
+
+ let hotelsToFetch: string[] = []
+
+ getHotelsCounter.add(1, {
+ input: JSON.stringify(input),
+ language,
+ })
+ console.info(
+ "api.hotel.hotels start",
+ JSON.stringify({
+ query: {
+ ...input,
+ language,
+ },
+ })
+ )
+
+ if (hotelsToInclude.length) {
+ hotelsToFetch = hotelsToInclude
+ } else if (locationFilter?.city) {
+ const locationsParams = new URLSearchParams({
+ language: apiLang,
+ })
+ const locations = await getLocations(
+ language,
+ options,
+ locationsParams,
+ null
+ )
+ if (!locations || "error" in locations) {
+ return []
+ }
+
+ const cityId = locations
+ .filter((loc): loc is CityLocation => loc.type === "cities")
+ .find((loc) => loc.cityIdentifier === locationFilter.city)?.id
+
+ if (!cityId) {
+ getHotelsFailCounter.add(1, {
+ input: JSON.stringify(input),
+ language,
+ error_type: "not_found",
+ error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
+ })
+
+ console.error(
+ "api.hotel.hotels not found error",
+ JSON.stringify({
+ query: { ...input, language },
+ error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
+ })
+ )
+ return []
+ }
+ const hotelIdsParams = new URLSearchParams({
+ language: apiLang,
+ city: cityId,
+ onlyBasicInfo: "true",
+ })
+ const hotelIds = await getHotelIdsByCityId(
+ cityId,
+ options,
+ hotelIdsParams
+ )
+
+ if (!hotelIds?.length) {
+ getHotelsFailCounter.add(1, {
+ cityId,
+ language,
+ error_type: "not_found",
+ error: `No hotelIds found for cityId: ${cityId}`,
+ })
+
+ console.error(
+ "api.hotel.hotels not found error",
+ JSON.stringify({
+ query: { cityId, language },
+ error: `No hotelIds found for cityId: ${cityId}`,
+ })
+ )
+ return []
+ }
+
+ const filteredHotelIds = hotelIds.filter(
+ (id) => !locationFilter.excluded.includes(id)
+ )
+
+ hotelsToFetch = filteredHotelIds
+ } else if (locationFilter?.country) {
+ const hotelIdsParams = new URLSearchParams({
+ language: ApiLang.En,
+ country: locationFilter.country,
+ onlyBasicInfo: "true",
+ })
+ const hotelIds = await getHotelIdsByCountry(
+ locationFilter.country,
+ options,
+ hotelIdsParams
+ )
+
+ if (!hotelIds?.length) {
+ getHotelsFailCounter.add(1, {
+ country: locationFilter.country,
+ language,
+ error_type: "not_found",
+ error: `No hotelIds found for country: ${locationFilter.country}`,
+ })
+
+ console.error(
+ "api.hotel.hotels not found error",
+ JSON.stringify({
+ query: { country: locationFilter.country, language },
+ error: `No hotelIds found for cityId: ${locationFilter.country}`,
+ })
+ )
+ return []
+ }
+
+ const filteredHotelIds = hotelIds.filter(
+ (id) => !locationFilter.excluded.includes(id)
+ )
+
+ hotelsToFetch = filteredHotelIds
+ }
+
+ if (!hotelsToFetch.length) {
+ getHotelsFailCounter.add(1, {
+ input: JSON.stringify(input),
+ language,
+ error_type: "not_found",
+ error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
+ })
+
+ console.error(
+ "api.hotel.hotels not found error",
+ JSON.stringify({
+ query: JSON.stringify(input),
+ error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
+ })
+ )
+ return []
+ }
+
+ const hotels = await Promise.all(
+ hotelsToFetch.map(async (hotelId) => {
+ const [hotelData, url] = await Promise.all([
+ getHotelData({ hotelId, language }, ctx.serviceToken),
+ getHotelPageUrl(language, hotelId),
+ ])
+
+ return {
+ data: hotelData?.data.attributes,
+ url,
+ }
+ })
+ )
+
+ getHotelsSuccessCounter.add(1, {
+ input: JSON.stringify(input),
+ language,
+ })
+
+ console.info(
+ "api.hotels success",
+ JSON.stringify({
+ query: {
+ input: JSON.stringify(input),
+ language,
+ },
+ })
+ )
+
+ return hotels.filter(
+ (hotel): hotel is { data: Hotel; url: HotelPageUrl } => !!hotel.data
+ )
+ }),
+ }),
locations: router({
get: serviceProcedure.query(async function ({ ctx }) {
const searchParams = new URLSearchParams()
diff --git a/server/routers/hotels/schemas/specialAlerts.ts b/server/routers/hotels/schemas/specialAlerts.ts
index 9d5dbd63c..6acd8407a 100644
--- a/server/routers/hotels/schemas/specialAlerts.ts
+++ b/server/routers/hotels/schemas/specialAlerts.ts
@@ -1,7 +1,9 @@
-import { dt } from "@/lib/dt"
-import { AlertTypeEnum } from "@/types/enums/alert"
import { z } from "zod"
+import { dt } from "@/lib/dt"
+
+import { AlertTypeEnum } from "@/types/enums/alert"
+
const specialAlertSchema = z.object({
type: z.string(),
title: z.string().optional(),
@@ -16,10 +18,14 @@ export const specialAlertsSchema = z
.transform((data) => {
const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => {
- const shouldShowNow =
- alert.startDate && alert.endDate
- ? alert.startDate <= now && alert.endDate >= now
- : true
+ let shouldShowNow = true
+
+ if (alert.startDate && alert.startDate > now) {
+ shouldShowNow = false
+ }
+ if (alert.endDate && alert.endDate < now) {
+ shouldShowNow = false
+ }
const hasText = alert.description || alert.title
return shouldShowNow && hasText
})
diff --git a/server/routers/hotels/telemetry.ts b/server/routers/hotels/telemetry.ts
new file mode 100644
index 000000000..72ea046ca
--- /dev/null
+++ b/server/routers/hotels/telemetry.ts
@@ -0,0 +1,74 @@
+import { metrics } from "@opentelemetry/api"
+
+const meter = metrics.getMeter("trpc.hotels")
+export const getHotelCounter = meter.createCounter("trpc.hotel.get")
+export const getHotelSuccessCounter = meter.createCounter(
+ "trpc.hotel.get-success"
+)
+export const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
+
+export const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
+export const getPackagesSuccessCounter = meter.createCounter(
+ "trpc.hotel.packages.get-success"
+)
+export const getPackagesFailCounter = meter.createCounter(
+ "trpc.hotel.packages.get-fail"
+)
+
+export const hotelsAvailabilityCounter = meter.createCounter(
+ "trpc.hotel.availability.hotels"
+)
+export const hotelsAvailabilitySuccessCounter = meter.createCounter(
+ "trpc.hotel.availability.hotels-success"
+)
+export const hotelsAvailabilityFailCounter = meter.createCounter(
+ "trpc.hotel.availability.hotels-fail"
+)
+
+export const roomsAvailabilityCounter = meter.createCounter(
+ "trpc.hotel.availability.rooms"
+)
+export const roomsAvailabilitySuccessCounter = meter.createCounter(
+ "trpc.hotel.availability.rooms-success"
+)
+export const roomsAvailabilityFailCounter = meter.createCounter(
+ "trpc.hotel.availability.rooms-fail"
+)
+
+export const selectedRoomAvailabilityCounter = meter.createCounter(
+ "trpc.hotel.availability.room"
+)
+export const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
+ "trpc.hotel.availability.room-success"
+)
+export const selectedRoomAvailabilityFailCounter = meter.createCounter(
+ "trpc.hotel.availability.room-fail"
+)
+
+export const breakfastPackagesCounter = meter.createCounter(
+ "trpc.package.breakfast"
+)
+export const breakfastPackagesSuccessCounter = meter.createCounter(
+ "trpc.package.breakfast-success"
+)
+export const breakfastPackagesFailCounter = meter.createCounter(
+ "trpc.package.breakfast-fail"
+)
+
+export const getHotelsCounter = meter.createCounter("trpc.hotel.hotels.get")
+export const getHotelsSuccessCounter = meter.createCounter(
+ "trpc.hotel.hotels.get-success"
+)
+export const getHotelsFailCounter = meter.createCounter(
+ "trpc.hotel.hotels.get-fail"
+)
+
+export const getHotelIdsCounter = meter.createCounter(
+ "trpc.hotel.hotel-ids.get"
+)
+export const getHotelIdsSuccessCounter = meter.createCounter(
+ "trpc.hotel.hotel-ids.get-success"
+)
+export const getHotelIdsFailCounter = meter.createCounter(
+ "trpc.hotel.hotel-ids.get-fail"
+)
diff --git a/server/routers/hotels/utils.ts b/server/routers/hotels/utils.ts
index 3e5b5f5e4..726947538 100644
--- a/server/routers/hotels/utils.ts
+++ b/server/routers/hotels/utils.ts
@@ -10,11 +10,18 @@ import {
apiLocationsSchema,
type CitiesGroupedByCountry,
type Countries,
+ getHotelIdsByCityIdSchema,
} from "./output"
+import {
+ getHotelIdsCounter,
+ getHotelIdsFailCounter,
+ getHotelIdsSuccessCounter,
+} from "./telemetry"
+import type { Country } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { PointOfInterestGroupEnum } from "@/types/hotel"
-import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
+import type { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
@@ -258,3 +265,145 @@ export async function getLocations(
{ revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry)
}
+
+export async function getHotelIdsByCityId(
+ cityId: string,
+ options: RequestOptionsWithOutBody,
+ params: URLSearchParams
+) {
+ return unstable_cache(
+ async function (params: URLSearchParams) {
+ getHotelIdsCounter.add(1, { params: params.toString() })
+ console.info(
+ "api.hotel.hotel-ids start",
+ JSON.stringify({ params: params.toString() })
+ )
+ const apiResponse = await api.get(
+ api.endpoints.v1.Hotel.hotels,
+ options,
+ params
+ )
+
+ if (!apiResponse.ok) {
+ const responseMessage = await apiResponse.text()
+ getHotelIdsFailCounter.add(1, {
+ params: params.toString(),
+ error_type: "http_error",
+ error: responseMessage,
+ })
+ console.error(
+ "api.hotel.hotel-ids fetch error",
+ JSON.stringify({
+ params: params.toString(),
+ error: {
+ status: apiResponse.status,
+ statusText: apiResponse.statusText,
+ text: responseMessage,
+ },
+ })
+ )
+
+ return null
+ }
+
+ const apiJson = await apiResponse.json()
+ const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
+ if (!validatedHotelIds.success) {
+ getHotelIdsFailCounter.add(1, {
+ params: params.toString(),
+ error_type: "validation_error",
+ error: JSON.stringify(validatedHotelIds.error),
+ })
+ console.error(
+ "api.hotel.hotel-ids validation error",
+ JSON.stringify({
+ params: params.toString(),
+ error: validatedHotelIds.error,
+ })
+ )
+ return null
+ }
+
+ getHotelIdsSuccessCounter.add(1, { cityId })
+ console.info(
+ "api.hotel.hotel-ids success",
+ JSON.stringify({ params: params.toString() })
+ )
+
+ return validatedHotelIds.data
+ },
+ [`hotelsByCityId`, params.toString()],
+ { revalidate: TWENTYFOUR_HOURS }
+ )(params)
+}
+
+export async function getHotelIdsByCountry(
+ country: Country,
+ options: RequestOptionsWithOutBody,
+ params: URLSearchParams
+) {
+ return unstable_cache(
+ async function (params: URLSearchParams) {
+ getHotelIdsCounter.add(1, { country })
+ console.info(
+ "api.hotel.hotel-ids start",
+ JSON.stringify({ query: { country } })
+ )
+ const apiResponse = await api.get(
+ api.endpoints.v1.Hotel.hotels,
+ options,
+ params
+ )
+
+ if (!apiResponse.ok) {
+ const responseMessage = await apiResponse.text()
+ getHotelIdsFailCounter.add(1, {
+ country,
+ error_type: "http_error",
+ error: responseMessage,
+ })
+ console.error(
+ "api.hotel.hotel-ids fetch error",
+ JSON.stringify({
+ query: { country },
+ error: {
+ status: apiResponse.status,
+ statusText: apiResponse.statusText,
+ text: responseMessage,
+ },
+ })
+ )
+
+ return null
+ }
+
+ const apiJson = await apiResponse.json()
+ const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
+ if (!validatedHotelIds.success) {
+ getHotelIdsFailCounter.add(1, {
+ country,
+ error_type: "validation_error",
+ error: JSON.stringify(validatedHotelIds.error),
+ })
+ console.error(
+ "api.hotel.hotel-ids validation error",
+ JSON.stringify({
+ query: { country },
+ error: validatedHotelIds.error,
+ })
+ )
+ return null
+ }
+
+ getHotelIdsSuccessCounter.add(1, { country })
+ console.info(
+ "api.hotel.hotel-ids success",
+ JSON.stringify({ query: { country } })
+ )
+
+ return validatedHotelIds.data
+ },
+ [`hotelsByCountry`, params.toString()],
+ { revalidate: TWENTYFOUR_HOURS }
+ )(params)
+}
diff --git a/server/routers/user/query.ts b/server/routers/user/query.ts
index 7f149cf3b..1e5145b41 100644
--- a/server/routers/user/query.ts
+++ b/server/routers/user/query.ts
@@ -1,5 +1,4 @@
import { metrics } from "@opentelemetry/api"
-import { cache } from "react"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
@@ -10,6 +9,7 @@ import {
} from "@/server/trpc"
import { countries } from "@/components/TempDesignSystem/Form/Country/countries"
+import { cache } from "@/utils/cache"
import * as maskValue from "@/utils/maskValue"
import { getMembership, getMembershipCards } from "@/utils/user"
diff --git a/types/components/blocks/hotelListing.ts b/types/components/blocks/hotelListing.ts
new file mode 100644
index 000000000..6505ac184
--- /dev/null
+++ b/types/components/blocks/hotelListing.ts
@@ -0,0 +1,8 @@
+import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
+
+export interface HotelListingProps {
+ heading?: string
+ locationFilter: HotelListing["locationFilter"]
+ hotelsToInclude: HotelListing["hotelsToInclude"]
+ contentType: HotelListing["contentType"]
+}
diff --git a/types/components/contentPage/hotelListingItem.ts b/types/components/contentPage/hotelListingItem.ts
index 1065d1c5f..d6bf0283b 100644
--- a/types/components/contentPage/hotelListingItem.ts
+++ b/types/components/contentPage/hotelListingItem.ts
@@ -1,9 +1,8 @@
-export type HotelListingItemProps = {
- imageUrl: string
- altText: string
- name: string
- address: string
- distanceToCentre: number
- description: string
- link: string
+import type { Hotel } from "@/types/hotel"
+import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks"
+
+export interface HotelListingItemProps {
+ hotel: Hotel
+ contentType: HotelListing["contentType"]
+ url: string | null
}
diff --git a/types/components/hotelReservation/selectHotel/hotelLogoProps.ts b/types/components/hotelLogo.ts
similarity index 100%
rename from types/components/hotelReservation/selectHotel/hotelLogoProps.ts
rename to types/components/hotelLogo.ts
diff --git a/types/components/hotelReservation/enterDetails/bedType.ts b/types/components/hotelReservation/enterDetails/bedType.ts
index a222ece2b..9f1fae723 100644
--- a/types/components/hotelReservation/enterDetails/bedType.ts
+++ b/types/components/hotelReservation/enterDetails/bedType.ts
@@ -1,6 +1,6 @@
-import { z } from "zod"
+import type { z } from "zod"
-import {
+import type {
bedTypeFormSchema,
bedTypeSchema,
} from "@/components/HotelReservation/EnterDetails/BedType/schema"
@@ -20,3 +20,7 @@ export type BedTypeProps = {
export interface BedTypeFormSchema extends z.output {}
export type BedTypeSchema = z.output["bedType"]
+
+export type BedTypeInfoProps = {
+ hasMultipleBedTypes: boolean
+}
diff --git a/types/enums/blocks.ts b/types/enums/blocks.ts
index 83ee7a13e..73e0992e8 100644
--- a/types/enums/blocks.ts
+++ b/types/enums/blocks.ts
@@ -9,5 +9,6 @@ export namespace BlocksEnums {
TextCols = "TextCols",
TextContent = "TextContent",
UspGrid = "UspGrid",
+ HotelListing = "HotelListing",
}
}
diff --git a/types/enums/contentPage.ts b/types/enums/contentPage.ts
index b00556a30..55fb12c35 100644
--- a/types/enums/contentPage.ts
+++ b/types/enums/contentPage.ts
@@ -9,6 +9,7 @@ export namespace ContentPageEnum {
TextCols = "ContentPageBlocksTextCols",
UspGrid = "ContentPageBlocksUspGrid",
Table = "ContentPageBlocksTable",
+ HotelListing = "ContentPageBlocksHotelListing",
}
export const enum sidebar {
diff --git a/types/enums/country.ts b/types/enums/country.ts
new file mode 100644
index 000000000..3ccdd1e4d
--- /dev/null
+++ b/types/enums/country.ts
@@ -0,0 +1,8 @@
+export enum Country {
+ Denmark = "Denmark",
+ Finland = "Finland",
+ Germany = "Germany",
+ Norway = "Norway",
+ Poland = "Poland",
+ Sweden = "Sweden",
+}
diff --git a/types/trpc/routers/contentstack/blocks.ts b/types/trpc/routers/contentstack/blocks.ts
index 149f9b35c..32aedbbfc 100644
--- a/types/trpc/routers/contentstack/blocks.ts
+++ b/types/trpc/routers/contentstack/blocks.ts
@@ -1,15 +1,16 @@
-import { z } from "zod"
+import type { z } from "zod"
-import {
+import type {
cardsGridSchema,
teaserCardBlockSchema,
} from "@/server/routers/contentstack/schemas/blocks/cardsGrid"
-import { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content"
-import { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent"
-import { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts"
-import { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table"
-import { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols"
-import { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid"
+import type { contentSchema } from "@/server/routers/contentstack/schemas/blocks/content"
+import type { dynamicContentSchema } from "@/server/routers/contentstack/schemas/blocks/dynamicContent"
+import type { hotelListingSchema } from "@/server/routers/contentstack/schemas/blocks/hotelListing"
+import type { shortcutsSchema } from "@/server/routers/contentstack/schemas/blocks/shortcuts"
+import type { tableSchema } from "@/server/routers/contentstack/schemas/blocks/table"
+import type { textColsSchema } from "@/server/routers/contentstack/schemas/blocks/textCols"
+import type { uspGridSchema } from "@/server/routers/contentstack/schemas/blocks/uspGrid"
export interface TeaserCard extends z.output {}
export interface CardsGrid extends z.output {}
@@ -21,3 +22,5 @@ export interface TableBlock extends z.output {}
export type TableData = TableBlock["table"]
export interface TextCols extends z.output {}
export interface UspGrid extends z.output {}
+interface GetHotelListing extends z.output {}
+export type HotelListing = GetHotelListing["hotel_listing"]
diff --git a/types/trpc/routers/contentstack/hotelPage.ts b/types/trpc/routers/contentstack/hotelPage.ts
index e157eaecf..6b98f5ca2 100644
--- a/types/trpc/routers/contentstack/hotelPage.ts
+++ b/types/trpc/routers/contentstack/hotelPage.ts
@@ -1,11 +1,12 @@
-import { z } from "zod"
+import type { z } from "zod"
-import {
+import type {
contentBlock,
hotelPageRefsSchema,
hotelPageSchema,
+ hotelPageUrlSchema,
} from "@/server/routers/contentstack/hotelPage/output"
-import { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard"
+import type { activitiesCardSchema } from "@/server/routers/contentstack/schemas/blocks/activitiesCard"
export interface GetHotelPageData extends z.input {}
export interface HotelPage extends z.output {}
@@ -18,3 +19,7 @@ export interface GetHotelPageRefsSchema
extends z.input {}
export interface HotelPageRefs extends z.output {}
+
+export interface GetHotelPageUrlData
+ extends z.input {}
+export type HotelPageUrl = z.output
diff --git a/utils/generateTag.ts b/utils/generateTag.ts
index b067f536e..df73aab53 100644
--- a/utils/generateTag.ts
+++ b/utils/generateTag.ts
@@ -1,4 +1,4 @@
-import { System } from "@/types/requests/system"
+import type { System } from "@/types/requests/system"
import type { Edges } from "@/types/requests/utils/edges"
import type { NodeRefs } from "@/types/requests/utils/refs"
import type { Lang } from "@/constants/languages"
@@ -109,3 +109,14 @@ export function generateLoyaltyConfigTag(
export function generateServiceTokenTag(scopes: string[]) {
return `service_token:${scopes.join("-")}`
}
+
+/**
+ * Function to generate tags for hotel page urls
+ *
+ * @param lang Lang
+ * @param hotelId hotelId of reference
+ * @returns string
+ */
+export function generateHotelUrlTag(lang: Lang, hotelId: string) {
+ return `${lang}:hotel_page_url:${hotelId}`
+}