diff --git a/.env.local.example b/.env.local.example index 5043646c9..7de28e6ee 100644 --- a/.env.local.example +++ b/.env.local.example @@ -52,7 +52,7 @@ GOOGLE_STATIC_MAP_SIGNATURE_SECRET="" GOOGLE_STATIC_MAP_ID="" GOOGLE_DYNAMIC_MAP_ID="" -HIDE_FOR_NEXT_RELEASE="false" +NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="false" ENABLE_BOOKING_FLOW="false" ENABLE_BOOKING_WIDGET="false" diff --git a/.env.test b/.env.test index e48b5dc41..b9083de46 100644 --- a/.env.test +++ b/.env.test @@ -42,7 +42,7 @@ GOOGLE_STATIC_MAP_KEY="test" GOOGLE_STATIC_MAP_SIGNATURE_SECRET="test" GOOGLE_STATIC_MAP_ID="test" GOOGLE_DYNAMIC_MAP_ID="test" -HIDE_FOR_NEXT_RELEASE="true" +NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE="true" SALESFORCE_PREFERENCE_BASE_URL="test" USE_NEW_REWARDS_ENDPOINT="true" USE_NEW_REWARD_MODEL="true" diff --git a/app/api/web/revalidate/hotel/route.ts b/app/api/web/revalidate/hotel/route.ts new file mode 100644 index 000000000..061e570cc --- /dev/null +++ b/app/api/web/revalidate/hotel/route.ts @@ -0,0 +1,73 @@ +import { revalidateTag } from "next/cache" +import { headers } from "next/headers" +import { z } from "zod" + +import { Lang } from "@/constants/languages" +import { env } from "@/env/server" +import { badRequest, internalServerError, notFound } from "@/server/errors/next" + +import { generateHotelUrlTag } from "@/utils/generateTag" + +import type { NextRequest } from "next/server" + +const validateJsonBody = z.object({ + data: z.object({ + content_type: z.object({ + uid: z.literal("hotel_page"), + }), + entry: z.object({ + hotel_page_id: z.string(), + locale: z.nativeEnum(Lang), + publish_details: z.object({ locale: z.nativeEnum(Lang) }).optional(), + }), + }), +}) + +export async function POST(request: NextRequest) { + try { + const headersList = headers() + const secret = headersList.get("x-revalidate-secret") + + if (secret !== env.REVALIDATE_SECRET) { + console.error(`Invalid Secret`) + console.error({ secret }) + return badRequest({ revalidated: false, now: Date.now() }) + } + + const data = await request.json() + const validatedData = validateJsonBody.safeParse(data) + if (!validatedData.success) { + console.error("Bad validation for `validatedData` in hotel revalidation") + console.error(validatedData.error) + return internalServerError({ revalidated: false, now: Date.now() }) + } + + const { + data: { + data: { content_type, entry }, + }, + } = validatedData + + // The publish_details.locale is the locale that the entry is published in, regardless if it is "localized" or not + const locale = entry.publish_details?.locale ?? entry.locale + + let tag = "" + if (content_type.uid === "hotel_page") { + const tag = generateHotelUrlTag(locale, entry.hotel_page_id) + } else { + console.error( + `Invalid content_type, received ${content_type.uid}, expected "hotel_page"` + ) + return notFound({ revalidated: false, now: Date.now() }) + } + + console.info(`Revalidating hotel url tag: ${tag}`) + revalidateTag(tag) + + return Response.json({ revalidated: true, now: Date.now() }) + } catch (error) { + console.error("Failed to revalidate tag(s) for hotel") + console.error(error) + return internalServerError({ revalidated: false, now: Date.now() }) + } +} diff --git a/components/ContentType/ContentPage/HotelListingItem/hotelListingItem.module.css b/components/Blocks/HotelListing/HotelListingItem/hotelListingItem.module.css similarity index 100% rename from components/ContentType/ContentPage/HotelListingItem/hotelListingItem.module.css rename to components/Blocks/HotelListing/HotelListingItem/hotelListingItem.module.css diff --git a/components/ContentType/ContentPage/HotelListingItem/index.tsx b/components/Blocks/HotelListing/HotelListingItem/index.tsx similarity index 60% rename from components/ContentType/ContentPage/HotelListingItem/index.tsx rename to components/Blocks/HotelListing/HotelListingItem/index.tsx index 18f8f6f7f..fea0d1a1b 100644 --- a/components/ContentType/ContentPage/HotelListingItem/index.tsx +++ b/components/Blocks/HotelListing/HotelListingItem/index.tsx @@ -1,4 +1,4 @@ -import { ScandicLogoIcon } from "@/components/Icons" +import HotelLogo from "@/components/Icons/Logos" import Image from "@/components/Image" import Button from "@/components/TempDesignSystem/Button" import Divider from "@/components/TempDesignSystem/Divider" @@ -10,25 +10,27 @@ import Title from "@/components/TempDesignSystem/Text/Title" import { getIntl } from "@/i18n" import getSingleDecimal from "@/utils/numberFormatting" +import { getTypeSpecificInformation } from "./utils" + import styles from "./hotelListingItem.module.css" import type { HotelListingItemProps } from "@/types/components/contentPage/hotelListingItem" export default async function HotelListingItem({ - imageUrl, - altText, - name, - address, - distanceToCentre, - description, - link, + hotel, + contentType = "hotel", + url, }: HotelListingItemProps) { const intl = await getIntl() + const { description, imageSrc, altText } = getTypeSpecificInformation( + contentType, + hotel + ) return (
{altText}
- + - {name} + {hotel.name}
- {address} + + {hotel.address.streetAddress} +
{intl.formatMessage( { id: "Distance in km to city centre" }, - { number: getSingleDecimal(distanceToCentre / 1000) } + { + number: getSingleDecimal( + hotel.location.distanceToCentre / 1000 + ), + } )}
{description} - + {url && ( + + )}
) diff --git a/components/Blocks/HotelListing/HotelListingItem/utils.ts b/components/Blocks/HotelListing/HotelListingItem/utils.ts new file mode 100644 index 000000000..6fb4a6a40 --- /dev/null +++ b/components/Blocks/HotelListing/HotelListingItem/utils.ts @@ -0,0 +1,36 @@ +import type { Hotel } from "@/types/hotel" +import type { HotelListing } from "@/types/trpc/routers/contentstack/blocks" + +export function getTypeSpecificInformation( + contentType: HotelListing["contentType"], + hotel: Hotel +) { + const { restaurantsOverviewPage, images } = hotel.hotelContent + const { descriptions, meetingDescription } = hotel.hotelContent.texts + const hotelData = { + description: descriptions.short, + imageSrc: images.imageSizes.small, + altText: images.metaData.altText, + } + switch (contentType) { + case "meeting": + const meetingImage = hotel.conferencesAndMeetings?.heroImages[0] + return { + description: meetingDescription?.short || hotelData.description, + imageSrc: meetingImage?.imageSizes.small || hotelData.imageSrc, + altText: meetingImage?.metaData.altText || hotelData.altText, + } + case "restaurant": + const restaurantImage = hotel.restaurantImages?.heroImages[0] + return { + description: + restaurantsOverviewPage.restaurantsContentDescriptionShort || + hotelData.description, + imageSrc: restaurantImage?.imageSizes.small || hotelData.imageSrc, + altText: restaurantImage?.metaData.altText || hotelData.altText, + } + case "hotel": + default: + return hotelData + } +} diff --git a/components/Blocks/HotelListing/index.tsx b/components/Blocks/HotelListing/index.tsx new file mode 100644 index 000000000..06e1d9087 --- /dev/null +++ b/components/Blocks/HotelListing/index.tsx @@ -0,0 +1,40 @@ +import { getHotels } from "@/lib/trpc/memoizedRequests" + +import SectionContainer from "@/components/Section/Container" +import Title from "@/components/TempDesignSystem/Text/Title" + +import HotelListingItem from "./HotelListingItem" + +import type { HotelListingProps } from "@/types/components/blocks/hotelListing" + +export default async function HotelListing({ + heading, + locationFilter, + hotelsToInclude, + contentType, +}: HotelListingProps) { + const hotels = await getHotels({ + locationFilter, + hotelsToInclude: hotelsToInclude, + }) + + if (!hotels.length) { + return null + } + + return ( + + + {heading} + + {hotels.map(({ data, url }) => ( + + ))} + + ) +} diff --git a/components/Blocks/index.tsx b/components/Blocks/index.tsx index f165b50ba..0382fbfbc 100644 --- a/components/Blocks/index.tsx +++ b/components/Blocks/index.tsx @@ -6,13 +6,14 @@ import UspGrid from "@/components/Blocks/UspGrid" import JsonToHtml from "@/components/JsonToHtml" import AccordionSection from "./Accordion" +import HotelListing from "./HotelListing" import Table from "./Table" import type { BlocksProps } from "@/types/components/blocks" import { BlocksEnums } from "@/types/enums/blocks" export default function Blocks({ blocks }: BlocksProps) { - return blocks.map((block, idx) => { + return blocks.map(async (block, idx) => { const firstItem = idx === 0 switch (block.typename) { case BlocksEnums.block.Accordion: @@ -48,6 +49,21 @@ export default function Blocks({ blocks }: BlocksProps) { key={`${block.dynamic_content.title}-${idx}`} /> ) + case BlocksEnums.block.HotelListing: + const { heading, contentType, locationFilter, hotelsToInclude } = + block.hotel_listing + if (!locationFilter && !hotelsToInclude.length) { + return null + } + + return ( + + ) case BlocksEnums.block.Shortcuts: return ( ))} - - setLightboxIsOpen(false)} - /> + {images.length > 1 && ( + <> + + setLightboxIsOpen(false)} + /> + + )} ) } diff --git a/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx b/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx new file mode 100644 index 000000000..2dd696349 --- /dev/null +++ b/components/HotelReservation/EnterDetails/BedType/BedTypeInfo/index.tsx @@ -0,0 +1,44 @@ +import { useIntl } from "react-intl" + +import { useEnterDetailsStore } from "@/stores/enter-details" + +import Body from "@/components/TempDesignSystem/Text/Body" + +import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" +import type { BedTypeInfoProps } from "@/types/components/hotelReservation/enterDetails/bedType" + +export default function BedTypeInfo({ hasMultipleBedTypes }: BedTypeInfoProps) { + const intl = useIntl() + + const hasChildWithExtraBed = useEnterDetailsStore((state) => + state.booking.rooms[0].children?.some( + (child) => Number(child.bed) === ChildBedMapEnum.IN_EXTRA_BED + ) + ) + + const availabilityText = intl.formatMessage({ + id: "Your selected bed type will be provided based on availability", + }) + + const extraBedText = intl.formatMessage({ + id: "Extra bed will be provided additionally", + }) + + if (hasMultipleBedTypes && hasChildWithExtraBed) { + return ( + + {availabilityText}. {extraBedText} + + ) + } + + if (hasMultipleBedTypes) { + return {availabilityText} + } + + if (hasChildWithExtraBed) { + return {extraBedText} + } + + return null +} diff --git a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css index 844ed4a6b..9bde9175b 100644 --- a/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css +++ b/components/HotelReservation/EnterDetails/BedType/bedOptions.module.css @@ -1,3 +1,9 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--Spacing-x2); +} + .form { display: grid; gap: var(--Spacing-x2); diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 4f89eb2f0..9002f344e 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -9,6 +9,7 @@ import { useEnterDetailsStore } from "@/stores/enter-details" import { KingBedIcon } from "@/components/Icons" import RadioCard from "@/components/TempDesignSystem/Form/ChoiceCard/Radio" +import BedTypeInfo from "./BedTypeInfo" import { bedTypeFormSchema } from "./schema" import styles from "./bedOptions.module.css" @@ -62,26 +63,29 @@ export default function BedType({ bedTypes }: BedTypeProps) { return ( -
- {bedTypes.map((roomType) => { - const width = - roomType.size.max === roomType.size.min - ? `${roomType.size.min} cm` - : `${roomType.size.min} cm - ${roomType.size.max} cm` - return ( - - ) - })} - +
+ 1} /> +
+ {bedTypes.map((roomType) => { + const width = + roomType.size.max === roomType.size.min + ? `${roomType.size.min} cm` + : `${roomType.size.min} cm - ${roomType.size.max} cm` + return ( + + ) + })} + +
) } diff --git a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index 2d1e02f77..0139daa28 100644 --- a/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -22,6 +22,7 @@ import Modal from "../../Modal" import styles from "./ui.module.css" +import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import type { SummaryProps } from "@/types/components/hotelReservation/summary" import type { DetailsState } from "@/types/stores/enter-details" @@ -67,6 +68,25 @@ export default function SummaryUI({ const adults = booking.rooms[0].adults const children = booking.rooms[0].children + const childrenBeds = children?.reduce( + (acc, value) => { + const bedType = Number(value.bed) + if (bedType === ChildBedMapEnum.IN_ADULTS_BED) { + return acc + } + const count = acc.get(bedType) ?? 0 + acc.set(bedType, count + 1) + return acc + }, + new Map([ + [ChildBedMapEnum.IN_CRIB, 0], + [ChildBedMapEnum.IN_EXTRA_BED, 0], + ]) + ) + + const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB) + const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED) + const memberPrice = roomRate.memberRate ? { currency: roomRate.memberRate.localPrice.currency, @@ -179,12 +199,7 @@ export default function SummaryUI({ : null} {bedType ? (
-
- {bedType.description} - - {intl.formatMessage({ id: "Based on availability" })} - -
+ {bedType.description} {intl.formatNumber(0, { @@ -194,7 +209,39 @@ export default function SummaryUI({
) : null} - + {childBedCrib ? ( +
+
+ + {`${intl.formatMessage({ id: "Crib (child)" })} × ${childBedCrib}`} + + + {intl.formatMessage({ id: "Based on availability" })} + +
+ + {intl.formatNumber(0, { + currency: roomPrice.local.currency, + style: "currency", + })} + +
+ ) : null} + {childBedExtraBed ? ( +
+
+ + {`${intl.formatMessage({ id: "Extra bed (child)" })} × ${childBedExtraBed}`} + +
+ + {intl.formatNumber(0, { + currency: roomPrice.local.currency, + style: "currency", + })} + +
+ ) : null} {breakfast === false ? (
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 medScandics 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}` +}