feat: Add TRPC procedure for hotel API, schemas, and use in hotel content page
This commit is contained in:
@@ -7,13 +7,20 @@ import styles from "./hotelPage.module.css"
|
||||
import type { LangParams } from "@/types/params"
|
||||
|
||||
export default async function HotelPage({ lang }: LangParams) {
|
||||
const hotelPage = await serverClient().contentstack.hotelPage.get()
|
||||
if (!hotelPage) {
|
||||
const hotelPageIdentifierData =
|
||||
await serverClient().contentstack.hotelPage.get()
|
||||
|
||||
if (!hotelPageIdentifierData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hotelPageData = await serverClient().hotel.getHotel({
|
||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||
language: lang,
|
||||
})
|
||||
return (
|
||||
<section className={styles.content}>
|
||||
<Title>{hotelPage.title}</Title>
|
||||
<Title>{hotelPageData.data.attributes.name}</Title>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export enum Lang {
|
||||
de = "de",
|
||||
}
|
||||
|
||||
export const languages = {
|
||||
export const languages: Record<Lang, string> = {
|
||||
[Lang.da]: "Dansk",
|
||||
[Lang.de]: "Deutsch",
|
||||
[Lang.en]: "English",
|
||||
@@ -16,7 +16,7 @@ export const languages = {
|
||||
[Lang.sv]: "Svenska",
|
||||
}
|
||||
|
||||
export const localeToLang = {
|
||||
export const localeToLang: Record<string, Lang> = {
|
||||
en: Lang.en,
|
||||
"en-US": Lang.en,
|
||||
"en-GB": Lang.en,
|
||||
@@ -56,12 +56,6 @@ export const localeToLang = {
|
||||
"se-NO": Lang.no,
|
||||
} as const
|
||||
|
||||
export function findLang(pathname: string) {
|
||||
return Object.values(Lang).find(
|
||||
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
|
||||
)
|
||||
}
|
||||
|
||||
export const languageSelect = [
|
||||
{ label: "Danish", value: "Da" },
|
||||
{ label: "German", value: "De" },
|
||||
@@ -70,3 +64,38 @@ export const languageSelect = [
|
||||
{ label: "Norwegian", value: "No" },
|
||||
{ label: "Swedish", value: "Sv" },
|
||||
]
|
||||
|
||||
// -- Lang util functions --
|
||||
|
||||
export function findLang(pathname: string) {
|
||||
return Object.values(Lang).find(
|
||||
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
|
||||
)
|
||||
}
|
||||
|
||||
// Helper function to convert API's (e.g. the Hotel endpoint) capitalized values to Lang enum.
|
||||
export function fromUppercaseToLangEnum(lang: string): Lang | undefined {
|
||||
const lowerCaseLang = lang.charAt(0).toLowerCase() + lang.slice(1)
|
||||
return Object.values(Lang).find((l) => l === lowerCaseLang)
|
||||
}
|
||||
|
||||
// Helper function to convert Lang enum to uppercase
|
||||
// Needed for certtain API (e.g. the Hotel endpoint).
|
||||
export const toUppercaseLang = (lang: Lang): string => {
|
||||
switch (lang) {
|
||||
case Lang.en:
|
||||
return "En"
|
||||
case Lang.sv:
|
||||
return "Sv"
|
||||
case Lang.no:
|
||||
return "No"
|
||||
case Lang.fi:
|
||||
return "Fi"
|
||||
case Lang.da:
|
||||
return "Da"
|
||||
case Lang.de:
|
||||
return "De"
|
||||
default:
|
||||
throw new Error("Invalid language")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang, toUppercaseLang } from "@/constants/languages"
|
||||
|
||||
export const getHotelInputSchema = z.object({
|
||||
hotelId: z.string(),
|
||||
language: z.nativeEnum(Lang).transform((val) => toUppercaseLang(val)),
|
||||
})
|
||||
|
||||
393
server/routers/hotels/output.ts
Normal file
393
server/routers/hotels/output.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { fromUppercaseToLangEnum } from "@/constants/languages"
|
||||
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
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(),
|
||||
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(),
|
||||
forEllergics: z.number(),
|
||||
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(),
|
||||
code: z.string().optional(),
|
||||
applyToAllHotels: z.boolean(),
|
||||
public: z.boolean(),
|
||||
icon: z.number(),
|
||||
iconName: z.string().optional(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
|
||||
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(),
|
||||
closingTime: z.string(),
|
||||
sortOrder: z.number().optional(),
|
||||
}),
|
||||
weekends: z.object({
|
||||
alwaysOpen: z.boolean(),
|
||||
isClosed: z.boolean(),
|
||||
openingTime: z.string(),
|
||||
closingTime: z.string(),
|
||||
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 PointsOfInterestSchema = z.object({
|
||||
name: z.string(),
|
||||
distance: z.number(),
|
||||
category: z.object({
|
||||
name: z.string(),
|
||||
group: z.string(),
|
||||
}),
|
||||
location: LocationSchema,
|
||||
isHighlighted: z.boolean(),
|
||||
})
|
||||
|
||||
const ParkingPricingSchema = z.object({
|
||||
freeParking: z.boolean(),
|
||||
paymentType: z.string(),
|
||||
localCurrency: 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(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
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(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const ParkingSchema = z.object({
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
address: z.string(),
|
||||
numberOfParkingSpots: z.number(),
|
||||
numberOfChargingSpaces: z.number(),
|
||||
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(),
|
||||
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(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
// 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()
|
||||
.refine((val) => fromUppercaseToLangEnum(val) !== undefined, {
|
||||
message: "Invalid language",
|
||||
})
|
||||
.transform((val) => {
|
||||
const lang = fromUppercaseToLangEnum(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),
|
||||
rewardNight: RewardNightSchema,
|
||||
pointsOfInterest: z.array(PointsOfInterestSchema),
|
||||
parking: z.array(ParkingSchema),
|
||||
specialNeedGroups: z.array(SpecialNeedGroupSchema),
|
||||
socialMedia: SocialMediaSchema,
|
||||
meta: MetaSchema,
|
||||
isActive: z.boolean(),
|
||||
}),
|
||||
relationships: RelationshipsSchema,
|
||||
}),
|
||||
//TODO: We can pass an "included" param to the hotel API to retrieve additional data for an individual hotel.
|
||||
// - This is out of scope for current work (and I'm unsure if we need it for hotel pages specifically),
|
||||
// - but if/when we do we can extend this schema to add necessary requirements.
|
||||
// - Example "included" data available in our tempHotelData file.
|
||||
// included: z.any(),
|
||||
})
|
||||
@@ -1,40 +1,52 @@
|
||||
import * as api from "@/lib/api"
|
||||
import { protectedProcedure, publicProcedure, router } from "@/server/trpc"
|
||||
import { badRequestError } from "@/server/errors/trpc"
|
||||
import { publicProcedure, router } from "@/server/trpc"
|
||||
|
||||
import { getHotelInputSchema } from "./input"
|
||||
import { getHotelDataSchema } from "./output"
|
||||
import tempHotelData from "./tempHotelData.json"
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
// TODO: Should be public.
|
||||
getHotel: protectedProcedure
|
||||
getHotel: publicProcedure
|
||||
.input(getHotelInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { hotelId } = input
|
||||
const { hotelId, language } = input
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set("hotelId", hotelId.toString())
|
||||
console.log("hotel fetch start")
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.hotel,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.session.token.access_token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
console.log("apiResponse", apiResponse)
|
||||
params.set("language", language.toString())
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
console.info(`API Response Failed - Getting Hotel`)
|
||||
console.error(apiResponse)
|
||||
return null
|
||||
// TODO: Enable once we have authorized API access.
|
||||
// const apiResponse = await api.get(
|
||||
// api.endpoints.v1.hotel,
|
||||
// {}, // Include token.
|
||||
// params
|
||||
// )
|
||||
//
|
||||
// if (!apiResponse.ok) {
|
||||
// console.info(`API Response Failed - Getting Hotel`)
|
||||
// console.error(apiResponse)
|
||||
// return null
|
||||
// }
|
||||
// const apiJson = await apiResponse.json()
|
||||
|
||||
//TODO: We can pass an "included" param to the hotel API to retrieve additional data for an individual hotel.
|
||||
// - This is out of scope for current work (and I'm unsure if we need it for hotel pages specifically),
|
||||
// - but if/when we do we can extend the endpoint (and schema) to add necessary requirements.
|
||||
// - Example "included" data available in our tempHotelData file.
|
||||
const { included, ...apiJsonWithoutIncluded } = tempHotelData
|
||||
console.log("hotel apiJson: ", apiJsonWithoutIncluded)
|
||||
const validatedHotelData = getHotelDataSchema.safeParse(
|
||||
apiJsonWithoutIncluded
|
||||
)
|
||||
|
||||
if (!validatedHotelData.success) {
|
||||
console.info(`Get Individual Hotel Data - Verified Data Error`)
|
||||
console.error(validatedHotelData.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
console.log("apiJson", apiJson)
|
||||
// return null
|
||||
// TODO: validate apiJson.
|
||||
return apiJson
|
||||
console.log("validatedHotelData.data: ", validatedHotelData)
|
||||
return validatedHotelData.data
|
||||
}),
|
||||
})
|
||||
|
||||
11030
server/routers/hotels/tempHotelData.json
Normal file
11030
server/routers/hotels/tempHotelData.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user