Merged in feat/sw-2863-move-contentstack-router-to-trpc-package (pull request #2389)

feat(SW-2863): Move contentstack router to trpc package

* Add exports to packages and lint rule to prevent relative imports

* Add env to trpc package

* Add eslint to trpc package

* Apply lint rules

* Use direct imports from trpc package

* Add lint-staged config to trpc

* Move lang enum to common

* Restructure trpc package folder structure

* WIP first step

* update internal imports in trpc

* Fix most errors in scandic-web

Just 100 left...

* Move Props type out of trpc

* Fix CategorizedFilters types

* Move more schemas in hotel router

* Fix deps

* fix getNonContentstackUrls

* Fix import error

* Fix entry error handling

* Fix generateMetadata metrics

* Fix alertType enum

* Fix duplicated types

* lint:fix

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package

* Fix broken imports

* Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-06-26 07:53:01 +00:00
parent 0263ab8c87
commit 002d093af4
921 changed files with 3112 additions and 3008 deletions

View File

@@ -0,0 +1,319 @@
import dayjs from "dayjs"
import { z } from "zod"
import { Lang } from "@scandic-hotels/common/constants/language"
import { BreakfastPackageEnum } from "../../enums/breakfast"
import { ChildBedMapEnum } from "../../enums/childBedMapEnum"
import { RoomPackageCodeEnum } from "../../enums/roomFilter"
import { Country } from "../../types/country"
export const hotelsAvailabilityInputSchema = z
.object({
cityId: z.string(),
roomStayStartDate: z.string().refine(
(val) => {
const fromDate = dayjs(val)
return fromDate.isValid()
},
{
message: "FROMDATE_INVALID",
}
),
roomStayEndDate: z.string().refine(
(val) => {
const fromDate = dayjs(val)
return fromDate.isValid()
},
{
message: "TODATE_INVALID",
}
),
adults: z.number(),
children: z.string().optional(),
bookingCode: z.string().optional().default(""),
redemption: z.boolean().optional().default(false),
})
.refine(
(data) => {
const fromDate = dayjs(data.roomStayStartDate).startOf("day")
const toDate = dayjs(data.roomStayEndDate).startOf("day")
return fromDate.isBefore(toDate)
},
{
message: "FROMDATE_BEFORE_TODATE",
}
)
.refine(
(data) => {
const fromDate = dayjs(data.roomStayStartDate)
const today = dayjs().startOf("day")
return fromDate.isSameOrAfter(today)
},
{
message: "FROMDATE_CANNOT_BE_IN_THE_PAST",
}
)
export const getHotelsByHotelIdsAvailabilityInputSchema = z
.object({
hotelIds: z.array(z.number()),
roomStayStartDate: z.string().refine(
(val) => {
const fromDate = dayjs(val)
return fromDate.isValid()
},
{
message: "FROMDATE_INVALID",
}
),
roomStayEndDate: z.string().refine(
(val) => {
const toDate = dayjs(val)
return toDate.isValid()
},
{
message: "TODATE_INVALID",
}
),
adults: z.number(),
children: z.string().optional(),
bookingCode: z.string().optional().default(""),
})
.refine(
(data) => {
const fromDate = dayjs(data.roomStayStartDate).startOf("day")
const toDate = dayjs(data.roomStayEndDate).startOf("day")
return fromDate.isBefore(toDate)
},
{
message: "FROMDATE_BEFORE_TODATE",
}
)
.refine(
(data) => {
const fromDate = dayjs(data.roomStayStartDate)
const today = dayjs().startOf("day")
return fromDate.isSameOrAfter(today)
},
{
message: "FROMDATE_CANNOT_BE_IN_THE_PAST",
}
)
const childrenInRoomSchema = z
.array(
z.object({
age: z.number(),
bed: z.nativeEnum(ChildBedMapEnum),
})
)
.optional()
const baseRoomSchema = z.object({
adults: z.number().int().min(1),
bookingCode: z.string().optional(),
childrenInRoom: childrenInRoomSchema,
packages: z
.array(z.nativeEnum({ ...BreakfastPackageEnum, ...RoomPackageCodeEnum }))
.optional(),
})
const selectedRoomSchema = z.object({
counterRateCode: z.string().optional(),
rateCode: z.string(),
roomTypeCode: z.string(),
})
const baseBookingSchema = z.object({
bookingCode: z.string().optional(),
fromDate: z.string(),
hotelId: z.string(),
searchType: z.string().optional(),
toDate: z.string(),
})
export const selectRateRoomsAvailabilityInputSchema = z
.object({
booking: baseBookingSchema.extend({
rooms: z.array(baseRoomSchema),
}),
lang: z.nativeEnum(Lang),
})
.refine(
(data) => {
const fromDate = dayjs(data.booking.fromDate)
return fromDate.isValid()
},
{
message: "FROMDATE_INVALID",
}
)
.refine(
(data) => {
const toDate = dayjs(data.booking.toDate)
return toDate.isValid()
},
{
message: "TODATE_INVALID",
}
)
.refine(
(data) => {
const fromDate = dayjs(data.booking.fromDate).startOf("day")
const toDate = dayjs(data.booking.toDate).startOf("day")
return fromDate.isBefore(toDate)
},
{
message: "TODATE_MUST_BE_AFTER_FROMDATE",
}
)
.refine(
(data) => {
const fromDate = dayjs(data.booking.fromDate)
const today = dayjs().startOf("day")
return fromDate.isSameOrAfter(today)
},
{
message: "FROMDATE_CANNOT_BE_IN_THE_PAST",
}
)
export const selectRateRoomAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
room: baseRoomSchema,
}),
lang: z.nativeEnum(Lang),
})
export const enterDetailsRoomsAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
rooms: z.array(baseRoomSchema.merge(selectedRoomSchema)),
}),
lang: z.nativeEnum(Lang),
})
export const myStayRoomAvailabilityInputSchema = z.object({
booking: baseBookingSchema.extend({
room: baseRoomSchema.merge(selectedRoomSchema),
}),
lang: z.nativeEnum(Lang),
})
export const roomFeaturesInputSchema = z.object({
adults: z.number(),
childrenInRoom: childrenInRoomSchema,
endDate: z.string(),
hotelId: z.string(),
lang: z.nativeEnum(Lang),
roomFeatureCodes: z
.array(z.nativeEnum({ ...BreakfastPackageEnum, ...RoomPackageCodeEnum }))
.optional(),
startDate: z.string(),
})
export type RoomFeaturesInput = z.input<typeof roomFeaturesInputSchema>
export const hotelInputSchema = z.object({
hotelId: z.string(),
isCardOnlyPayment: z.boolean(),
language: z.nativeEnum(Lang),
})
export const getHotelsByCSFilterInput = z.object({
locationFilter: z
.object({
city: z.string().nullable(),
country: z.nativeEnum(Country).nullable(),
excluded: z.array(z.string()),
})
.nullish(),
hotelsToInclude: z.array(z.string()),
})
export interface GetHotelsByCSFilterInput
extends z.infer<typeof getHotelsByCSFilterInput> {}
export const nearbyHotelIdsInput = z.object({
hotelId: z.string(),
})
export const getDestinationsMapDataInput = z
.object({
lang: z.nativeEnum(Lang),
warmup: z.boolean().optional(),
})
.optional()
export const breakfastPackageInputSchema = z.object({
adults: z.number().min(1, { message: "at least one adult is required" }),
fromDate: z
.string()
.min(1, { message: "fromDate is required" })
.pipe(z.coerce.date()),
hotelId: z.string().min(1, { message: "hotelId is required" }),
toDate: z
.string()
.min(1, { message: "toDate is required" })
.pipe(z.coerce.date()),
})
export const ancillaryPackageInputSchema = z.object({
fromDate: z
.string()
.min(1, { message: "fromDate is required" })
.pipe(z.coerce.date()),
hotelId: z.string().min(1, { message: "hotelId is required" }),
toDate: z.string().pipe(z.coerce.date()).optional(),
})
export const roomPackagesInputSchema = z.object({
adults: z.number(),
children: z.number().optional().default(0),
endDate: z.string(),
hotelId: z.string(),
lang: z.nativeEnum(Lang),
packageCodes: z.array(z.string()).optional().default([]),
startDate: z.string(),
})
export const cityCoordinatesInputSchema = z.object({
city: z.string(),
hotel: z.object({
address: z.string().optional(),
}),
})
export const getMeetingRoomsInputSchema = z.object({
hotelId: z.string(),
language: z.string(),
})
export const getAdditionalDataInputSchema = z.object({
hotelId: z.string(),
language: z.string(),
})
export const getHotelsByCountryInput = z.object({
country: z.nativeEnum(Country),
})
export const getHotelsByCityIdentifierInput = z.object({
cityIdentifier: z.string(),
})
export const getLocationsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const getLocationsUrlsInput = z.object({
lang: z.nativeEnum(Lang),
})

