Feat/SW-431 payment flow * feat(SW-431): Update mock hotel data * feat(SW-431): Added route handler and trpc routes * feat(SW-431): List payment methods and handle booking status and redirection * feat(SW-431): Updated booking page to poll for booking status * feat(SW-431): Updated create booking contract * feat(SW-431): small fix * fix(SW-431): Added intl string and sorted dictionaries * fix(SW-431): Changes from PR * fix(SW-431): fixes from PR * fix(SW-431): add todo comments * fix(SW-431): update schema prop Approved-by: Simon.Emanuelsson
781 lines
19 KiB
TypeScript
781 lines
19 KiB
TypeScript
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(),
|
|
})
|
|
|
|
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(),
|
|
}),
|
|
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(),
|
|
amount: z.number(),
|
|
regularAmount: z.number(),
|
|
memberAmount: z.number(),
|
|
discountRate: z.number(),
|
|
discountAmount: z.number(),
|
|
points: z.number(),
|
|
numberOfVouchers: z.number(),
|
|
numberOfBonusCheques: z.number(),
|
|
})
|
|
|
|
const bestPricePerNightSchema = z.object({
|
|
currency: z.string(),
|
|
amount: z.number(),
|
|
regularAmount: z.number(),
|
|
memberAmount: z.number(),
|
|
discountRate: z.number(),
|
|
discountAmount: z.number(),
|
|
points: z.number(),
|
|
numberOfVouchers: z.number(),
|
|
numberOfBonusCheques: z.number(),
|
|
})
|
|
|
|
const linksSchema = z.object({
|
|
links: z.array(
|
|
z.object({
|
|
url: z.string().url(),
|
|
type: z.string(),
|
|
})
|
|
),
|
|
})
|
|
|
|
const availabilitySchema = 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 getAvailabilitySchema = availabilitySchema
|
|
export type Availability = z.infer<typeof availabilitySchema>
|
|
export type AvailabilityPrices =
|
|
Availability["data"][number]["attributes"]["bestPricePerNight"]
|
|
|
|
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<typeof rate>
|
|
|
|
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<typeof hotelFilter>
|
|
|
|
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<typeof apiCitiesByCountrySchema> {}
|
|
export type CitiesGroupedByCountry = Record<string, CitiesByCountry["data"]>
|
|
|
|
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<typeof apiCountriesSchema> {}
|
|
|
|
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
|
|
}
|
|
})
|
|
),
|
|
})
|