import { z } from "zod" import { toLang } from "@/server/utils" 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 { locationCitySchema } from "./schemas/location/city" import { locationHotelSchema } from "./schemas/location/hotel" import { ancillaryPackageSchema, breakfastPackageSchema, packageSchema, } from "./schemas/packages" import { rateSchema } from "./schemas/rate" import { relationshipsSchema } from "./schemas/relationships" import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration" import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import type { AdditionalData, City, NearbyHotel, Restaurant, Room, } from "@/types/hotel" import type { Product, RateDefinition, RoomConfiguration, } from "@/types/trpc/routers/hotel/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({ checkInDate: z.string(), checkOutDate: z.string(), hotelId: z.number(), occupancy: occupancySchema, productType: productTypeSchema, status: z.string(), }), relationships: relationshipsSchema.optional(), type: z.string().optional(), }) ), }) function everyRateHasBreakfastIncluded( product: Product, rateDefinitions: RateDefinition[], userType: "member" | "public" ) { const rateDefinition = rateDefinitions.find( (rd) => rd.rateCode === product[userType]?.rateCode ) if (!rateDefinition) { return false } return rateDefinition.breakfastIncluded } function getRate(rate: RateDefinition) { switch (rate.cancellationRule) { case "CancellableBefore6PM": return "flex" case "Changeable": return "change" case "NotCancellable": return "save" default: console.info(`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 // Used to ensure `Available` rooms // are shown before all `NotAvailable` const statusLookup = { [AvailabilityEnum.Available]: 1, [AvailabilityEnum.NotAvailable]: 2, } function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) { // @ts-expect-error - array indexing return statusLookup[a.status] - statusLookup[b.status] } export const roomsCombinedAvailabilitySchema = z .object({ data: z.object({ attributes: z.object({ checkInDate: z.string(), checkOutDate: z.string(), hotelId: z.number(), mustBeGuaranteed: z.boolean().optional(), occupancy: occupancySchema.optional(), rateDefinitions: z.array(rateDefinitionSchema), roomConfigurations: z .array(roomConfigurationSchema) .transform((data) => { // Initial sort to guarantee if one bed is NotAvailable and whereas // the other is Available to make sure data is added to the correct // roomConfig const configs = data.sort(sortRoomConfigs) const roomConfigs = new Map() for (const roomConfig of configs) { if (roomConfigs.has(roomConfig.roomType)) { const currentRoomConf = roomConfigs.get(roomConfig.roomType) if (currentRoomConf) { currentRoomConf.features = roomConfig.features.reduce( (feats, feature) => { const currentFeatureIndex = feats.findIndex( (f) => f.code === feature.code ) if (currentFeatureIndex !== -1) { feats[currentFeatureIndex].inventory = feats[currentFeatureIndex].inventory + feature.inventory } else { feats.push(feature) } return feats }, currentRoomConf.features ) currentRoomConf.roomsLeft = currentRoomConf.roomsLeft + roomConfig.roomsLeft roomConfigs.set(currentRoomConf.roomType, currentRoomConf) } } else { roomConfigs.set(roomConfig.roomType, roomConfig) } } return Array.from(roomConfigs.values()) }), }), relationships: relationshipsSchema.optional(), type: z.string().optional(), }), }) .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 }, {}) const roomConfigurations = attributes.roomConfigurations .map((room) => { if (room.products.length) { room.breakfastIncludedInAllRatesMember = room.products.every( (product) => everyRateHasBreakfastIncluded(product, rateDefinitions, "member") ) room.breakfastIncludedInAllRatesPublic = room.products.every( (product) => everyRateHasBreakfastIncluded(product, rateDefinitions, "public") ) room.products = room.products.map((product) => { const publicRate = product.public if (publicRate?.rateCode) { const publicRateDefinition = rateDefinitions.find( (rateDefinition) => rateDefinition.rateCode === publicRate.rateCode ) if (publicRateDefinition) { const rate = getRate(publicRateDefinition) if (rate) { product.rate = rate if (rate === "flex") { product.isFlex = true } } } } const memberRate = product.member if (memberRate?.rateCode) { const memberRateDefinition = rateDefinitions.find( (rate) => rate.rateCode === memberRate.rateCode ) if (memberRateDefinition) { const rate = getRate(memberRateDefinition) if (rate) { product.rate = rate if (rate === "flex") { product.isFlex = true } } } } return product }) // CancellationRule is the same for public and member per product // Sorting to guarantee order based on rate room.products = room.products.sort( (a, b) => // @ts-expect-error - index cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] - // @ts-expect-error - index cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode] ) } return room }) .sort(sortRoomConfigs) return { ...attributes, roomConfigurations, } }) export const roomsAvailabilitySchema = z .object({ data: z.object({ attributes: z.object({ checkInDate: z.string(), checkOutDate: z.string(), hotelId: z.number(), mustBeGuaranteed: z.boolean().optional(), occupancy: occupancySchema.optional(), rateDefinitions: z.array(rateDefinitionSchema), roomConfigurations: z.array(roomConfigurationSchema), }), relationships: relationshipsSchema.optional(), type: z.string().optional(), }), }) .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 }, {}) const roomConfigurations = attributes.roomConfigurations .map((room) => { if (room.products.length) { room.breakfastIncludedInAllRatesMember = room.products.every( (product) => everyRateHasBreakfastIncluded(product, rateDefinitions, "member") ) room.breakfastIncludedInAllRatesPublic = room.products.every( (product) => everyRateHasBreakfastIncluded(product, rateDefinitions, "public") ) room.products = room.products.map((product) => { const publicRate = product.public if (publicRate?.rateCode) { const publicRateDefinition = rateDefinitions.find( (rateDefinition) => rateDefinition.rateCode === publicRate.rateCode ) if (publicRateDefinition) { const rate = getRate(publicRateDefinition) if (rate) { product.rate = rate if (rate === "flex") { product.isFlex = true } } } } const memberRate = product.member if (memberRate?.rateCode) { const memberRateDefinition = rateDefinitions.find( (rate) => rate.rateCode === memberRate.rateCode ) if (memberRateDefinition) { const rate = getRate(memberRateDefinition) if (rate) { product.rate = rate if (rate === "flex") { product.isFlex = true } } } } return product }) // CancellationRule is the same for public and member per product // Sorting to guarantee order based on rate room.products = room.products.sort( (a, b) => // @ts-expect-error - index cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] - // @ts-expect-error - index cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode] ) } return room }) .sort( // @ts-expect-error - array indexing (a, b) => statusLookup[a.status] - statusLookup[b.status] ) return { ...attributes, roomConfigurations, } }) export const ratesSchema = z.array(rateSchema) 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 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?.country ?? "", 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) .filter((node) => { if (node.type === "hotels") { if (!node.operaId) { 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), }), 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({ ancillaries: z.array(ancillaryPackageSchema), }), }), }) .transform(({ data }) => data.attributes.ancillaries .map((ancillary) => ({ categoryName: ancillary.categoryName, ancillaryContent: ancillary.ancillaryContent .filter((item) => item.status === "Available") .map((item) => ({ id: item.id, title: item.title, description: item.descriptions.html, imageUrl: item.images[0]?.imageSizes.small, 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, })), })) .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([]), }), relationships: z .object({ links: z.array( z.object({ type: z.string(), url: z.string(), }) ), }) .optional(), type: z.string(), }) .optional(), }) .transform(({ data }) => data?.attributes.packages) export const getHotelIdsSchema = z .object({ data: z.array( z.object({ attributes: z.object({ isPublished: 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({ // We only care about the hotel id id: z.string(), }) ), }) .transform((data) => data.data.map((hotel) => hotel.id))