feat(SW-66, SW-348): search functionality and ui

This commit is contained in:
Simon Emanuelsson
2024-08-28 10:47:57 +02:00
parent b9dbcf7d90
commit af850c90e7
437 changed files with 7663 additions and 9881 deletions

View File

@@ -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
}
})
),
})

View File

@@ -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
}),
}),
})

View File

@@ -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
})
)
}