import { z } from "zod" import { ChildBedTypeEnum } from "@/constants/booking" import { dt } from "@/lib/dt" import { toLang } from "@/server/utils" import { imageMetaDataSchema, imageSizesSchema } from "./schemas/image" import { roomSchema } from "./schemas/room" import { getPoiGroupByCategoryName } from "./utils" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { AlertTypeEnum } from "@/types/enums/alert" import { CurrencyEnum } from "@/types/enums/currency" import { FacilityEnum } from "@/types/enums/facilities" import { PackageTypeEnum } from "@/types/enums/packages" const ratingsSchema = z .object({ tripAdvisor: z.object({ numberOfReviews: z.number(), rating: z.number(), ratingImageUrl: z.string(), webUrl: z.string(), awards: z.array( z.object({ displayName: z.string(), images: z.object({ small: z.string(), medium: z.string(), large: z.string(), }), }) ), reviews: z .object({ widgetHtmlTagId: z.string(), widgetScriptEmbedUrlIframe: z.string(), widgetScriptEmbedUrlJavaScript: z.string(), }) .optional(), }), }) .optional() const addressSchema = z.object({ streetAddress: z.string(), city: z.string(), zipCode: z.string(), country: z.string(), }) const contactInformationSchema = z.object({ phoneNumber: z.string(), faxNumber: z.string().optional(), email: z.string(), websiteUrl: z.string(), }) const checkinSchema = z.object({ checkInTime: z.string(), checkOutTime: z.string(), onlineCheckOutAvailableFrom: z.string().nullable().optional(), onlineCheckout: z.boolean(), }) const ecoLabelsSchema = z.object({ euEcoLabel: z.boolean(), greenGlobeLabel: z.boolean(), nordicEcoLabel: z.boolean(), svanenEcoLabelCertificateNumber: z.string().optional(), }) const interiorSchema = z.object({ numberOfBeds: z.number(), numberOfCribs: z.number(), numberOfFloors: z.number(), numberOfRooms: z.object({ connected: z.number(), forAllergics: z.number().optional(), forDisabled: z.number(), nonSmoking: z.number(), pet: z.number(), withExtraBeds: z.number(), total: z.number(), }), }) const receptionHoursSchema = z.object({ alwaysOpen: z.boolean(), isClosed: z.boolean(), openingTime: z.string().optional(), closingTime: z.string().optional(), }) const locationSchema = z.object({ distanceToCentre: z.number(), latitude: z.number(), longitude: z.number(), }) const hotelContentSchema = z.object({ images: z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }), texts: z.object({ facilityInformation: z.string().optional(), surroundingInformation: z.string(), descriptions: z.object({ short: z.string(), medium: z.string(), }), }), restaurantsOverviewPage: z.object({ restaurantsOverviewPageLinkText: z.string().optional(), restaurantsOverviewPageLink: z.string().optional(), restaurantsContentDescriptionShort: z.string().optional(), restaurantsContentDescriptionMedium: z.string().optional(), }), }) const detailedFacilitySchema = z.object({ id: z.nativeEnum(FacilityEnum), name: z.string(), public: z.boolean(), sortOrder: z.number(), filter: z.string().optional(), }) export const facilitySchema = z.object({ headingText: z.string(), heroImages: z.array( z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }) ), }) export const imageSchema = z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }) export const gallerySchema = z.object({ heroImages: z.array(imageSchema), smallerImages: z.array(imageSchema), }) const healthFacilitySchema = z.object({ type: z.string(), content: z.object({ images: z.array(imageSchema), texts: z.object({ facilityInformation: z.string().optional(), surroundingInformation: z.string().optional(), descriptions: z.object({ short: z.string(), medium: z.string(), }), }), }), openingDetails: z.object({ useManualOpeningHours: z.boolean(), manualOpeningHours: z.string().optional(), openingHours: z.object({ ordinary: z.object({ alwaysOpen: z.boolean(), isClosed: z.boolean(), openingTime: z.string().optional(), closingTime: z.string().optional(), sortOrder: z.number().optional(), }), weekends: z.object({ alwaysOpen: z.boolean(), isClosed: z.boolean(), openingTime: z.string().optional(), closingTime: z.string().optional(), sortOrder: z.number().optional(), }), }), }), details: z.array( z.object({ name: z.string(), type: z.string(), value: z.string().optional(), }) ), }) const rewardNightSchema = z.object({ points: z.number(), campaign: z.object({ start: z.string(), end: z.string(), points: z.number(), }), }) export const pointOfInterestSchema = z .object({ name: z.string(), distance: z.number(), category: z.object({ name: z.string(), group: z.string(), }), location: locationSchema, isHighlighted: z.boolean(), }) .transform((poi) => ({ name: poi.name, distance: poi.distance, categoryName: poi.category.name, group: getPoiGroupByCategoryName(poi.category.name), coordinates: { lat: poi.location.latitude, lng: poi.location.longitude, }, })) const parkingPricingSchema = z.object({ freeParking: z.boolean(), paymentType: z.string().optional(), localCurrency: z.object({ currency: z.string().optional(), range: z.object({ min: z.number().optional(), max: z.number().optional(), }), ordinary: z .array( z.object({ period: z.string().optional(), amount: z.number().optional(), startTime: z.string().optional(), endTime: z.string().optional(), }) ) .optional(), weekend: z .array( z.object({ period: z.string().optional(), amount: z.number().optional(), startTime: z.string().optional(), endTime: z.string().optional(), }) ) .optional(), }), requestedCurrency: z .object({ currency: z.string().optional(), range: z .object({ min: z.number().optional(), max: z.number().optional(), }) .optional(), ordinary: z .array( z.object({ period: z.string().optional(), amount: z.number().optional(), startTime: z.string().optional(), endTime: z.string().optional(), }) ) .optional(), weekend: z .array( z.object({ period: z.string().optional(), amount: z.number().optional(), startTime: z.string().optional(), endTime: z.string().optional(), }) ) .optional(), }) .optional(), }) export const parkingSchema = z.object({ type: z.string().optional(), name: z.string().optional(), address: z.string().optional(), numberOfParkingSpots: z.number().optional(), numberOfChargingSpaces: z.number().optional(), distanceToHotel: z.number().optional(), canMakeReservation: z.boolean(), pricing: parkingPricingSchema, }) const specialNeedSchema = z.object({ name: z.string(), details: z.string(), }) const specialNeedGroupSchema = z.object({ name: z.string(), specialNeeds: z.array(specialNeedSchema), }) const socialMediaSchema = z.object({ instagram: z.string().optional(), facebook: z.string().optional(), }) const specialAlertSchema = z.object({ type: z.string(), title: z.string().optional(), description: z.string().optional(), displayInBookingFlow: z.boolean(), startDate: z.string().optional(), endDate: z.string().optional(), }) const specialAlertsSchema = z .array(specialAlertSchema) .transform((data) => { const now = dt().utc().format("YYYY-MM-DD") const filteredAlerts = data.filter((alert) => { const shouldShowNow = alert.startDate && alert.endDate ? alert.startDate <= now && alert.endDate >= now : true const hasText = alert.description || alert.title return shouldShowNow && hasText }) return filteredAlerts.map((alert, idx) => ({ id: `alert-${alert.type}-${idx}`, type: AlertTypeEnum.Info, heading: alert.title || null, text: alert.description || null, })) }) .default([]) const relationshipsSchema = z.object({ restaurants: z.object({ links: z.object({ related: z.string(), }), }), nearbyHotels: z.object({ links: z.object({ related: z.string(), }), }), roomCategories: z.object({ links: z.object({ related: z.string(), }), }), meetingRooms: z.object({ links: z.object({ related: z.string(), }), }), }) const merchantInformationSchema = z.object({ webMerchantId: z.string(), cards: z.record(z.string(), z.boolean()).transform((val) => { return Object.entries(val) .filter(([_, enabled]) => enabled) .map(([key]) => key) }), alternatePaymentOptions: z .record(z.string(), z.boolean()) .transform((val) => { return Object.entries(val) .filter(([_, enabled]) => enabled) .map(([key]) => key) }), }) const hotelFacilityDetailSchema = z .object({ description: z.string(), heading: z.string(), }) .optional() /** Possibly more values */ const hotelFacilityDetailsSchema = z.object({ breakfast: hotelFacilityDetailSchema, checkout: hotelFacilityDetailSchema, gym: hotelFacilityDetailSchema, internet: hotelFacilityDetailSchema, laundry: hotelFacilityDetailSchema, luggage: hotelFacilityDetailSchema, shop: hotelFacilityDetailSchema, telephone: hotelFacilityDetailSchema, }) const hotelInformationSchema = z .object({ description: z.string(), heading: z.string(), link: z.string().optional(), }) .optional() const hotelInformationsSchema = z.object({ accessibility: hotelInformationSchema, safety: hotelInformationSchema, sustainability: hotelInformationSchema, }) const hotelFactsSchema = z.object({ checkin: checkinSchema, ecoLabels: ecoLabelsSchema, hotelFacilityDetail: hotelFacilityDetailsSchema.default({}), hotelInformation: hotelInformationsSchema.default({}), interior: interiorSchema, receptionHours: receptionHoursSchema, yearBuilt: z.string(), }) // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html export const getHotelDataSchema = z.object({ data: z.object({ id: z.string(), type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels". language: z.string().transform((val) => { const lang = toLang(val) if (!lang) { throw new Error("Invalid language") } return lang }), attributes: z.object({ accessibilityElevatorPitchText: z.string().optional(), address: addressSchema, cityId: z.string(), cityName: z.string(), conferencesAndMeetings: facilitySchema.optional(), contactInformation: contactInformationSchema, detailedFacilities: z .array(detailedFacilitySchema) .transform((facilities) => facilities.sort((a, b) => b.sortOrder - a.sortOrder) ), gallery: gallerySchema.optional(), galleryImages: z.array(imageSchema).optional(), healthAndWellness: facilitySchema.optional(), healthFacilities: z.array(healthFacilitySchema), hotelContent: hotelContentSchema, hotelFacts: hotelFactsSchema, hotelRoomElevatorPitchText: z.string().optional(), hotelType: z.string().optional(), isActive: z.boolean(), isPublished: z.boolean(), keywords: z.array(z.string()), location: locationSchema, merchantInformationData: merchantInformationSchema, name: z.string(), operaId: z.string(), parking: z.array(parkingSchema), pointsOfInterest: z .array(pointOfInterestSchema) .transform((pois) => pois.sort((a, b) => a.distance - b.distance)), ratings: ratingsSchema, rewardNight: rewardNightSchema, restaurantImages: facilitySchema.optional(), socialMedia: socialMediaSchema, specialAlerts: specialAlertsSchema, specialNeedGroups: z.array(specialNeedGroupSchema), }), relationships: relationshipsSchema, }), // NOTE: We can pass an "include" param to the hotel API to retrieve // additional data for an individual hotel. included: z.array(roomSchema).optional(), }) export const childrenSchema = z.object({ age: z.number(), bedType: z.nativeEnum(ChildBedTypeEnum), }) const occupancySchema = z.object({ adults: z.number(), children: z.array(childrenSchema), }) const linksSchema = z.object({ links: z.array( z.object({ url: z.string().url(), type: z.string(), }) ), }) export const priceSchema = z.object({ pricePerNight: z.coerce.number(), pricePerStay: z.coerce.number(), currency: z.string(), }) export const productTypePriceSchema = z.object({ rateCode: z.string(), rateType: z.string().optional(), localPrice: priceSchema, requestedPrice: priceSchema.optional(), }) const productSchema = z.object({ productType: z.object({ public: productTypePriceSchema.default({ rateCode: "", rateType: "", localPrice: { currency: "SEK", pricePerNight: 0, pricePerStay: 0, }, requestedPrice: undefined, }), member: productTypePriceSchema.optional(), }), }) const hotelsAvailabilitySchema = z.object({ data: z.array( z.object({ attributes: z.object({ checkInDate: z.string(), checkOutDate: z.string(), occupancy: occupancySchema, status: z.string(), hotelId: z.number(), productType: z .object({ public: productTypePriceSchema.optional(), member: productTypePriceSchema.optional(), }) .optional(), }), relationships: linksSchema.optional(), type: z.string().optional(), }) ), }) export const getHotelsAvailabilitySchema = hotelsAvailabilitySchema export type HotelsAvailability = z.infer export type ProductType = HotelsAvailability["data"][number]["attributes"]["productType"] export type ProductTypePrices = z.infer const roomConfigurationSchema = z.object({ status: z.string(), roomTypeCode: z.string(), roomType: z.string(), roomsLeft: z.number(), features: z.array( z.object({ inventory: z.number(), code: z.enum([ RoomPackageCodeEnum.PET_ROOM, RoomPackageCodeEnum.ALLERGY_ROOM, RoomPackageCodeEnum.ACCESSIBILITY_ROOM, ]), }) ), products: z.array(productSchema), }) const rateDefinitionSchema = z.object({ title: z.string(), breakfastIncluded: z.boolean(), rateType: z.string().optional(), rateCode: z.string(), generalTerms: z.array(z.string()), cancellationRule: z.string(), cancellationText: z.string(), mustBeGuaranteed: z.boolean(), }) const roomsAvailabilitySchema = z .object({ data: z.object({ attributes: z.object({ checkInDate: z.string(), checkOutDate: z.string(), occupancy: occupancySchema.optional(), hotelId: z.number(), roomConfigurations: z.array(roomConfigurationSchema), rateDefinitions: z.array(rateDefinitionSchema), mustBeGuaranteed: z.boolean().optional(), }), relationships: linksSchema.optional(), type: z.string().optional(), }), }) .transform((o) => o.data.attributes) export const getRoomsAvailabilitySchema = roomsAvailabilitySchema export type RoomsAvailability = z.infer export type RoomConfiguration = z.infer export type Product = z.infer export type RateDefinition = z.infer const flexibilityPrice = z.object({ standard: z.number(), member: z.number(), }) const rate = z.object({ id: z.number(), name: z.string(), description: z.string(), size: z.string(), imageSrc: z.string(), breakfastIncluded: z.boolean(), prices: z.object({ currency: z.string(), nonRefundable: flexibilityPrice, freeRebooking: flexibilityPrice, freeCancellation: flexibilityPrice, }), }) export const getRatesSchema = z.array(rate) export type Rate = z.infer const hotelFilter = z.object({ roomFacilities: z.array(z.string()), hotelFacilities: z.array(z.string()), hotelSurroundings: z.array(z.string()), }) export const getFiltersSchema = hotelFilter export type HotelFilter = z.infer export const apiCitiesByCountrySchema = z.object({ data: z.array( z .object({ attributes: z.object({ cityIdentifier: z.string().optional(), name: z.string(), keywords: z.array(z.string()).optional(), timeZoneId: z.string().optional(), ianaTimeZoneId: z.string().optional(), isPublished: z.boolean().optional().default(false), }), id: z.string(), type: z.literal("cities"), }) .transform((data) => { return { ...data.attributes, id: data.id, type: data.type, } }) ), }) export interface CitiesByCountry extends z.output {} export type CitiesGroupedByCountry = Record export const apiCountriesSchema = z.object({ data: z .array( z.object({ attributes: z.object({ currency: z.string().optional(), 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 interface Countries extends z.output {} export const apiLocationCitySchema = z.object({ attributes: z.object({ cityIdentifier: z.string().optional(), keyWords: z.array(z.string()).optional(), name: z.string().optional().default(""), }), country: z.string().optional().default(""), id: z.string().optional().default(""), type: z.literal("cities"), }) export const apiCitySchema = z .object({ data: z.array( z.object({ attributes: z.object({ cityIdentifier: z.string().optional(), name: z.string(), keywords: z.array(z.string()), timeZoneId: z.string().optional(), ianaTimeZoneId: z.string().optional(), isPublished: z.boolean().optional().default(false), }), id: z.string().optional(), type: z.literal("cities"), }) ), }) .transform(({ data }) => { if (data.length) { const city = data[0] return { ...city.attributes, id: city.id, type: city.type, } } return null }) export const apiLocationHotelSchema = 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(), keyWords: z.array(z.string()).optional(), name: z.string().optional().default(""), operaId: z.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"), }) export const apiLocationsSchema = z.object({ data: z .array( z .discriminatedUnion("type", [ apiLocationCitySchema, apiLocationHotelSchema, ]) .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, } }) ) .transform((data) => data .filter((node) => !!node) .sort((a, b) => { if (a.type === b.type) { return a.name.localeCompare(b.name) } else { return a.type === "cities" ? -1 : 1 } }) ), }) export const packagePriceSchema = z.object({ currency: z.nativeEnum(CurrencyEnum), price: z.string(), totalPrice: z.string(), }) export const breakfastPackageSchema = z.object({ code: z.string(), description: z.string(), localPrice: packagePriceSchema, requestedPrice: packagePriceSchema, packageType: z.literal(PackageTypeEnum.BreakfastAdult), }) 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 packagesSchema = z.object({ code: z.nativeEnum(RoomPackageCodeEnum), itemCode: z.string().optional(), description: z.string(), localPrice: packagePriceSchema, requestedPrice: packagePriceSchema, inventories: z.array( z.object({ date: z.string(), total: z.number(), available: z.number(), }) ), }) export const getRoomPackagesSchema = z .object({ data: z.object({ attributes: z.object({ hotelId: z.number(), packages: z.array(packagesSchema).optional().default([]), }), relationships: z .object({ links: z.array( z.object({ url: z.string(), type: z.string(), }) ), }) .optional(), type: z.string(), }), }) .transform((data) => data.data.attributes.packages)