View File

@@ -0,0 +1,654 @@
import { z } from "zod"
import { toLang } from "@scandic-hotels/common/utils/languages"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { RateEnum } from "../../enums/rate"
import { RateTypeEnum } from "../../enums/rateType"
import { RoomPackageCodeEnum } from "../../enums/roomFilter"
import { AvailabilityEnum } from "../../enums/selectHotel"
import {
ancillaryPackageSchema,
breakfastPackageSchema,
packageSchema,
} from "../../routers/hotels/schemas/packages"
import { sortRoomConfigs } from "../../utils/sortRoomConfigs"
import { occupancySchema } from "./schemas/availability/occupancy"
import { productTypeSchema } from "./schemas/availability/productType"
import { citySchema } from "./schemas/city"
import {
attributesSchema,
includedSchema,
relationshipsSchema as hotelRelationshipsSchema,
} from "./schemas/hotel"
import { addressSchema } from "./schemas/hotel/address"
import { detailedFacilitiesSchema } from "./schemas/hotel/detailedFacility"
import { locationSchema } from "./schemas/hotel/location"
import { imageSchema } from "./schemas/image"
import { locationCitySchema } from "./schemas/location/city"
import { locationHotelSchema } from "./schemas/location/hotel"
import { relationshipsSchema } from "./schemas/relationships"
import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration"
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import type {
AdditionalData,
City,
NearbyHotel,
Restaurant,
Room,
} from "../../types/hotel"
import type { Product, RateDefinition } from "../../types/roomAvailability"
// NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html
export const hotelSchema = z
.object({
data: z.object({
attributes: attributesSchema,
id: z.string(),
language: z.string().transform((val) => {
const lang = toLang(val)
if (!lang) {
throw new Error("Invalid language")
}
return lang
}),
relationships: hotelRelationshipsSchema,
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
}),
// NOTE: We can pass an "include" param to the hotel API to retrieve
// additional data for an individual hotel.
included: includedSchema,
})
.transform(({ data: { attributes, ...data }, included }) => {
const additionalData =
included.find(
(inc): inc is AdditionalData => inc!.type === "additionalData"
) ?? ({} as AdditionalData)
const cities = included.filter((inc): inc is City => inc!.type === "cities")
const nearbyHotels = included.filter(
(inc): inc is NearbyHotel => inc!.type === "hotels"
)
const restaurants = included.filter(
(inc): inc is Restaurant => inc!.type === "restaurants"
)
const roomCategories = included.filter(
(inc): inc is Room => inc!.type === "roomcategories"
)
return {
additionalData,
cities,
hotel: {
...data,
...attributes,
},
nearbyHotels,
restaurants,
roomCategories,
}
})
export const hotelsAvailabilitySchema = z.object({
data: z.array(
z.object({
attributes: z.object({
bookingCode: z.string().nullish(),
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
occupancy: occupancySchema,
productType: productTypeSchema,
status: z.string(),
}),
relationships: relationshipsSchema.optional(),
type: z.string().optional(),
})
),
})
function getRate(rate: RateDefinition) {
switch (rate.cancellationRule) {
case "CancellableBefore6PM":
return RateEnum.flex
case "Changeable":
return RateEnum.change
case "NotCancellable":
return RateEnum.save
default:
console.info(
`Unknown cancellationRule [${rate.cancellationRule}]. This should never happen!`
)
return null
}
}
/**
* This is used for custom sorting further down
* to guarantee correct order of rates
*/
const cancellationRules = {
CancellableBefore6PM: 2,
Changeable: 1,
NotCancellable: 0,
} as const
export const roomsAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
bookingCode: nullableStringValidator,
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
mustBeGuaranteed: z.boolean().optional(),
occupancy: occupancySchema.optional(),
packages: z.array(packageSchema).optional().default([]),
rateDefinitions: z.array(rateDefinitionSchema),
roomConfigurations: z.array(roomConfigurationSchema),
}),
}),
})
.transform(({ data: { attributes } }) => {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
function getProductRateCode(product: Product) {
if ("corporateCheque" in product) {
return product.corporateCheque.rateCode
}
if ("redemption" in product && product.redemption) {
return product.redemption.rateCode
}
if ("voucher" in product) {
return product.voucher.rateCode
}
if ("public" in product && product.public) {
return product.public.rateCode
}
if ("member" in product && product.member) {
return product.member.rateCode
}
return ""
}
function sortProductsBasedOnCancellationRule(a: Product, b: Product) {
// @ts-expect-error - index
const lookUpA = cancellationRuleLookup[getProductRateCode(a)]
// @ts-expect-error - index
const lookUpB = cancellationRuleLookup[getProductRateCode(b)]
return lookUpA - lookUpB
}
function findRateDefintion(rateCode: string) {
return rateDefinitions.find(
(rateDefinition) => rateDefinition.rateCode === rateCode
)
}
function getRateDetails(product: Product) {
let rateCode = ""
if ("corporateCheque" in product) {
rateCode = product.corporateCheque.rateCode
} else if ("redemption" in product && product.redemption) {
rateCode = product.redemption.rateCode
} else if ("voucher" in product && product.voucher) {
rateCode = product.voucher.rateCode
} else if ("public" in product && product.public) {
rateCode = product.public.rateCode
} else if ("member" in product && product.member) {
rateCode = product.member.rateCode
}
if (!rateCode) {
return null
}
const rateDefinition = findRateDefintion(rateCode)
if (!rateDefinition) {
return null
}
const rate = getRate(rateDefinition)
if (!rate) {
return null
}
if (attributes.bookingCode) {
product.bookingCode = attributes.bookingCode
} else {
product.bookingCode = undefined
}
product.rate = rate
product.rateDefinition = rateDefinition
return product
}
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
const breakfastIncluded = []
const breakfastIncludedMember = []
for (const product of room.products) {
if ("corporateCheque" in product) {
const rateDetails = getRateDetails(product)
if (rateDetails) {
breakfastIncluded.push(
rateDetails.rateDefinition.breakfastIncluded
)
room.code.push({
...rateDetails,
corporateCheque: product.corporateCheque,
})
}
continue
}
if ("voucher" in product) {
const rateDetails = getRateDetails(product)
if (rateDetails) {
breakfastIncluded.push(
rateDetails.rateDefinition.breakfastIncluded
)
room.code.push({
...rateDetails,
voucher: product.voucher,
})
}
continue
}
// Redemption is an array
if (Array.isArray(product)) {
if (product.length) {
for (const redemption of product) {
const rateDetails = getRateDetails(redemption)
if (rateDetails) {
breakfastIncluded.push(
rateDetails.rateDefinition.breakfastIncluded
)
room.redemptions.push({
...redemption,
...rateDetails,
})
}
}
}
continue
}
if (
("member" in product && product.member) ||
("public" in product && product.public)
) {
const memberRate = product.member
const publicRate = product.public
const rateDetails = getRateDetails(product)
const rateDetailsMember = getRateDetails({
...product,
public: null,
})
if (rateDetails) {
if (publicRate) {
breakfastIncluded.push(
rateDetails.rateDefinition.breakfastIncluded
)
}
if (rateDetailsMember) {
breakfastIncludedMember.push(
rateDetailsMember.rateDefinition.breakfastIncluded
)
rateDetails.rateDefinitionMember =
rateDetailsMember.rateDefinition
}
switch (rateDetails.rateDefinition.rateType) {
case RateTypeEnum.PublicPromotion:
room.campaign.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
break
case RateTypeEnum.Regular:
room.regular.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
break
default:
room.code.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
}
continue
}
}
}
room.breakfastIncludedInAllRates =
!!breakfastIncluded.length && breakfastIncluded.every(Boolean)
room.breakfastIncludedInAllRatesMember =
!!breakfastIncludedMember.length &&
breakfastIncludedMember.every(Boolean)
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.campaign.sort(sortProductsBasedOnCancellationRule)
room.code.sort(sortProductsBasedOnCancellationRule)
room.redemptions.sort(sortProductsBasedOnCancellationRule)
room.regular.sort(sortProductsBasedOnCancellationRule)
const hasCampaignProducts = room.campaign.length
const hasCodeProducts = room.code.length
const hasRedemptionProducts = room.redemptions.length
const hasRegularProducts = room.regular.length
if (
!hasCampaignProducts &&
!hasCodeProducts &&
!hasRedemptionProducts &&
!hasRegularProducts
) {
room.status = AvailabilityEnum.NotAvailable
}
}
return room
})
.sort(sortRoomConfigs)
return {
...attributes,
roomConfigurations,
}
})
export const citiesByCountrySchema = z.object({
data: z.array(
citySchema.transform((data) => {
return {
...data.attributes,
id: data.id,
type: data.type,
}
})
),
})
export const countriesSchema = z.object({
data: z
.array(
z.object({
attributes: z.object({
currency: z.string().default("N/A"),
name: z.string(),
}),
hotelInformationSystemId: z.number().optional(),
id: z.string().optional().default(""),
language: z.string().optional(),
type: z.literal("countries"),
})
)
.transform((data) => {
return data.map((country) => {
return {
...country.attributes,
hotelInformationSystemId: country.hotelInformationSystemId,
id: country.id,
language: country.language,
type: country.type,
}
})
}),
})
export type Cities = z.infer<typeof citiesSchema>
export const citiesSchema = z
.object({
data: z.array(citySchema),
})
.transform(({ data }) => {
if (data.length) {
const city = data[0]
return {
...city.attributes,
id: city.id,
type: city.type,
}
}
return null
})
export const locationsSchema = z.object({
data: z
.array(
z
.discriminatedUnion("type", [locationCitySchema, locationHotelSchema])
.transform((location) => {
if (location.type === "cities") {
return {
...location.attributes,
country: location.attributes.countryName || "",
id: location.id,
type: location.type,
}
}
return {
...location.attributes,
id: location.id,
relationships: {
city: {
cityIdentifier: "",
ianaTimeZoneId: "",
id: "",
isPublished: false,
keywords: [],
name: "",
timeZoneId: "",
type: "cities",
url: location?.relationships?.city?.links?.related ?? "",
},
},
type: location.type,
operaId: location.attributes.operaId ?? "",
}
})
)
.transform((data) =>
data
.filter((node) => !!node && node.isPublished)
.filter((node) => {
if (node.type === "hotels") {
if (!node.operaId) {
return false
}
} else {
if (!node.cityIdentifier) {
return false
}
}
return true
})
.sort((a, b) => {
if (a.type === b.type) {
return a.name.localeCompare(b.name)
} else {
return a.type === "cities" ? -1 : 1
}
})
),
})
export const breakfastPackagesSchema = z
.object({
data: z.object({
attributes: z
.object({
hotelId: z.number(),
packages: z.array(breakfastPackageSchema),
})
.or(z.object({ packages: z.tuple([]) })),
type: z.literal("breakfastpackage"),
}),
})
.transform(({ data }) =>
data.attributes.packages.filter((pkg) => pkg.code?.match(/^(BRF\d+)$/gm))
)
export const ancillaryPackagesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
ancillaries: z.array(ancillaryPackageSchema),
}),
}),
})
.transform(({ data }) =>
data.attributes.ancillaries
.map((ancillary) => ({
categoryName: ancillary.categoryName,
ancillaryContent: ancillary.ancillaryContent
.filter((item) => item.status === "Available")
.map((item) => ({
hotelId: data.attributes.hotelId,
id: item.id,
title: item.title,
description: item.descriptions.html,
imageUrl: item.images[0]?.imageSizes.small || undefined,
price: {
total: item.variants.ancillary.price.totalPrice,
currency: item.variants.ancillary.price.currency,
},
points: item.variants.ancillaryLoyalty?.points,
loyaltyCode: item.variants.ancillaryLoyalty?.code,
requiresDeliveryTime: item.requiresDeliveryTime,
categoryName: ancillary.categoryName,
})),
}))
.filter((ancillary) => ancillary.ancillaryContent.length > 0)
)
export const packagesSchema = z
.object({
data: z
.object({
attributes: z.object({
hotelId: z.number(),
packages: z.array(packageSchema).default([]),
}),
})
.optional(),
})
.transform(({ data }) => data?.attributes.packages)
export const getHotelIdsSchema = z
.object({
data: z.array(
z.object({
attributes: z.object({
isPublished: z.boolean(),
isActive: z.boolean(),
}),
id: z.string(),
})
),
})
.transform(({ data }) => {
const filteredHotels = data.filter((hotel) => hotel.attributes.isPublished)
return filteredHotels.map((hotel) => hotel.id)
})
export const getNearbyHotelIdsSchema = z
.object({
data: z.array(
z.object({
attributes: z.object({
isPublished: z.boolean(),
isActive: z.boolean(),
}),
id: z.string(),
})
),
})
.transform(({ data }) => {
const filteredHotels = data.filter(
(hotel) => hotel.attributes.isPublished && hotel.attributes.isActive
)
return filteredHotels.map((hotel) => hotel.id)
})
export const roomFeaturesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
roomFeatures: z
.array(
z.object({
roomTypeCode: z.string(),
features: z.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
),
})
)
.default([]),
}),
}),
})
.transform((data) => {
return data.data.attributes.roomFeatures
})
export const destinationPagesHotelDataSchema = z
.object({
data: z.object({
id: z.string(),
name: z.string(),
location: locationSchema,
cityIdentifier: z.string().optional(),
tripadvisor: z.number().optional(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
url: z.string().optional(),
hotelContent: z
.object({
texts: z.object({
descriptions: z.object({
short: z.string().optional(),
}),
}),
})
.optional(),
}),
})
.transform(({ data: { hotelContent, ...data } }) => {
return {
hotel: {
...data,
hotelDescription: hotelContent?.texts.descriptions?.short,
},
url: data.url ?? "",
}
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { ChildBedTypeEnum } from "../../../../enums/childBedTypeEnum"
export const childrenSchema = z.object({
age: z.number(),
bedType: z.nativeEnum(ChildBedTypeEnum),
})
export const occupancySchema = z.object({
adults: z.number(),
children: z.array(childrenSchema).default([]),
})

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import {
productTypeCorporateChequeSchema,
productTypePointsSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "../productTypePrice"
export const productTypeSchema = z
.object({
bonusCheque: productTypeCorporateChequeSchema.optional(),
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
voucher: productTypeVoucherSchema.optional(),
})
.optional()

View File

@@ -0,0 +1,14 @@
import { z } from "zod"
export const citySchema = z.object({
attributes: z.object({
cityIdentifier: z.string().default(""),
ianaTimeZoneId: z.string().default(""),
isPublished: z.boolean().default(false),
keywords: z.array(z.string()).default([]),
name: z.string(),
timeZoneId: z.string().default(""),
}),
id: z.string(),
type: z.literal("cities"),
})

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import {
nullableArrayObjectValidator,
nullableArrayStringValidator,
} from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@scandic-hotels/common/utils/zod/numberValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { addressSchema } from "./hotel/address"
import { contactInformationSchema } from "./hotel/contactInformation"
import { hotelContentSchema } from "./hotel/content"
import { detailedFacilitiesSchema } from "./hotel/detailedFacility"
import { hotelFactsSchema } from "./hotel/facts"
import { healthFacilitiesSchema } from "./hotel/healthFacilities"
import { includeSchema } from "./hotel/include"
import { locationSchema } from "./hotel/location"
import { merchantInformationSchema } from "./hotel/merchantInformation"
import { parkingSchema } from "./hotel/parking"
import { pointOfInterestsSchema } from "./hotel/poi"
import { ratingsSchema } from "./hotel/rating"
import { rewardNightSchema } from "./hotel/rewardNight"
import { socialMediaSchema } from "./hotel/socialMedia"
import { specialAlertsSchema } from "./hotel/specialAlerts"
import { imageSchema } from "./image"
export const attributesSchema = z.object({
address: addressSchema,
cityId: nullableStringValidator,
cityName: nullableStringValidator,
contactInformation: contactInformationSchema,
countryCode: nullableStringValidator,
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
healthFacilities: healthFacilitiesSchema,
hotelContent: hotelContentSchema,
hotelFacts: hotelFactsSchema,
hotelType: nullableStringValidator,
isActive: z.boolean(),
isPublished: z.boolean(),
keywords: nullableArrayStringValidator,
location: locationSchema,
merchantInformationData: merchantInformationSchema,
name: nullableStringValidator,
operaId: nullableStringValidator,
parking: nullableArrayObjectValidator(parkingSchema),
pointsOfInterest: pointOfInterestsSchema,
ratings: ratingsSchema,
rewardNight: rewardNightSchema,
socialMedia: socialMediaSchema,
specialAlerts: specialAlertsSchema,
vat: nullableNumberValidator,
})
export const includedSchema = z
.array(includeSchema)
.default([])
.transform((data) =>
data.filter((item) => {
if (item) {
if ("isPublished" in item && item.isPublished === false) {
return false
}
return true
}
return false
})
)
const relationshipSchema = z.object({
links: z.object({
related: z.string(),
}),
})
export const relationshipsSchema = z.object({
meetingRooms: relationshipSchema,
nearbyHotels: relationshipSchema,
restaurants: relationshipSchema,
roomCategories: relationshipSchema,
})

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const addressSchema = z.object({
city: nullableStringValidator,
country: nullableStringValidator,
streetAddress: nullableStringValidator,
zipCode: nullableStringValidator,
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import {
nullableStringEmailValidator,
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
export const contactInformationSchema = z.object({
email: nullableStringEmailValidator,
faxNumber: nullableStringValidator,
phoneNumber: nullableStringValidator,
websiteUrl: nullableStringValidator,
})

View File

@@ -0,0 +1,24 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { imageSchema } from "../image"
const descriptionSchema = z
.object({
medium: nullableStringValidator,
short: nullableStringValidator,
})
.nullish()
const textsSchema = z.object({
descriptions: descriptionSchema,
facilityInformation: nullableStringValidator,
meetingDescription: descriptionSchema,
surroundingInformation: nullableStringValidator,
})
export const hotelContentSchema = z.object({
images: imageSchema,
texts: textsSchema,
})

View File

@@ -0,0 +1,37 @@
import slugify from "slugify"
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { FacilityEnum } from "../../../../enums/facilities"
const rawDetailedFacilitySchema = z.object({
filter: nullableStringValidator,
icon: nullableStringValidator,
id: z.nativeEnum(FacilityEnum),
name: nullableStringValidator,
public: z.boolean(),
sortOrder: z.number(),
})
function transformDetailedFacility(
data: z.output<typeof rawDetailedFacilitySchema>
) {
return {
...data,
slug: slugify(data.name, { lower: true, strict: true }),
}
}
export const detailedFacilitySchema = rawDetailedFacilitySchema.transform(
transformDetailedFacility
)
export const detailedFacilitiesSchema = nullableArrayObjectValidator(
rawDetailedFacilitySchema
).transform((facilities) =>
facilities
.sort((a, b) => b.sortOrder - a.sortOrder)
.map(transformDetailedFacility)
)

View File

@@ -0,0 +1,47 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const checkinSchema = z.object({
checkInTime: nullableStringValidator,
checkOutTime: nullableStringValidator,
onlineCheckout: z.boolean(),
onlineCheckOutAvailableFrom: nullableStringValidator,
})
const ecoLabelsSchema = z.object({
euEcoLabel: z.boolean(),
greenGlobeLabel: z.boolean(),
nordicEcoLabel: z.boolean(),
svanenEcoLabelCertificateNumber: nullableStringValidator,
})
const interiorSchema = z.object({
numberOfBeds: z.number(),
numberOfCribs: z.number(),
numberOfFloors: z.number(),
numberOfRooms: z.object({
connected: z.number(),
forAllergics: z.number(),
forDisabled: z.number(),
nonSmoking: z.number(),
pet: z.number(),
withExtraBeds: z.number(),
total: z.number(),
}),
})
const receptionHoursSchema = z.object({
alwaysOpen: z.boolean(),
closingTime: nullableStringValidator,
isClosed: z.boolean(),
openingTime: nullableStringValidator,
})
export const hotelFactsSchema = z.object({
checkin: checkinSchema,
ecoLabels: ecoLabelsSchema,
interior: interiorSchema,
receptionHours: receptionHoursSchema,
yearBuilt: nullableStringValidator,
})

View File

@@ -0,0 +1,62 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@scandic-hotels/common/utils/zod/numberValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { imageSchema } from "../image"
const healthFacilitiesOpenHoursSchema = z.object({
alwaysOpen: z.boolean(),
closingTime: nullableStringValidator,
isClosed: z.boolean(),
openingTime: nullableStringValidator,
sortOrder: nullableNumberValidator,
})
const descriptionSchema = z
.object({
medium: nullableStringValidator,
short: nullableStringValidator,
})
.nullish()
const detailsSchema = z.object({
name: nullableStringValidator,
type: nullableStringValidator,
value: nullableStringValidator,
})
const textsSchema = z.object({
descriptions: descriptionSchema,
facilityInformation: nullableStringValidator,
meetingDescription: descriptionSchema,
surroundingInformation: nullableStringValidator,
})
export const healthFacilitySchema = z.object({
content: z.object({
images: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
texts: textsSchema,
}),
details: nullableArrayObjectValidator(detailsSchema),
openingDetails: z.object({
manualOpeningHours: nullableStringValidator,
openingHours: z.object({
ordinary: healthFacilitiesOpenHoursSchema,
weekends: healthFacilitiesOpenHoursSchema,
}),
useManualOpeningHours: z
.boolean()
.nullish()
.transform((b) => !!b),
openingHoursType: nullableStringValidator,
}),
type: nullableStringValidator,
})
export const healthFacilitiesSchema =
nullableArrayObjectValidator(healthFacilitySchema)

View File

@@ -0,0 +1,44 @@
import { z } from "zod"
import { citySchema } from "../city"
import {
additionalDataSchema,
transformAdditionalData,
} from "./include/additionalData"
import { nearbyHotelsSchema } from "./include/nearbyHotels"
import { restaurantsSchema } from "./include/restaurants"
import {
roomCategoriesSchema,
transformRoomCategories,
} from "./include/roomCategories"
export const includeSchema = z
.union([
z.null(),
z.undefined(),
z.discriminatedUnion("type", [
additionalDataSchema,
citySchema,
nearbyHotelsSchema,
restaurantsSchema,
roomCategoriesSchema,
]),
])
.transform((data) => {
switch (data?.type) {
case "additionalData":
return transformAdditionalData(data)
case "cities":
case "hotels":
case "restaurants":
return {
...data.attributes,
id: data.id,
type: data.type,
}
case "roomcategories":
return transformRoomCategories(data)
default:
return null
}
})

View File

@@ -0,0 +1,54 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { displayWebPageSchema } from "./additionalData/displayWebPage"
import { facilitySchema } from "./additionalData/facility"
import { gallerySchema } from "./additionalData/gallery"
import { restaurantsOverviewPageSchema } from "./additionalData/restaurantsOverviewPage"
import { specialNeedGroupSchema } from "./additionalData/specialNeedGroups"
export const extraPageSchema = z.object({
elevatorPitch: nullableStringValidator,
mainBody: nullableStringValidator,
nameInUrl: nullableStringValidator,
})
export const additionalDataAttributesSchema = z.object({
accessibility: facilitySchema.nullish(),
conferencesAndMeetings: facilitySchema.nullish(),
displayWebPage: displayWebPageSchema,
gallery: gallerySchema.nullish(),
healthAndFitness: extraPageSchema,
healthAndWellness: facilitySchema.nullish(),
hotelParking: extraPageSchema,
hotelRoomElevatorPitchText: nullableStringValidator,
hotelSpecialNeeds: extraPageSchema,
id: nullableStringValidator,
meetingRooms: extraPageSchema.merge(
z.object({
meetingOnlineLink: z.string().nullish(),
})
),
name: nullableStringValidator,
parkingImages: facilitySchema.nullish(),
restaurantImages: facilitySchema.nullish(),
restaurantsOverviewPage: restaurantsOverviewPageSchema,
specialNeedGroups: nullableArrayObjectValidator(specialNeedGroupSchema),
})
export const additionalDataSchema = z.object({
attributes: additionalDataAttributesSchema,
type: z.literal("additionalData"),
})
export function transformAdditionalData(
data: z.output<typeof additionalDataSchema>
) {
return {
...data.attributes,
id: data.attributes.id,
type: data.type,
}
}

View File

@@ -0,0 +1,19 @@
import { z } from "zod"
export const displayWebPageSchema = z
.object({
healthGym: z.boolean().default(false),
meetingRoom: z.boolean().default(false),
parking: z.boolean().default(false),
specialNeeds: z.boolean().default(false),
})
.nullish()
.transform(
(object) =>
object ?? {
healthGym: false,
meetingRoom: false,
parking: false,
specialNeeds: false,
}
)

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { imageSchema } from "../../../image"
export const facilitySchema = z.object({
headingText: nullableStringValidator,
heroImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
import { imageSchema } from "../../../image"
const imagesSchema = z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
export const gallerySchema = z.object({
heroImages: imagesSchema,
smallerImages: imagesSchema,
})

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const restaurantsOverviewPageSchema = z.object({
restaurantsContentDescriptionMedium: nullableStringValidator,
restaurantsContentDescriptionShort: nullableStringValidator,
restaurantsOverviewPageLink: nullableStringValidator,
restaurantsOverviewPageLinkText: nullableStringValidator,
})

View File

@@ -0,0 +1,14 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
const specialNeedSchema = z.object({
details: nullableStringValidator,
name: nullableStringValidator,
})
export const specialNeedGroupSchema = z.object({
name: nullableStringValidator,
specialNeeds: nullableArrayObjectValidator(specialNeedSchema),
})

View File

@@ -0,0 +1,23 @@
import { z } from "zod"
import { attributesSchema } from "../../hotel"
export const nearbyHotelsSchema = z.object({
attributes: z.lazy(() =>
attributesSchema.pick({
address: true,
cityId: true,
cityName: true,
detailedFacilities: true,
hotelContent: true,
isActive: true,
isPublished: true,
location: true,
name: true,
operaId: true,
ratings: true,
})
),
id: z.string(),
type: z.literal("hotels"),
})

View File

@@ -0,0 +1,95 @@
import { z } from "zod"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import {
nullableIntValidator,
nullableNumberValidator,
} from "@scandic-hotels/common/utils/zod/numberValidator"
import {
nullableStringUrlValidator,
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
import { imageSchema } from "../../image"
import { specialAlertsSchema } from "../specialAlerts"
const descriptionSchema = z.object({
medium: nullableStringValidator,
short: nullableStringValidator,
})
const textSchema = z.object({
descriptions: descriptionSchema,
facilityInformation: nullableStringValidator,
meetingDescription: descriptionSchema.optional(),
surroundingInformation: nullableStringValidator,
})
const contentSchema = z.object({
images: z.array(imageSchema).default([]),
texts: textSchema,
})
const restaurantPriceSchema = z.object({
amount: nullableNumberValidator,
currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.SEK),
})
const externalBreakfastSchema = z.object({
isAvailable: z.boolean().default(false),
localPriceForExternalGuests: restaurantPriceSchema.optional(),
requestedPriceForExternalGuests: restaurantPriceSchema.optional(),
})
const menuItemSchema = z.object({
name: nullableStringValidator,
url: nullableStringUrlValidator,
})
export const openingHoursDetailsSchema = z.object({
alwaysOpen: z.boolean().default(false),
closingTime: nullableStringValidator,
isClosed: z.boolean().default(false),
openingTime: nullableStringValidator,
sortOrder: nullableIntValidator,
})
export const openingHoursSchema = z.object({
friday: openingHoursDetailsSchema.optional(),
isActive: z.boolean().default(false),
monday: openingHoursDetailsSchema.optional(),
name: nullableStringValidator,
saturday: openingHoursDetailsSchema.optional(),
sunday: openingHoursDetailsSchema.optional(),
thursday: openingHoursDetailsSchema.optional(),
tuesday: openingHoursDetailsSchema.optional(),
wednesday: openingHoursDetailsSchema.optional(),
})
const openingDetailsSchema = z.object({
alternateOpeningHours: openingHoursSchema.optional(),
openingHours: openingHoursSchema,
ordinary: openingHoursSchema.optional(),
weekends: openingHoursSchema.optional(),
})
export const restaurantsSchema = z.object({
attributes: z.object({
bookTableUrl: nullableStringValidator,
content: contentSchema,
email: z.string().email().optional(),
externalBreakfast: externalBreakfastSchema,
isPublished: z.boolean().default(false),
menus: z.array(menuItemSchema).default([]),
name: z.string().default(""),
openingDetails: z.array(openingDetailsSchema).default([]),
phoneNumber: z.string().optional(),
restaurantPage: z.boolean().default(false),
elevatorPitch: z.string().optional(),
nameInUrl: z.string().optional(),
mainBody: z.string().optional(),
specialAlerts: specialAlertsSchema,
}),
id: z.string(),
type: z.literal("restaurants"),
})

View File

@@ -0,0 +1,107 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { imageSchema } from "../../image"
const minMaxSchema = z.object({
max: z.number(),
min: z.number(),
})
const bedTypeSchema = z.object({
description: nullableStringValidator,
type: nullableStringValidator,
widthRange: minMaxSchema,
})
const occupancySchema = z.object({
adults: z.number(),
children: z.number(),
total: z.number(),
})
const roomContentSchema = z.object({
images: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
texts: z.object({
descriptions: z.object({
medium: nullableStringValidator,
short: nullableStringValidator,
}),
}),
})
const roomTypesSchema = z.object({
code: nullableStringValidator,
description: nullableStringValidator,
fixedExtraBed: bedTypeSchema.optional(),
isLackingCribs: z.boolean(),
isLackingExtraBeds: z.boolean(),
mainBed: bedTypeSchema,
name: nullableStringValidator,
occupancy: occupancySchema,
roomCount: z.number(),
roomSize: minMaxSchema,
})
const roomFacilitiesSchema = z.object({
availableInAllRooms: z.boolean(),
icon: z.string().optional(),
isUniqueSellingPoint: z.boolean(),
name: z.string(),
sortOrder: z.number(),
})
export const roomCategoriesSchema = z.object({
attributes: z.object({
content: roomContentSchema,
name: nullableStringValidator,
occupancy: minMaxSchema,
roomFacilities: nullableArrayObjectValidator(roomFacilitiesSchema),
roomSize: minMaxSchema,
roomTypes: nullableArrayObjectValidator(roomTypesSchema),
sortOrder: z.number(),
}),
id: z.string(),
type: z.literal("roomcategories"),
})
export function transformRoomCategories(
data: z.output<typeof roomCategoriesSchema>
) {
return {
descriptions: data.attributes.content.texts.descriptions,
id: data.id,
images: data.attributes.content.images,
name: data.attributes.name,
occupancy: data.attributes.occupancy,
roomFacilities: data.attributes.roomFacilities,
roomSize: data.attributes.roomSize,
roomTypes: data.attributes.roomTypes.map((roomType) => {
if (!roomType || roomType.fixedExtraBed?.type.toLowerCase() === "none") {
return {
...roomType,
fixedExtraBed: undefined,
}
}
return roomType
}),
sortOrder: data.attributes.sortOrder,
type: data.type,
totalOccupancy:
data.attributes.occupancy.min === data.attributes.occupancy.max
? {
max: data.attributes.occupancy.max,
range: `${data.attributes.occupancy.max}`,
}
: {
max: data.attributes.occupancy.max,
range: `${data.attributes.occupancy.min}-${data.attributes.occupancy.max}`,
},
}
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod"
export const locationSchema = z.object({
distanceToCentre: z.number(),
latitude: z.number(),
longitude: z.number(),
})

View File

@@ -0,0 +1,23 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import type { PaymentMethodEnum } from "@scandic-hotels/common/constants/paymentMethod"
export const merchantInformationSchema = z.object({
alternatePaymentOptions: z
.record(z.string(), z.boolean())
.transform((val) => {
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
.filter((key): key is PaymentMethodEnum => !!key)
}),
cards: z.record(z.string(), z.boolean()).transform((val) => {
return Object.entries(val)
.filter(([_, enabled]) => enabled)
.map(([key]) => key)
.filter((key): key is PaymentMethodEnum => !!key)
}),
webMerchantId: nullableStringValidator,
})

View File

@@ -0,0 +1,45 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import { nullableNumberValidator } from "@scandic-hotels/common/utils/zod/numberValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
const periodSchema = z.object({
amount: nullableNumberValidator,
endTime: nullableStringValidator,
period: nullableStringValidator,
startTime: nullableStringValidator,
})
const currencySchema = z
.object({
currency: nullableStringValidator,
ordinary: nullableArrayObjectValidator(periodSchema),
range: z
.object({
min: nullableNumberValidator,
max: nullableNumberValidator,
})
.nullish(),
weekend: nullableArrayObjectValidator(periodSchema),
})
.nullish()
const pricingSchema = z.object({
freeParking: z.boolean(),
localCurrency: currencySchema,
paymentType: nullableStringValidator,
requestedCurrency: currencySchema,
})
export const parkingSchema = z.object({
address: nullableStringValidator,
canMakeReservation: z.boolean(),
distanceToHotel: nullableNumberValidator,
externalParkingUrl: nullableStringValidator,
name: nullableStringValidator,
numberOfChargingSpaces: nullableNumberValidator,
numberOfParkingSpots: nullableNumberValidator,
pricing: pricingSchema,
type: nullableStringValidator,
})

View File

@@ -0,0 +1,63 @@
import { z } from "zod"
import { nullableNumberValidator } from "@scandic-hotels/common/utils/zod/numberValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { PointOfInterestGroupEnum } from "../../../../enums/pointOfInterest"
import { locationSchema } from "./location"
export const pointOfInterestSchema = z
.object({
category: z.object({
name: nullableStringValidator,
}),
distance: nullableNumberValidator,
location: locationSchema,
name: nullableStringValidator,
})
.transform((poi) => ({
categoryName: poi.category.name,
coordinates: {
lat: poi.location.latitude,
lng: poi.location.longitude,
},
distance: poi.distance,
group: getPoiGroupByCategoryName(poi.category.name),
name: poi.name,
}))
export const pointOfInterestsSchema = z
.array(pointOfInterestSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
.transform((pois) =>
pois.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))
)
function getPoiGroupByCategoryName(category: string | undefined) {
if (!category) return PointOfInterestGroupEnum.LOCATION
switch (category) {
case "Airport":
case "Bus terminal":
case "Transportations":
return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
case "Amusement park":
case "Museum":
case "Sports":
case "Theatre":
case "Tourist":
case "Zoo":
return PointOfInterestGroupEnum.ATTRACTIONS
case "Nearby companies":
case "Fair":
return PointOfInterestGroupEnum.BUSINESS
case "Parking / Garage":
return PointOfInterestGroupEnum.PARKING
case "Shopping":
case "Restaurant":
return PointOfInterestGroupEnum.SHOPPING_DINING
case "Hospital":
default:
return PointOfInterestGroupEnum.LOCATION
}
}

View File

@@ -0,0 +1,57 @@
import { z } from "zod"
import { nullableArrayObjectValidator } from "@scandic-hotels/common/utils/zod/arrayValidator"
import {
nullableStringUrlValidator,
nullableStringValidator,
} from "@scandic-hotels/common/utils/zod/stringValidator"
const awardSchema = z.object({
displayName: nullableStringValidator,
images: z
.object({
large: nullableStringValidator,
medium: nullableStringValidator,
small: nullableStringValidator,
})
.nullish()
.transform((obj) =>
obj
? obj
: {
small: "",
medium: "",
large: "",
}
),
})
const reviewsSchema = z
.object({
widgetHtmlTagId: nullableStringValidator,
widgetScriptEmbedUrlIframe: nullableStringValidator,
widgetScriptEmbedUrlJavaScript: nullableStringValidator,
})
.nullish()
.transform((obj) =>
obj
? obj
: {
widgetHtmlTagId: "",
widgetScriptEmbedUrlIframe: "",
widgetScriptEmbedUrlJavaScript: "",
}
)
export const ratingsSchema = z
.object({
tripAdvisor: z.object({
awards: nullableArrayObjectValidator(awardSchema),
numberOfReviews: z.number(),
rating: z.number(),
ratingImageUrl: nullableStringUrlValidator,
reviews: reviewsSchema,
webUrl: nullableStringUrlValidator,
}),
})
.optional()

View File

@@ -0,0 +1,12 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const rewardNightSchema = z.object({
campaign: z.object({
end: nullableStringValidator,
points: z.number(),
start: nullableStringValidator,
}),
points: z.number(),
})

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const socialMediaSchema = z.object({
facebook: nullableStringValidator,
instagram: nullableStringValidator,
})

View File

@@ -0,0 +1,42 @@
import { z } from "zod"
import { dt } from "@scandic-hotels/common/dt"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { AlertTypeEnum } from "../../../../types/alertType"
const specialAlertSchema = z.object({
description: nullableStringValidator,
displayInBookingFlow: z.boolean().default(false),
endDate: nullableStringValidator,
startDate: nullableStringValidator,
title: nullableStringValidator,
type: nullableStringValidator,
})
export const specialAlertsSchema = z
.array(specialAlertSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : []))
.transform((data) => {
const now = dt().utc().format("YYYY-MM-DD")
const filteredAlerts = data.filter((alert) => {
const hasText = alert.description || alert.title
const hasDates = alert.startDate && alert.endDate
if (!hasDates) {
return hasText
}
const shouldShowNow = alert.startDate <= now && alert.endDate >= now
return shouldShowNow && hasText
})
return filteredAlerts.map((alert, idx) => ({
heading: alert.title || null,
id: `alert-${alert.type}-${idx}`,
name: alert.type,
text: alert.description || null,
type: AlertTypeEnum.Info,
displayInBookingFlow: alert.displayInBookingFlow,
endDate: alert.endDate,
startDate: alert.startDate,
}))
})

View File

@@ -0,0 +1,45 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const imageSizesSchema = z.object({
large: nullableStringValidator,
medium: nullableStringValidator,
small: nullableStringValidator,
tiny: nullableStringValidator,
})
export const imageMetaDataSchema = z.object({
altText: nullableStringValidator,
altText_En: nullableStringValidator,
copyRight: nullableStringValidator,
title: nullableStringValidator,
})
const DEFAULT_IMAGE_OBJ = {
metaData: {
altText: "Default image",
altText_En: "Default image",
copyRight: "Default image",
title: "Default image",
},
imageSizes: {
tiny: "https://placehold.co/1280x720",
small: "https://placehold.co/1280x720",
medium: "https://placehold.co/1280x720",
large: "https://placehold.co/1280x720",
},
}
export const imageSchema = z
.object({
imageSizes: imageSizesSchema,
metaData: imageMetaDataSchema,
})
.nullish()
.transform((val) => {
if (!val) {
return DEFAULT_IMAGE_OBJ
}
return val
})

View File

@@ -0,0 +1,13 @@
import { z } from "zod"
export const locationCitySchema = z.object({
attributes: z.object({
cityIdentifier: z.string().optional(),
keyWords: z.array(z.string()).optional(),
name: z.string().optional().default(""),
countryName: z.string().optional().default(""),
isPublished: z.boolean(),
}),
id: z.string().optional().default(""),
type: z.literal("cities"),
})

View File

@@ -0,0 +1,35 @@
import { z } from "zod"
export const locationHotelSchema = z.object({
attributes: z.object({
distanceToCentre: z.number().optional(),
images: z
.object({
large: z.string().optional(),
medium: z.string().optional(),
small: z.string().optional(),
tiny: z.string().optional(),
})
.optional(),
isActive: z.boolean(),
isPublished: z.boolean(),
keyWords: z.array(z.string()).optional(),
name: z.string().optional().default(""),
operaId: z.coerce.string().optional(),
}),
id: z.string().optional().default(""),
relationships: z
.object({
city: z
.object({
links: z
.object({
related: z.string().optional(),
})
.optional(),
})
.optional(),
})
.optional(),
type: z.literal("hotels"),
})

View File

@@ -0,0 +1,68 @@
import { z } from "zod"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { BreakfastPackageEnum } from "../../../enums/breakfast"
import { PackageTypeEnum } from "../../../enums/packages"
import { RoomPackageCodeEnum } from "../../../enums/roomFilter"
import { imageSizesSchema } from "./image"
// TODO: Remove optional and default when the API change has been deployed
export const packagePriceSchema = z
.object({
currency: z.nativeEnum(CurrencyEnum).default(CurrencyEnum.Unknown),
price: z.number(),
totalPrice: z.number(),
})
.optional()
.default({
currency: CurrencyEnum.Unknown,
price: 0,
totalPrice: 0,
})
const inventorySchema = z.object({
date: z.string(),
total: z.number(),
available: z.number(),
})
export const ancillaryContentSchema = z.object({
status: z.string(),
id: z.string(),
variants: z.object({
ancillary: z.object({ id: z.string(), price: packagePriceSchema }),
ancillaryLoyalty: z
.object({ points: z.number(), code: z.string() })
.optional(),
}),
title: z.string(),
descriptions: z.object({ html: z.string() }),
images: z.array(z.object({ imageSizes: imageSizesSchema })),
requiresDeliveryTime: z.boolean(),
})
export const packageSchema = z.object({
code: z.nativeEnum({ ...RoomPackageCodeEnum, ...BreakfastPackageEnum }),
description: z.string(),
inventories: z.array(inventorySchema),
itemCode: z.string().default(""),
localPrice: packagePriceSchema,
requestedPrice: packagePriceSchema,
})
export const breakfastPackageSchema = z.object({
code: z.string(),
description: z.string(),
localPrice: packagePriceSchema,
requestedPrice: packagePriceSchema,
packageType: z.enum([
PackageTypeEnum.BreakfastAdult,
PackageTypeEnum.BreakfastChildren,
]),
})
export const ancillaryPackageSchema = z.object({
categoryName: z.string(),
ancillaryContent: z.array(ancillaryContentSchema),
})

View File

@@ -0,0 +1,77 @@
import { z } from "zod"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { nullableNumberValidator } from "@scandic-hotels/common/utils/zod/numberValidator"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
import { RateTypeEnum } from "../../../enums/rateType"
export const corporateChequeSchema = z
.object({
additionalPricePerStay: nullableNumberValidator,
currency: z.nativeEnum(CurrencyEnum).nullish(),
numberOfBonusCheques: nullableNumberValidator,
})
.transform((data) => ({
additionalPricePerStay: data.additionalPricePerStay,
currency: data.currency,
numberOfCheques: data.numberOfBonusCheques,
}))
export const redemptionSchema = z.object({
additionalPricePerStay: nullableNumberValidator,
currency: z.nativeEnum(CurrencyEnum).nullish(),
pointsPerNight: nullableNumberValidator,
pointsPerStay: nullableNumberValidator,
})
export const priceSchema = z.object({
currency: z.nativeEnum(CurrencyEnum),
omnibusPricePerNight: nullableNumberValidator,
pricePerNight: nullableNumberValidator,
pricePerStay: nullableNumberValidator,
regularPricePerNight: nullableNumberValidator,
regularPricePerStay: nullableNumberValidator,
})
const partialPriceSchema = z.object({
rateCode: nullableStringValidator,
rateType: z.nativeEnum(RateTypeEnum).catch((err) => {
const issue = err.error.issues[0]
// This is necessary to handle cases were a
// new `rateType` is added in the API that has
// not yet been handled in web
if (issue.code === "invalid_enum_value") {
return issue.received.toString() as RateTypeEnum
}
return RateTypeEnum.Regular
}),
})
export const productTypeCorporateChequeSchema = z
.object({
localPrice: corporateChequeSchema,
requestedPrice: corporateChequeSchema.nullish(),
})
.merge(partialPriceSchema)
export const productTypePriceSchema = z
.object({
localPrice: priceSchema,
requestedPrice: priceSchema.nullish(),
})
.merge(partialPriceSchema)
export const productTypePointsSchema = z
.object({
localPrice: redemptionSchema,
requestedPrice: redemptionSchema.nullish(),
hasEnoughPoints: z.boolean().optional().default(false),
})
.merge(partialPriceSchema)
export const productTypeVoucherSchema = z
.object({
numberOfVouchers: nullableNumberValidator,
})
.merge(partialPriceSchema)

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const relationshipsSchema = z.object({
links: z.array(
z.object({
type: z.string(),
url: z.string().url(),
})
),
})

View File

@@ -0,0 +1,57 @@
import { z } from "zod"
import { RoomPackageCodeEnum } from "../../../../enums/roomFilter"
import { AvailabilityEnum } from "../../../../enums/selectHotel"
import {
corporateChequeProduct,
priceProduct,
productSchema,
redemptionProduct,
voucherProduct,
} from "./product"
export const roomConfigurationSchema = z.object({
breakfastIncludedInAllRatesMember: z.boolean().default(false),
breakfastIncludedInAllRates: z.boolean().default(false),
features: z
.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
)
.default([]),
products: z.array(productSchema).default([]),
roomsLeft: z.number(),
roomType: z.string(),
roomTypeCode: z.string(),
status: z
.nativeEnum(AvailabilityEnum)
.nullish()
.default(AvailabilityEnum.NotAvailable),
// Red
campaign: z
.array(priceProduct)
.optional()
.transform((val) => (val ? val.filter(Boolean) : [])),
// Blue
code: z
.array(z.union([corporateChequeProduct, priceProduct, voucherProduct]))
.optional()
.transform((val) => (val ? val.filter(Boolean) : [])),
// Beige
regular: z
.array(priceProduct)
.optional()
.transform((val) => (val ? val.filter(Boolean) : [])),
// Burgundy
redemptions: z
.array(redemptionProduct)
.optional()
.transform((val) => (val ? val.filter(Boolean) : [])),
})

View File

@@ -0,0 +1,132 @@
import { z } from "zod"
import { RateEnum } from "../../../../enums/rate"
import {
productTypeCorporateChequeSchema,
productTypePointsSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "../productTypePrice"
import { rateDefinitionSchema } from "./rateDefinition"
const baseProductSchema = z.object({
// transform empty string to undefined
bookingCode: z
.string()
.optional()
.transform((val) => val),
// Used to set the rate that we use to chose titles etc.
rate: z.nativeEnum(RateEnum).default(RateEnum.save),
rateDefinition: rateDefinitionSchema.optional().transform((val) =>
val
? val
: {
breakfastIncluded: false,
cancellationRule: "",
cancellationText: "",
displayPriceRed: false,
isCampaignRate: false,
isMemberRate: false,
isPackageRate: false,
generalTerms: [],
mustBeGuaranteed: false,
rateCode: "",
rateType: "",
title: "",
}
),
rateDefinitionMember: rateDefinitionSchema.optional(),
})
function mapBaseProduct(baseProduct: typeof baseProductSchema._type) {
return {
bookingCode: baseProduct.bookingCode,
rate: baseProduct.rate,
rateDefinition: baseProduct.rateDefinition,
rateDefinitionMember: baseProduct.rateDefinitionMember,
}
}
export const corporateChequeProduct = z
.object({
productType: z
.object({
bonusCheque: productTypeCorporateChequeSchema,
})
.transform((data) => ({
corporateCheque: data.bonusCheque,
})),
})
.merge(baseProductSchema)
.transform((data) => ({
...data.productType,
...mapBaseProduct(data),
}))
export const priceProduct = z
.object({
productType: z.object({
member: productTypePriceSchema.nullish().default(null),
public: productTypePriceSchema.nullish().default(null),
}),
})
.merge(baseProductSchema)
.transform((data) => ({
...data.productType,
...mapBaseProduct(data),
}))
export const redemptionProduct = z
.object({
redemption: productTypePointsSchema,
})
.merge(baseProductSchema)
.transform((data) => ({
redemption: data.redemption,
...mapBaseProduct(data),
}))
export const redemptionsProduct = z
.object({
productType: z.object({
redemptions: z
.array(productTypePointsSchema.merge(baseProductSchema))
.transform((data) =>
data.map(
({
bookingCode,
rate,
rateDefinition,
rateDefinitionMember,
...redemption
}) => ({
bookingCode,
rate,
rateDefinition,
rateDefinitionMember,
redemption,
})
)
),
}),
})
.transform((data) => data.productType.redemptions)
export const voucherProduct = z
.object({
productType: z.object({
voucher: productTypeVoucherSchema,
}),
})
.merge(baseProductSchema)
.transform((data) => ({
...data.productType,
...mapBaseProduct(data),
}))
export const productSchema = z.union([
corporateChequeProduct,
redemptionsProduct,
voucherProduct,
priceProduct,
])

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { nullableStringValidator } from "@scandic-hotels/common/utils/zod/stringValidator"
export const rateDefinitionSchema = z.object({
breakfastIncluded: z.boolean(),
cancellationRule: z.string(),
cancellationText: nullableStringValidator,
displayPriceRed: z.boolean().default(false),
generalTerms: z.array(z.string()),
isCampaignRate: z.boolean().default(false),
isMemberRate: z.boolean().default(false),
isPackageRate: z.boolean().default(false),
mustBeGuaranteed: z.boolean(),
rateCode: z.string(),
rateType: nullableStringValidator,
title: z.string(),
})

View File

@@ -0,0 +1,596 @@
import deepmerge from "deepmerge"
import { Lang } from "@scandic-hotels/common/constants/language"
import { getCacheClient } from "@scandic-hotels/common/dataCache"
import { createCounter } from "@scandic-hotels/common/telemetry"
import { chunk } from "@scandic-hotels/common/utils/chunk"
import { env } from "../../../env/server"
import * as api from "../../api"
import { cache } from "../../DUPLICATED/cache"
import { HotelTypeEnum } from "../../enums/hotelType"
import { badRequestError } from "../../errors"
import { toApiLang } from "../../utils"
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import {
citiesByCountrySchema,
citiesSchema,
countriesSchema,
getHotelIdsSchema,
hotelSchema,
locationsSchema,
} from "./output"
import type { z } from "zod"
import type { Endpoint } from "../../api/endpoints"
import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
} from "../../types/locations"
import type { Cities } from "./output"
export const locationsAffix = "locations"
export async function getCitiesByCountry({
countries,
lang,
affix = locationsAffix,
serviceToken,
}: {
countries: string[]
lang: Lang
affix?: string
serviceToken: string
}): Promise<CitiesGroupedByCountry> {
const cacheClient = await getCacheClient()
const allCitiesByCountries = await Promise.all(
countries.map(async (country) => {
return cacheClient.cacheOrGet(
`${lang}:${affix}:cities-by-country:${country}`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.Cities.country(country),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
throw new Error(`Unable to fetch cities by country ${country}`)
}
const countryJson = await countryResponse.json()
const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
if (!citiesByCountry.success) {
console.error(`Unable to parse cities by country ${country}`)
console.error(citiesByCountry.error)
throw new Error(`Unable to parse cities by country ${country}`)
}
return { ...citiesByCountry.data, country }
},
"1d"
)
})
)
const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
...country,
data: country.data.filter((city) => city.isPublished),
}))
const groupedCitiesByCountry: CitiesGroupedByCountry =
filteredCitiesByCountries.reduce((acc, { country, data }) => {
acc[country] = data
return acc
}, {} as CitiesGroupedByCountry)
return groupedCitiesByCountry
}
export async function getCountries({
lang,
serviceToken,
warmup = false,
}: {
lang: Lang
serviceToken: string
warmup?: boolean
}) {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
`${lang}:${locationsAffix}:countries`,
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const countryResponse = await api.get(
api.endpoints.v1.Hotel.countries,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!countryResponse.ok) {
throw new Error("Unable to fetch countries")
}
const countriesJson = await countryResponse.json()
const countries = countriesSchema.safeParse(countriesJson)
if (!countries.success) {
console.info(`Validation for countries failed`)
console.error(countries.error)
return null
}
return countries.data
},
"1d",
{
cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
}
)
}
export async function getHotelIdsByCityId({
cityId,
serviceToken,
}: {
cityId: string
serviceToken: string
}) {
const getHotelIdsByCityIdCounter = createCounter(
"hotel",
"getHotelIdsByCityId"
)
const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({
cityId,
})
metricsGetHotelIdsByCityId.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${cityId}:hotelsByCityId`,
async () => {
const searchParams = new URLSearchParams({
city: cityId,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
searchParams
)
if (!apiResponse.ok) {
await metricsGetHotelIdsByCityId.httpError(apiResponse)
throw new Error("Unable to fetch hotelIds by cityId")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error)
throw new Error("Unable to parse data for hotelIds by cityId")
}
return validatedHotelIds.data
},
env.CACHE_TIME_HOTELS
)
metricsGetHotelIdsByCityId.success()
return result
}
export async function getCityByCityIdentifier({
cityIdentifier,
lang,
serviceToken,
}: {
cityIdentifier: string
lang: Lang
serviceToken: string
}) {
const locations = await getLocations({
lang,
citiesByCountry: null,
serviceToken,
})
if (!locations || "error" in locations) {
return null
}
const city = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === cityIdentifier)
return city ?? null
}
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const city = await getCityByCityIdentifier({
cityIdentifier,
lang: Lang.en,
serviceToken,
})
if (!city) {
return []
}
const hotelIds = await getHotelIdsByCityId({
cityId: city.id,
serviceToken,
})
return hotelIds
}
export async function getHotelIdsByCountry({
country,
serviceToken,
}: {
country: string
serviceToken: string
}) {
const getHotelIdsByCountryCounter = createCounter(
"hotel",
"getHotelIdsByCountry"
)
const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({
country,
})
metricsGetHotelIdsByCountry.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${country}:hotelsByCountry`,
async () => {
const hotelIdsParams = new URLSearchParams({
country,
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
hotelIdsParams
)
if (!apiResponse.ok) {
await metricsGetHotelIdsByCountry.httpError(apiResponse)
throw new Error("Unable to fetch hotelIds by country")
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error)
throw new Error("Unable to parse hotelIds by country")
}
return validatedHotelIds.data
},
env.CACHE_TIME_HOTELS
)
metricsGetHotelIdsByCountry.success()
return result
}
export async function getHotelsByHotelIds({
hotelIds,
lang,
serviceToken,
}: {
hotelIds: string[]
lang: Lang
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
return await cacheClient.cacheOrGet(
cacheKey,
async () => {
const hotelPages = await getHotelPageUrls(lang)
const chunkedHotelIds = chunk(hotelIds, 10)
const hotels: DestinationPagesHotelData[] = []
for (const hotelIdChunk of chunkedHotelIds) {
const chunkedHotels = await Promise.all(
hotelIdChunk.map(async (hotelId) => {
const hotelResponse = await getHotel(
{ hotelId, language: lang, isCardOnlyPayment: false },
serviceToken
)
if (!hotelResponse) {
throw new Error(`Hotel not found: ${hotelId}`)
}
const hotelPage = hotelPages.find(
(page) => page.hotelId === hotelId
)
const { hotel, cities } = hotelResponse
const data: DestinationPagesHotelData = {
hotel: {
id: hotel.id,
galleryImages: hotel.galleryImages,
name: hotel.name,
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
detailedFacilities: hotel.detailedFacilities || [],
location: hotel.location,
hotelType: hotel.hotelType,
type: hotel.type,
address: hotel.address,
cityIdentifier: cities?.[0]?.cityIdentifier,
hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
},
url: hotelPage?.url ?? "",
}
return data
})
)
hotels.push(...chunkedHotels)
}
return hotels.filter(
(hotel): hotel is DestinationPagesHotelData => !!hotel
)
},
"1d"
)
}
export async function getLocations({
lang,
citiesByCountry,
serviceToken,
}: {
lang: Lang
citiesByCountry: CitiesGroupedByCountry | null
serviceToken: string
}) {
const cacheClient = await getCacheClient()
const countryKeys = Object.keys(citiesByCountry ?? {})
let cacheKey = `${lang}:locations`
if (countryKeys.length) {
cacheKey += `:${countryKeys.join(",")}`
}
return await cacheClient.cacheOrGet(
cacheKey.toLowerCase(),
async () => {
const params = new URLSearchParams({
language: toApiLang(lang),
})
const apiResponse = await api.get(
api.endpoints.v1.Hotel.locations,
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
if (apiResponse.status === 401) {
throw new Error("unauthorized")
} else if (apiResponse.status === 403) {
throw new Error("forbidden")
}
throw new Error("downstream error")
}
const apiJson = await apiResponse.json()
const verifiedLocations = locationsSchema.safeParse(apiJson)
if (!verifiedLocations.success) {
console.info(`Locations Verification Failed`)
console.error(verifiedLocations.error)
throw new Error("Unable to parse locations")
}
const chunkedLocations = chunk(verifiedLocations.data.data, 10)
let locations: z.infer<typeof locationsSchema>["data"] = []
for (const chunk of chunkedLocations) {
const chunkLocations = await Promise.all(
chunk.map(async (location) => {
if (location.type === "cities") {
if (citiesByCountry) {
const country = Object.keys(citiesByCountry).find((country) =>
citiesByCountry[country].find(
(loc) => loc.name === location.name
)
)
if (country) {
return {
...location,
country,
}
} else {
console.info(
`Location cannot be found in any of the countries cities`
)
console.info(location)
}
}
} else if (location.type === "hotels") {
if (location.relationships.city?.url) {
const city = await getCity({
cityUrl: location.relationships.city.url,
serviceToken,
})
if (city) {
return deepmerge(location, {
relationships: {
city,
},
})
}
}
}
return location
})
)
locations.push(...chunkLocations)
}
return locations
},
"1d"
)
}
export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => {
const { hotelId, language, isCardOnlyPayment } = input
const getHotelCounter = createCounter("hotel", "getHotel")
const metricsGetHotel = getHotelCounter.init({
hotelId,
language,
isCardOnlyPayment,
})
metricsGetHotel.start()
const cacheClient = await getCacheClient()
const result = await cacheClient.cacheOrGet(
`${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
async () => {
/**
* Since API expects the params appended and not just
* a comma separated string we need to initialize the
* SearchParams with a sequence of pairs
* (include=City&include=NearbyHotels&include=Restaurants etc.)
**/
const params = new URLSearchParams([
["include", "AdditionalData"],
["include", "City"],
["include", "NearbyHotels"],
["include", "Restaurants"],
["include", "RoomCategories"],
["language", toApiLang(language)],
])
const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.hotel(hotelId),
{
headers: {
Authorization: `Bearer ${serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
await metricsGetHotel.httpError(apiResponse)
return null
}
const apiJson = await apiResponse.json()
const validateHotelData = hotelSchema.safeParse(apiJson)
if (!validateHotelData.success) {
metricsGetHotel.validationError(validateHotelData.error)
throw badRequestError()
}
const hotelData = validateHotelData.data
if (isCardOnlyPayment) {
hotelData.hotel.merchantInformationData.alternatePaymentOptions = []
}
const gallery = hotelData.additionalData?.gallery
if (gallery) {
const smallerImages = gallery.smallerImages
const hotelGalleryImages =
hotelData.hotel.hotelType === HotelTypeEnum.Signature
? smallerImages.slice(0, 10)
: smallerImages.slice(0, 6)
hotelData.hotel.galleryImages = hotelGalleryImages
}
return hotelData
},
env.CACHE_TIME_HOTELS
)
metricsGetHotel.success()
return result
}
)
export async function getCity({
cityUrl,
serviceToken,
}: {
cityUrl: string
serviceToken: string
}): Promise<Cities> {
const cacheClient = await getCacheClient()
return await cacheClient.cacheOrGet(
cityUrl,
async () => {
const url = new URL(cityUrl)
const cityResponse = await api.get(
url.pathname as Endpoint,
{ headers: { Authorization: `Bearer ${serviceToken}` } },
url.searchParams
)
if (!cityResponse.ok) {
return null
}
const cityJson = await cityResponse.json()
const city = citiesSchema.safeParse(cityJson)
if (!city.success) {
console.info(`Validation of city failed`)
console.info(`cityUrl: ${cityUrl}`)
console.error(city.error)
return null
}
return city.data
},
"1d"
)
}