Merged in feat/SW-1454-hotel-listing-city-page (pull request #1250)

feat(SW-1454): added hotel listing

* feat(SW-1454): added hotel listing


Approved-by: Fredrik Thorsson
This commit is contained in:
Erik Tiekstra
2025-02-05 13:10:28 +00:00
parent f3e6318d49
commit e3b1bfc414
27 changed files with 522 additions and 103 deletions

View File

@@ -4,7 +4,7 @@ import {
} from "@/lib/graphql/Query/DestinationCityPage/DestinationCityPage.graphql"
import { request } from "@/lib/graphql/request"
import { notFound } from "@/server/errors/trpc"
import { contentstackExtendedProcedureUID, router } from "@/server/trpc"
import { contentStackUidWithServiceProcedure, router } from "@/server/trpc"
import { generateTag } from "@/utils/generateTag"
@@ -20,7 +20,7 @@ import {
getDestinationCityPageRefsSuccessCounter,
getDestinationCityPageSuccessCounter,
} from "./telemetry"
import { generatePageTags } from "./utils"
import { generatePageTags, getHotelListData } from "./utils"
import type {
GetDestinationCityPageData,
@@ -28,8 +28,8 @@ import type {
} from "@/types/trpc/routers/contentstack/destinationCityPage"
export const destinationCityPageQueryRouter = router({
get: contentstackExtendedProcedureUID.query(async ({ ctx }) => {
const { lang, uid } = ctx
get: contentStackUidWithServiceProcedure.query(async ({ ctx }) => {
const { lang, uid, serviceToken } = ctx
getDestinationCityPageRefsCounter.add(1, { lang, uid })
console.info(
@@ -128,27 +128,31 @@ export const destinationCityPageQueryRouter = router({
throw notFoundError
}
const validatedDestinationCityPage = destinationCityPageSchema.safeParse(
response.data
)
const validatedResponse = destinationCityPageSchema.safeParse(response.data)
if (!validatedDestinationCityPage.success) {
if (!validatedResponse.success) {
getDestinationCityPageFailCounter.add(1, {
lang,
uid: `${uid}`,
error_type: "validation_error",
error: JSON.stringify(validatedDestinationCityPage.error),
error: JSON.stringify(validatedResponse.error),
})
console.error(
"contentstack.destinationCityPage validation error",
JSON.stringify({
query: { lang, uid },
error: validatedDestinationCityPage.error,
error: validatedResponse.error,
})
)
return null
}
const hotels = await getHotelListData(
lang,
serviceToken,
validatedResponse.data.destinationCityPage.destination_settings.city
)
getDestinationCityPageSuccessCounter.add(1, { lang, uid: `${uid}` })
console.info(
"contentstack.destinationCityPage success",
@@ -157,6 +161,9 @@ export const destinationCityPageQueryRouter = router({
})
)
return validatedDestinationCityPage.data
return {
...validatedResponse.data,
hotels,
}
}),
})

View File

@@ -1,5 +1,10 @@
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { getHotel } from "../../hotels/query"
import { getHotelIdsByCityIdentifier } from "../../hotels/utils"
import { getHotelPageUrl } from "../hotelPage/utils"
import type { HotelData } from "@/types/hotel"
import type { System } from "@/types/requests/system"
import type { GetDestinationCityPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { Lang } from "@/constants/languages"
@@ -29,3 +34,29 @@ export function getConnections({
return connections
}
export async function getHotelListData(
lang: Lang,
serviceToken: string,
cityIdentifier: string
) {
const hotelIds = await getHotelIdsByCityIdentifier(
cityIdentifier,
serviceToken
)
const hotels = await Promise.all(
hotelIds.map(async (hotelId) => {
const [hotelData, url] = await Promise.all([
getHotel({ hotelId, language: lang }, serviceToken),
getHotelPageUrl(lang, hotelId),
])
return hotelData ? { ...hotelData, url } : null
})
)
return hotels.filter(
(hotel): hotel is HotelData & { url: string | null } => !!hotel
)
}

View File

@@ -1,4 +1,3 @@
import { env } from "@/env/server"
import {
GetDestinationCountryPage,
GetDestinationCountryPageRefs,
@@ -10,7 +9,6 @@ import { toApiLang } from "@/server/utils"
import { generateTag } from "@/utils/generateTag"
import { getCitiesByCountry } from "../../hotels/utils"
import {
destinationCountryPageRefsSchema,
destinationCountryPageSchema,
@@ -23,10 +21,9 @@ import {
getDestinationCountryPageRefsSuccessCounter,
getDestinationCountryPageSuccessCounter,
} from "./telemetry"
import { generatePageTags, getCityListDataByCityIdentifier } from "./utils"
import { generatePageTags, getCityPages } from "./utils"
import { ApiCountry } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type {
GetDestinationCountryPageData,
GetDestinationCountryPageRefsSchema,
@@ -155,44 +152,10 @@ export const destinationCountryPageQueryRouter = router({
return null
}
const params = new URLSearchParams({
language: apiLang,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const selectedCountry =
validatedResponse.data.destinationCountryPage.destination_settings.country
const apiCountry = ApiCountry[lang][selectedCountry]
const cities = await getCitiesByCountry(
[apiCountry],
options,
params,
const cities = await getCityPages(
lang,
true,
"destinationCountryPage"
)
const cityPages = await Promise.all(
cities[apiCountry].map(async (city) => {
if (!city.cityIdentifier) {
return null
}
const data = await getCityListDataByCityIdentifier(
lang,
city.cityIdentifier
)
return data ? { ...data, cityName: city.name } : null
})
serviceToken,
validatedResponse.data.destinationCountryPage.destination_settings.country
)
getDestinationCountryPageSuccessCounter.add(1, { lang, uid: `${uid}` })
@@ -205,10 +168,12 @@ export const destinationCountryPageQueryRouter = router({
return {
...validatedResponse.data,
translatedCountry: apiCountry,
cities: cityPages
.flat()
.filter((city): city is NonNullable<typeof city> => !!city),
translatedCountry:
ApiCountry[lang][
validatedResponse.data.destinationCountryPage.destination_settings
.country
],
cities,
}
}),
})

View File

@@ -1,8 +1,11 @@
import { env } from "@/env/server"
import { GetDestinationCityListData } from "@/lib/graphql/Query/DestinationCityPage/DestinationCityListData.graphql"
import { request } from "@/lib/graphql/request"
import { toApiLang } from "@/server/utils"
import { generateTag, generateTagsFromSystem } from "@/utils/generateTag"
import { getCitiesByCountry } from "../../hotels/utils"
import { destinationCityListDataSchema } from "../destinationCityPage/output"
import {
getCityListDataCounter,
@@ -10,6 +13,8 @@ import {
getCityListDataSuccessCounter,
} from "./telemetry"
import { ApiCountry, type Country } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type { System } from "@/types/requests/system"
import type { GetDestinationCityListDataResponse } from "@/types/trpc/routers/contentstack/destinationCityPage"
import type { GetDestinationCountryPageRefsSchema } from "@/types/trpc/routers/contentstack/destinationCountryPage"
@@ -108,3 +113,47 @@ export async function getCityListDataByCityIdentifier(
return validatedResponse.data
}
export async function getCityPages(
lang: Lang,
serviceToken: string,
country: Country
) {
const apiLang = toApiLang(lang)
const params = new URLSearchParams({
language: apiLang,
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const apiCountry = ApiCountry[lang][country]
const cities = await getCitiesByCountry([apiCountry], options, params, lang)
const publishedCities = cities[apiCountry].filter((city) => city.isPublished)
const cityPages = await Promise.all(
publishedCities.map(async (city) => {
if (!city.cityIdentifier) {
return null
}
const data = await getCityListDataByCityIdentifier(
lang,
city.cityIdentifier
)
return data ? { ...data, cityName: city.name } : null
})
)
return cityPages
.flat()
.filter((city): city is NonNullable<typeof city> => !!city)
}

View File

@@ -251,16 +251,23 @@ export const packagesSchema = z
})
.transform(({ data }) => data?.attributes.packages)
export const getHotelIdsByCityIdSchema = z
export const getHotelIdsSchema = z
.object({
data: z.array(
z.object({
// We only care about the hotel id
attributes: z.object({
isPublished: z.boolean(),
}),
id: z.string(),
})
),
})
.transform((data) => data.data.map((hotel) => hotel.id))
.transform(({ data }) => {
const filteredHotels = data.filter(
(hotel) => !!hotel.attributes.isPublished
)
return filteredHotels.map((hotel) => hotel.id)
})
export const getNearbyHotelIdsSchema = z
.object({

View File

@@ -91,7 +91,7 @@ export const getHotel = cache(
})
console.info(
"api.hotels.hotelData start",
JSON.stringify({ query: { hotelId, params } })
JSON.stringify({ query: { hotelId, params: params.toString() } })
)
const apiResponse = await api.get(
@@ -126,7 +126,7 @@ export const getHotel = cache(
console.error(
"api.hotels.hotelData error",
JSON.stringify({
query: { hotelId, params },
query: { hotelId, params: params.toString() },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
@@ -151,7 +151,7 @@ export const getHotel = cache(
console.error(
"api.hotels.hotelData validation error",
JSON.stringify({
query: { hotelId, params },
query: { hotelId, params: params.toString() },
error: validateHotelData.error,
})
)
@@ -165,7 +165,7 @@ export const getHotel = cache(
console.info(
"api.hotels.hotelData success",
JSON.stringify({
query: { hotelId, params: params },
query: { hotelId, params: params.toString() },
})
)
const hotelData = validateHotelData.data

View File

@@ -30,7 +30,7 @@ export const restaurantsOverviewPageSchema = z.object({
})
export const extraPageSchema = z.object({
elevatorPitch: z.string().optional(),
elevatorPitch: z.string().default(""),
mainBody: z.string().optional(),
})

View File

@@ -1,14 +1,17 @@
import deepmerge from "deepmerge"
import { unstable_cache } from "next/cache"
import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { toApiLang } from "@/server/utils"
import { metrics } from "./metrics"
import {
citiesByCountrySchema,
citiesSchema,
countriesSchema,
getHotelIdsByCityIdSchema,
getHotelIdsSchema,
locationsSchema,
} from "./output"
@@ -17,10 +20,9 @@ import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import type {
CitiesGroupedByCountry,
Countries,
CityLocation,
HotelLocation,
} from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
export function getPoiGroupByCategoryName(category: string | undefined) {
@@ -305,11 +307,11 @@ export async function getHotelIdsByCityId(
})
)
return null
return []
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, {
params: params.toString(),
@@ -323,19 +325,22 @@ export async function getHotelIdsByCityId(
error: validatedHotelIds.error,
})
)
return null
return []
}
metrics.hotelIds.success.add(1, { cityId })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({ params: params.toString() })
JSON.stringify({
params: params.toString(),
response: validatedHotelIds.data,
})
)
return validatedHotelIds.data
},
[`hotelsByCityId`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
}
@@ -376,11 +381,11 @@ export async function getHotelIdsByCountry(
})
)
return null
return []
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
metrics.hotelIds.fail.add(1, {
country,
@@ -394,7 +399,7 @@ export async function getHotelIdsByCountry(
error: validatedHotelIds.error,
})
)
return null
return []
}
metrics.hotelIds.success.add(1, { country })
@@ -406,6 +411,69 @@ export async function getHotelIdsByCountry(
return validatedHotelIds.data
},
[`hotelsByCountry`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
{ revalidate: env.CACHE_TIME_HOTELS }
)(params)
}
export async function getHotelIdsByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const apiLang = toApiLang(Lang.en)
const cityId = await getCityIdByCityIdentifier(cityIdentifier, serviceToken)
if (!cityId) {
return []
}
const hotelIdsParams = new URLSearchParams({
language: apiLang,
city: cityId,
onlyBasicInfo: "true",
})
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const hotelIds = await getHotelIdsByCityId(cityId, options, hotelIdsParams)
return hotelIds
}
export async function getCityIdByCityIdentifier(
cityIdentifier: string,
serviceToken: string
) {
const lang = Lang.en
const apiLang = toApiLang(lang)
const options: RequestOptionsWithOutBody = {
// needs to clear default option as only
// cache or next.revalidate is permitted
cache: undefined,
headers: {
Authorization: `Bearer ${serviceToken}`,
},
next: {
revalidate: env.CACHE_TIME_HOTELS,
},
}
const params = new URLSearchParams({
language: apiLang,
})
const locations = await getLocations(lang, options, params, null)
if (!locations || "error" in locations) {
return null
}
const cityId = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === cityIdentifier)?.id
return cityId ?? null
}