From 3939bf7cdc874b6d0e0646014bcfba29f9b076d6 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 11 Dec 2024 14:46:38 +0100 Subject: [PATCH] feat(SW-664): Hotel listing component and queries for content pages --- app/api/web/revalidate/hotel/route.ts | 73 +++++ .../hotelListingItem.module.css | 0 .../HotelListing}/HotelListingItem/index.tsx | 58 ++-- .../HotelListing/HotelListingItem/utils.ts | 36 +++ components/Blocks/HotelListing/index.tsx | 40 +++ components/Blocks/index.tsx | 18 +- .../HotelReservation/HotelCard/index.tsx | 2 +- .../HotelLogo => Icons/Logos}/index.tsx | 2 +- .../Fragments/Blocks/HotelListing.graphql | 24 ++ .../Query/ContentPage/ContentPage.graphql | 2 + .../Query/HotelPage/HotelPageUrl.graphql | 12 + lib/trpc/memoizedRequests/index.ts | 7 + .../contentstack/contentPage/output.ts | 8 + .../routers/contentstack/hotelPage/output.ts | 25 ++ .../routers/contentstack/hotelPage/query.ts | 21 +- .../contentstack/hotelPage/telemetry.ts | 33 +++ .../routers/contentstack/hotelPage/utils.ts | 104 +++++-- .../schemas/blocks/hotelListing.ts | 62 ++++ server/routers/hotels/input.ts | 13 + server/routers/hotels/output.ts | 11 + server/routers/hotels/query.ts | 277 ++++++++++++++---- server/routers/hotels/telemetry.ts | 74 +++++ server/routers/hotels/utils.ts | 151 +++++++++- types/components/blocks/hotelListing.ts | 8 + .../contentPage/hotelListingItem.ts | 15 +- .../hotelLogoProps.ts => hotelLogo.ts} | 0 types/enums/blocks.ts | 1 + types/enums/contentPage.ts | 1 + types/enums/country.ts | 8 + types/trpc/routers/contentstack/blocks.ts | 19 +- types/trpc/routers/contentstack/hotelPage.ts | 11 +- utils/generateTag.ts | 13 +- 32 files changed, 989 insertions(+), 140 deletions(-) create mode 100644 app/api/web/revalidate/hotel/route.ts rename components/{ContentType/ContentPage => Blocks/HotelListing}/HotelListingItem/hotelListingItem.module.css (100%) rename components/{ContentType/ContentPage => Blocks/HotelListing}/HotelListingItem/index.tsx (60%) create mode 100644 components/Blocks/HotelListing/HotelListingItem/utils.ts create mode 100644 components/Blocks/HotelListing/index.tsx rename components/{HotelReservation/HotelCard/HotelLogo => Icons/Logos}/index.tsx (90%) create mode 100644 lib/graphql/Fragments/Blocks/HotelListing.graphql create mode 100644 lib/graphql/Query/HotelPage/HotelPageUrl.graphql create mode 100644 server/routers/contentstack/hotelPage/telemetry.ts create mode 100644 server/routers/contentstack/schemas/blocks/hotelListing.ts create mode 100644 server/routers/hotels/telemetry.ts create mode 100644 types/components/blocks/hotelListing.ts rename types/components/{hotelReservation/selectHotel/hotelLogoProps.ts => hotelLogo.ts} (100%) create mode 100644 types/enums/country.ts 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 ( { + 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..ae35da556 100644 --- a/server/routers/hotels/output.ts +++ b/server/routers/hotels/output.ts @@ -870,3 +870,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..4cebfea69 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -1,9 +1,8 @@ -import { metrics } from "@opentelemetry/api" - import * as api from "@/lib/api" import { dt } from "@/lib/dt" import { badRequestError } from "@/server/errors/trpc" import { + contentStackBaseWithServiceProcedure, publicProcedure, router, safeProtectedServiceProcedure, @@ -13,12 +12,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 +34,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,57 +71,9 @@ 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) => { @@ -695,6 +673,199 @@ 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 options: RequestOptionsWithOutBody = { + cache: "force-cache", + 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: toApiLang(ctx.lang), + }) + const locations = await getLocations( + ctx.lang, + 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: ctx.lang, + 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: ctx.lang, + 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/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..f2b9803e5 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, { cityId }) + console.info( + "api.hotel.hotel-ids start", + JSON.stringify({ query: { cityId } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Hotel.hotels, + options, + params + ) + + if (!apiResponse.ok) { + const responseMessage = await apiResponse.text() + getHotelIdsFailCounter.add(1, { + cityId, + error_type: "http_error", + error: responseMessage, + }) + console.error( + "api.hotel.hotel-ids fetch error", + JSON.stringify({ + query: { cityId }, + 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, { + cityId, + error_type: "validation_error", + error: JSON.stringify(validatedHotelIds.error), + }) + console.error( + "api.hotel.hotel-ids validation error", + JSON.stringify({ + query: { cityId }, + error: validatedHotelIds.error, + }) + ) + return null + } + + getHotelIdsSuccessCounter.add(1, { cityId }) + console.info( + "api.hotel.hotel-ids success", + JSON.stringify({ query: { cityId } }) + ) + + return validatedHotelIds.data + }, + [`hotels`, 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 + }, + [`hotels`, params.toString()], + { revalidate: TWENTYFOUR_HOURS } + )(params) +} 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/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}` +}