import { z } from "zod" import { toLang } from "@/server/utils" import { getPoiGroupByCategoryName } from "./utils" import { PointOfInterestCategoryNameEnum, PointOfInterestGroupEnum, } from "@/types/hotel" 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 hotelFacilityDetailSchema = z.object({ heading: z.string(), description: z.string(), }) const hotelFacilitySchema = z.object({ breakfast: hotelFacilityDetailSchema, checkout: hotelFacilityDetailSchema, gym: hotelFacilityDetailSchema, internet: hotelFacilityDetailSchema, laundry: hotelFacilityDetailSchema, luggage: hotelFacilityDetailSchema, shop: hotelFacilityDetailSchema, telephone: hotelFacilityDetailSchema, }) const hotelInformationDetailSchema = z.object({ heading: z.string(), description: z.string(), link: z.string().optional(), }) const hotelInformationSchema = z.object({ accessibility: hotelInformationDetailSchema, safety: hotelInformationDetailSchema, sustainability: hotelInformationDetailSchema, }) 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 imageMetaDataSchema = z.object({ title: z.string(), altText: z.string(), altText_En: z.string(), copyRight: z.string(), }) const imageSizesSchema = z.object({ tiny: z.string(), small: z.string(), medium: z.string(), large: z.string(), }) const hotelContentSchema = z.object({ images: z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }), texts: z.object({ facilityInformation: z.string(), surroundingInformation: z.string(), descriptions: z.object({ short: z.string(), medium: z.string(), }), }), restaurantsOverviewPage: z.object({ restaurantsOverviewPageLinkText: z.string(), restaurantsOverviewPageLink: z.string(), restaurantsContentDescriptionShort: z.string(), restaurantsContentDescriptionMedium: z.string(), }), }) const detailedFacilitySchema = z.object({ id: z.number(), 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, }) ), }) const healthFacilitySchema = z.object({ type: z.string(), content: z.object({ images: z.array( z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }) ), 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(), }), }) const poiGroups = z.nativeEnum(PointOfInterestGroupEnum) const poiCategoryNames = z.nativeEnum(PointOfInterestCategoryNameEnum) export const pointOfInterestSchema = z .object({ name: z.string(), distance: z.number(), category: z.object({ name: poiCategoryNames, 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(), localCurrency: z.object({ currency: z.string(), range: z.object({ min: z.number().optional(), max: z.number().optional(), }), ordinary: z.array( z.object({ period: z.string(), amount: z.number().optional(), startTime: z.string(), endTime: z.string(), }) ), weekend: z.array( z.object({ period: z.string(), amount: z.number().optional(), startTime: z.string(), endTime: z.string(), }) ), }), requestedCurrency: z .object({ currency: z.string(), range: z.object({ min: z.number(), max: z.number(), }), ordinary: z.array( z.object({ period: z.string(), amount: z.number(), startTime: z.string(), endTime: z.string(), }) ), weekend: z.array( z.object({ period: z.string(), amount: z.number(), startTime: z.string(), endTime: z.string(), }) ), }) .optional(), }) export const parkingSchema = z.object({ type: z.string(), name: z.string(), address: z.string().optional(), numberOfParkingSpots: z.number().optional(), numberOfChargingSpaces: z.number().optional(), distanceToHotel: z.number(), 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 metaSpecialAlertSchema = z.object({ type: z.string(), description: z.string().optional(), displayInBookingFlow: z.boolean(), startDate: z.string(), endDate: z.string(), }) const metaSchema = z.object({ specialAlerts: z.array(metaSpecialAlertSchema), }) 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 roomContentSchema = z.object({ images: z.array( z.object({ metaData: imageMetaDataSchema, imageSizes: imageSizesSchema, }) ), texts: z.object({ descriptions: z.object({ short: z.string(), medium: z.string(), }), }), }) const roomTypesSchema = z.object({ name: z.string(), description: z.string(), code: z.string(), roomCount: z.number(), mainBed: z.object({ type: z.string(), description: z.string(), widthRange: z.object({ min: z.number(), max: z.number(), }), }), fixedExtraBed: z.object({ type: z.string(), description: z.string().optional(), widthRange: z.object({ min: z.number(), max: z.number(), }), }), roomSize: z.object({ min: z.number(), max: z.number(), }), occupancy: z.object({ total: z.number(), adults: z.number(), children: z.number(), }), isLackingCribs: z.boolean(), isLackingExtraBeds: z.boolean(), }) const roomFacilitiesSchema = z.object({ availableInAllRooms: z.boolean(), name: z.string(), isUniqueSellingPoint: z.boolean(), sortOrder: z.number(), }) export const roomSchema = z.object({ attributes: z.object({ name: z.string(), sortOrder: z.number(), content: roomContentSchema, roomTypes: z.array(roomTypesSchema), roomFacilities: z.array(roomFacilitiesSchema), occupancy: z.object({ total: z.number(), adults: z.number(), children: z.number(), }), roomSize: z.object({ min: z.number(), max: z.number(), }), }), id: z.string(), type: z.enum(["roomcategories"]), }) 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) }), }) // 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.string(), // 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({ name: z.string(), operaId: z.string(), keywords: z.array(z.string()), isPublished: z.boolean(), cityId: z.string(), cityName: z.string(), ratings: ratingsSchema, address: addressSchema, contactInformation: contactInformationSchema, hotelFacts: z.object({ checkin: checkinSchema, ecoLabels: ecoLabelsSchema, hotelFacilityDetail: hotelFacilitySchema, hotelInformation: hotelInformationSchema, interior: interiorSchema, receptionHours: receptionHoursSchema, yearBuilt: z.string(), }), location: locationSchema, hotelContent: hotelContentSchema, detailedFacilities: z.array(detailedFacilitySchema), healthFacilities: z.array(healthFacilitySchema), merchantInformationData: merchantInformationSchema, rewardNight: rewardNightSchema, pointsOfInterest: z .array(pointOfInterestSchema) .transform((pois) => pois.sort((a, b) => a.distance - b.distance)), parking: z.array(parkingSchema), specialNeedGroups: z.array(specialNeedGroupSchema), socialMedia: socialMediaSchema, meta: metaSchema.optional(), isActive: z.boolean(), conferencesAndMeetings: facilitySchema.optional(), healthAndWellness: facilitySchema.optional(), restaurantImages: facilitySchema.optional(), }), 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(), }) const occupancySchema = z.object({ adults: z.number(), children: z.number(), }) const bestPricePerStaySchema = z.object({ currency: z.string(), // TODO: remove optional when API is ready regularAmount: z.string().optional(), // TODO: remove optional when API is ready memberAmount: z.string().optional(), }) const bestPricePerNightSchema = z.object({ currency: z.string(), // TODO: remove optional when API is ready regularAmount: z.string().optional(), // TODO: remove optional when API is ready memberAmount: z.string().optional(), }) const linksSchema = z.object({ links: z.array( z.object({ url: z.string().url(), type: z.string(), }) ), }) const hotelsAvailabilitySchema = z.object({ data: z.array( z.object({ attributes: z.object({ checkInDate: z.string(), checkOutDate: z.string(), occupancy: occupancySchema.optional(), status: z.string(), hotelId: z.number(), ratePlanSet: z.string().optional(), bestPricePerStay: bestPricePerStaySchema.optional(), bestPricePerNight: bestPricePerNightSchema.optional(), }), relationships: linksSchema.optional(), type: z.string().optional(), }) ), }) export const getHotelsAvailabilitySchema = hotelsAvailabilitySchema export type HotelsAvailability = z.infer export type HotelsAvailabilityPrices = HotelsAvailability["data"][number]["attributes"]["bestPricePerNight"] const priceSchema = z.object({ pricePerNight: z.string(), pricePerStay: z.string(), currency: z.string(), }) export type Price = z.infer const productSchema = z.object({ productType: z.object({ public: z .object({ rateCode: z.string(), rateType: z.string().optional(), localPrice: priceSchema, requestedPrice: priceSchema.optional(), }) .optional(), member: z .object({ rateCode: z.string(), rateType: z.string().optional(), localPrice: priceSchema, requestedPrice: priceSchema.optional(), }) .optional(), }), }) const roomConfigurationSchema = z.object({ status: z.string(), bedType: z.string(), roomType: z.string(), roomsLeft: z.number(), features: z.array(z.object({ inventory: z.number(), code: z.string() })), 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), }), 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(), 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 } }) ), })