Files
web/server/routers/hotels/output.ts
Bianca Widstam 541b91e34c Merged in feat/SW-1281-ancillaries-add-flow (pull request #1399)
Feat/SW-1281 ancillaries add flow

* feat(SW-1546): update design

* feat(SW-1546): show points only if logged in

* feat(SW-1546): always show points

* feat(SW-1281): ancillary add flow initial

* feat(SW-1546): add api call

* feat(SW-1281): refactor naming and break out components

* feat(SW-1281): handle back button

* feat(SW-1281): make mobile cards clickable

* feat(SW-1281): refactor spread ancillaries

* feat(SW-1281): add deliverytimes

* feat(SW-1281): rebase master

* feat(SW-1281): add design for logged in or not

* feat(SW-1281): add design

* feat(SW-1281): add mobile design

* feat(SW-1281): fix carousel

* feat(SW-1281): show deliverytime only if ancillary has not been added

* feat(SW-1281): add design

* feat(SW-1281): add translations

* feat(SW-1281): add translations

* feat(SW-1281): add translations

* feat(SW-1281): base dates on check in date only

* feat(SW-1281): fix show correct toast when no valid data

* feat(SW-1281): hande logic if deliverytime is not required

* feat(SW-1281): fix max width for mobile

* feat(SW-1281): refactor after pr comment


Approved-by: Niclas Edenvin
Approved-by: Linus Flood
2025-02-26 07:20:45 +00:00

433 lines
12 KiB
TypeScript

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 type {
AdditionalData,
City,
NearbyHotel,
Restaurant,
Room,
} from "@/types/hotel"
import type {
Product,
RateDefinition,
} 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.productType[userType]?.rateCode
)
if (!rateDefinition) {
return false
}
return rateDefinition.breakfastIncluded
}
function getRate(rate: RateDefinition | undefined) {
if (!rate) {
return null
}
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
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((o) => {
const cancellationRuleLookup = o.data.attributes.rateDefinitions.reduce(
(acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
},
{}
)
o.data.attributes.roomConfigurations =
o.data.attributes.roomConfigurations.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(
product,
o.data.attributes.rateDefinitions,
"member"
)
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(
product,
o.data.attributes.rateDefinitions,
"public"
)
)
room.products = room.products.map((product) => {
const publicRateDefinition = o.data.attributes.rateDefinitions.find(
(rate) =>
product.productType.public.rateCode
? rate.rateCode === product.productType.public.rateCode
: rate.rateCode === product.productType.public.oldRateCode
)
const publicRate = getRate(publicRateDefinition)
const memberRateDefinition = o.data.attributes.rateDefinitions.find(
(rate) =>
product.productType.member?.rateCode
? rate.rateCode === product.productType.member?.rateCode
: rate.rateCode === product.productType.member?.oldRateCode
)
const memberRate = getRate(memberRateDefinition)
if (publicRate) {
product.productType.public.rate = publicRate
}
if (memberRate && product.productType.member) {
product.productType.member.rate = memberRate
}
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.productType.public.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.productType.public.rateCode]
)
return room
})
return o.data.attributes
})
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 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,
}
})
)
.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 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: parseInt(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))