feat(SW-66, SW-348): search functionality and ui
This commit is contained in:
@@ -584,3 +584,192 @@ const rate = z.object({
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { metrics } from "@opentelemetry/api"
|
||||
import { unstable_cache } from "next/cache"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage.graphql"
|
||||
import { GetHotelPage } from "@/lib/graphql/Query/HotelPage/HotelPage.graphql"
|
||||
import { request } from "@/lib/graphql/request"
|
||||
import {
|
||||
badRequestError,
|
||||
@@ -17,14 +18,7 @@ import {
|
||||
} from "@/server/trpc"
|
||||
import { toApiLang } from "@/server/utils"
|
||||
|
||||
import { makeImageVaultImage } from "@/utils/imageVault"
|
||||
import { removeMultipleSlashes } from "@/utils/url"
|
||||
|
||||
import {
|
||||
type ContentBlockItem,
|
||||
type HotelPageDataRaw,
|
||||
validateHotelPageSchema,
|
||||
} from "../contentstack/hotelPage/output"
|
||||
import { hotelPageSchema } from "../contentstack/hotelPage/output"
|
||||
import {
|
||||
getAvailabilityInputSchema,
|
||||
getHotelInputSchema,
|
||||
@@ -38,9 +32,17 @@ import {
|
||||
roomSchema,
|
||||
} from "./output"
|
||||
import tempRatesData from "./tempRatesData.json"
|
||||
import {
|
||||
getCitiesByCountry,
|
||||
getCountries,
|
||||
getLocations,
|
||||
locationsAffix,
|
||||
TWENTYFOUR_HOURS,
|
||||
} from "./utils"
|
||||
|
||||
import { HotelBlocksTypenameEnum } from "@/types/components/hotelPage/enums"
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RequestOptionsWithOutBody } from "@/types/fetch"
|
||||
import type { GetHotelPageData } from "@/types/trpc/routers/contentstack/hotelPage"
|
||||
|
||||
const meter = metrics.getMeter("trpc.hotels")
|
||||
const getHotelCounter = meter.createCounter("trpc.hotel.get")
|
||||
@@ -59,19 +61,16 @@ async function getContentstackData(
|
||||
locale: string,
|
||||
uid: string | null | undefined
|
||||
) {
|
||||
const rawContentStackData = await request<HotelPageDataRaw>(GetHotelPage, {
|
||||
const response = await request<GetHotelPageData>(GetHotelPage, {
|
||||
locale,
|
||||
uid,
|
||||
})
|
||||
|
||||
if (!rawContentStackData.data) {
|
||||
throw notFound(rawContentStackData)
|
||||
if (!response.data) {
|
||||
throw notFound(response)
|
||||
}
|
||||
|
||||
const hotelPageData = validateHotelPageSchema.safeParse(
|
||||
rawContentStackData.data
|
||||
)
|
||||
|
||||
const hotelPageData = hotelPageSchema.safeParse(response.data)
|
||||
if (!hotelPageData.success) {
|
||||
console.error(
|
||||
`Failed to validate Hotel Page - (uid: ${uid}, lang: ${locale})`
|
||||
@@ -116,7 +115,6 @@ export const hotelQueryRouter = router({
|
||||
const apiResponse = await api.get(
|
||||
`${api.endpoints.v1.hotels}/${hotelId}`,
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
@@ -180,60 +178,38 @@ export const hotelQueryRouter = router({
|
||||
|
||||
const roomCategories = included
|
||||
? included
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = roomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
getHotelFailCounter.add(1, {
|
||||
hotelId,
|
||||
lang,
|
||||
include,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(
|
||||
validatedRoom.error.issues.map(({ code, message }) => ({
|
||||
code,
|
||||
message,
|
||||
}))
|
||||
),
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = roomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
getHotelFailCounter.add(1, {
|
||||
hotelId,
|
||||
lang,
|
||||
include,
|
||||
error_type: "validation_error",
|
||||
error: JSON.stringify(
|
||||
validatedRoom.error.issues.map(({ code, message }) => ({
|
||||
code,
|
||||
message,
|
||||
}))
|
||||
),
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.hotel validation error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validatedRoom.error,
|
||||
})
|
||||
console.error(
|
||||
"api.hotels.hotel validation error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validatedRoom.error,
|
||||
})
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
return validatedRoom.data
|
||||
})
|
||||
return validatedRoom.data
|
||||
})
|
||||
: []
|
||||
|
||||
const activities = contentstackData?.content
|
||||
? contentstackData.content.map((block: ContentBlockItem) => {
|
||||
switch (block.__typename) {
|
||||
case HotelBlocksTypenameEnum.HotelPageContentUpcomingActivitiesCard:
|
||||
return {
|
||||
...block.upcoming_activities_card,
|
||||
background_image: makeImageVaultImage(
|
||||
block.upcoming_activities_card?.background_image
|
||||
),
|
||||
contentPage:
|
||||
block.upcoming_activities_card?.hotel_page_activities_content_pageConnection?.edges.map(
|
||||
({ node: contentPage }: { node: any }) => {
|
||||
return {
|
||||
href:
|
||||
contentPage.web?.original_url ||
|
||||
removeMultipleSlashes(
|
||||
`/${contentPage.system.locale}/${contentPage.url}`
|
||||
),
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
})[0]
|
||||
? contentstackData?.content[0]
|
||||
: null
|
||||
|
||||
getHotelSuccessCounter.add(1, { hotelId, lang, include })
|
||||
@@ -253,7 +229,7 @@ export const hotelQueryRouter = router({
|
||||
hotelImages: images,
|
||||
pointsOfInterest: hotelAttributes.pointsOfInterest,
|
||||
roomCategories,
|
||||
activitiesCard: activities,
|
||||
activitiesCard: activities?.upcoming_activities_card,
|
||||
}
|
||||
}),
|
||||
availability: router({
|
||||
@@ -516,4 +492,66 @@ export const hotelQueryRouter = router({
|
||||
return validateHotelData.data
|
||||
}),
|
||||
}),
|
||||
locations: router({
|
||||
get: serviceProcedure.query(async function ({ ctx }) {
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set("language", toApiLang(ctx.lang))
|
||||
|
||||
const options: RequestOptionsWithOutBody = {
|
||||
// needs to clear default option as only
|
||||
// cache or next.revalidate is permitted
|
||||
cache: undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
next: {
|
||||
revalidate: TWENTYFOUR_HOURS,
|
||||
},
|
||||
}
|
||||
|
||||
const getCachedCountries = unstable_cache(
|
||||
getCountries,
|
||||
[`${ctx.lang}:${locationsAffix}:countries`],
|
||||
{ revalidate: TWENTYFOUR_HOURS }
|
||||
)
|
||||
|
||||
const countries = await getCachedCountries(options, searchParams)
|
||||
|
||||
const getCachedCitiesByCountry = unstable_cache(
|
||||
getCitiesByCountry,
|
||||
[`${ctx.lang}:${locationsAffix}:cities-by-country`],
|
||||
{ revalidate: TWENTYFOUR_HOURS }
|
||||
)
|
||||
|
||||
let citiesByCountry = null
|
||||
if (countries) {
|
||||
citiesByCountry = await getCachedCitiesByCountry(
|
||||
countries,
|
||||
options,
|
||||
searchParams
|
||||
)
|
||||
}
|
||||
|
||||
const getCachedLocations = unstable_cache(
|
||||
getLocations,
|
||||
[`${ctx.lang}:${locationsAffix}`],
|
||||
{ revalidate: TWENTYFOUR_HOURS }
|
||||
)
|
||||
|
||||
const locations = await getCachedLocations(
|
||||
ctx.lang,
|
||||
options,
|
||||
searchParams,
|
||||
citiesByCountry
|
||||
)
|
||||
|
||||
if (Array.isArray(locations)) {
|
||||
return {
|
||||
data: locations,
|
||||
}
|
||||
}
|
||||
|
||||
return locations
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import { IconName } from "@/types/components/icon"
|
||||
|
||||
import deepmerge from "deepmerge"
|
||||
import { unstable_cache } from "next/cache"
|
||||
|
||||
import * as api from "@/lib/api"
|
||||
|
||||
import {
|
||||
apiCitiesByCountrySchema,
|
||||
apiCitySchema,
|
||||
apiCountriesSchema,
|
||||
apiLocationsSchema,
|
||||
type CitiesGroupedByCountry,
|
||||
type Countries,
|
||||
} from "./output"
|
||||
|
||||
import type { RequestOptionsWithOutBody } from "@/types/fetch"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
import type { Endpoint } from "@/lib/api/endpoints"
|
||||
|
||||
export function getIconByPoiCategory(category: string) {
|
||||
switch (category) {
|
||||
case "Transportations":
|
||||
@@ -16,3 +34,169 @@ export function getIconByPoiCategory(category: string) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const locationsAffix = "locations"
|
||||
|
||||
export const TWENTYFOUR_HOURS = 60 * 60 * 24
|
||||
export async function getCity(
|
||||
cityUrl: string,
|
||||
options: RequestOptionsWithOutBody
|
||||
) {
|
||||
const url = new URL(cityUrl)
|
||||
const cityResponse = await api.get(
|
||||
url.pathname as Endpoint,
|
||||
options,
|
||||
url.searchParams
|
||||
)
|
||||
|
||||
if (!cityResponse.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cityJson = await cityResponse.json()
|
||||
const city = apiCitySchema.safeParse(cityJson)
|
||||
if (!city.success) {
|
||||
console.info(`Validation of city failed`)
|
||||
console.info(`cityUrl: ${cityUrl}`)
|
||||
console.error(city.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return city.data
|
||||
}
|
||||
|
||||
export async function getCountries(
|
||||
options: RequestOptionsWithOutBody,
|
||||
params: URLSearchParams
|
||||
) {
|
||||
const countryResponse = await api.get(
|
||||
api.endpoints.v1.countries,
|
||||
options,
|
||||
params
|
||||
)
|
||||
|
||||
if (!countryResponse.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const countriesJson = await countryResponse.json()
|
||||
const countries = apiCountriesSchema.safeParse(countriesJson)
|
||||
if (!countries.success) {
|
||||
console.info(`Validation for countries failed`)
|
||||
console.error(countries.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return countries.data
|
||||
}
|
||||
|
||||
export async function getCitiesByCountry(
|
||||
countries: Countries,
|
||||
options: RequestOptionsWithOutBody,
|
||||
params: URLSearchParams
|
||||
) {
|
||||
const citiesGroupedByCountry: CitiesGroupedByCountry = {}
|
||||
|
||||
await Promise.all(
|
||||
countries.data.map(async (country) => {
|
||||
const countryResponse = await api.get(
|
||||
`${api.endpoints.v1.citiesCountry}/${country.name}`,
|
||||
options,
|
||||
params
|
||||
)
|
||||
|
||||
if (!countryResponse.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const countryJson = await countryResponse.json()
|
||||
const citiesByCountry = apiCitiesByCountrySchema.safeParse(countryJson)
|
||||
if (!citiesByCountry.success) {
|
||||
console.info(`Failed to validate Cities by Country payload`)
|
||||
console.error(citiesByCountry.error)
|
||||
return null
|
||||
}
|
||||
|
||||
citiesGroupedByCountry[country.name] = citiesByCountry.data.data
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
return citiesGroupedByCountry
|
||||
}
|
||||
|
||||
export async function getLocations(
|
||||
lang: Lang,
|
||||
options: RequestOptionsWithOutBody,
|
||||
params: URLSearchParams,
|
||||
citiesByCountry: CitiesGroupedByCountry | null
|
||||
) {
|
||||
const apiResponse = await api.get(api.endpoints.v1.locations, options, params)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
if (apiResponse.status === 401) {
|
||||
return { error: true, cause: "unauthorized" } as const
|
||||
} else if (apiResponse.status === 403) {
|
||||
return { error: true, cause: "forbidden" } as const
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const apiJson = await apiResponse.json()
|
||||
const verifiedLocations = apiLocationsSchema.safeParse(apiJson)
|
||||
if (!verifiedLocations.success) {
|
||||
console.info(`Locations Verification Failed`)
|
||||
console.error(verifiedLocations.error)
|
||||
return null
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
verifiedLocations.data.data.map(async (location) => {
|
||||
if (location.type === "cities") {
|
||||
if (citiesByCountry) {
|
||||
const country = Object.keys(citiesByCountry).find((country) => {
|
||||
if (
|
||||
citiesByCountry[country].find((loc) => loc.name === location.name)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (country) {
|
||||
return {
|
||||
...location,
|
||||
country,
|
||||
}
|
||||
} else {
|
||||
console.info(
|
||||
`Location cannot be found in any of the countries cities`
|
||||
)
|
||||
console.info(location)
|
||||
}
|
||||
}
|
||||
} else if (location.type === "hotels") {
|
||||
if (location.relationships.city?.url) {
|
||||
const getCachedCity = unstable_cache(
|
||||
getCity,
|
||||
[`${lang}:${location.relationships.city}`],
|
||||
{ revalidate: TWENTYFOUR_HOURS }
|
||||
)
|
||||
|
||||
const city = await getCachedCity(
|
||||
location.relationships.city.url,
|
||||
options
|
||||
)
|
||||
if (city) {
|
||||
return deepmerge(location, {
|
||||
relationships: {
|
||||
city,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user