Merged in feature/SW-3572-hotel-data-endpoint (pull request #3051)
SW-3572 API route for listing hotels per city or country * wip hotel data endpoint * Correct route params type * wip * skip static paths call * timeout when getting destinations take too long * call noStore when we get a timeout * add cache-control headers * . * . * . * wip * wip * wip * wip * add route for getting hotels per country * include city when listing by country * fix distance SI unit * fix sorting * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/SW-3572-hotel-data-endpoint * packages/tracking passWithNoTests * revalidate must be static value * remove oxc reference * cleanup * cleanup hotel api route * feat(SW-3572): cleanup error handling Approved-by: Anton Gunnarsson
This commit is contained in:
139
apps/scandic-web/app/api/destinations/[country]/[city]/route.ts
Normal file
139
apps/scandic-web/app/api/destinations/[country]/[city]/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { TRPCError } from "@trpc/server"
|
||||
import { unstable_noStore as noStore } from "next/cache"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
|
||||
import {
|
||||
gatewayTimeout,
|
||||
httpStatusByErrorCode,
|
||||
notFound,
|
||||
} from "@scandic-hotels/trpc/errors"
|
||||
import { Country } from "@scandic-hotels/trpc/types/country"
|
||||
import {
|
||||
isCityLocation,
|
||||
isHotelLocation,
|
||||
} from "@scandic-hotels/trpc/types/locations"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { timeout } from "@/utils/timeout"
|
||||
|
||||
import { createDataResponse } from "../createDataResponse"
|
||||
|
||||
export const revalidate = 28_800 // 8 hours
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ country: string; city: string }> }
|
||||
) {
|
||||
try {
|
||||
const { country: countryParam, city: cityParam } = await params
|
||||
|
||||
const country = Object.values(Country).find((c) =>
|
||||
equalsIgnoreCaseAndAccents(c, countryParam)
|
||||
)
|
||||
|
||||
if (!country) {
|
||||
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
|
||||
}
|
||||
|
||||
const caller = await serverClient()
|
||||
const locations = await Promise.any([
|
||||
timeout(3_000).then(() => {
|
||||
throw gatewayTimeout("Fetching locations timed out")
|
||||
}),
|
||||
caller.hotel.locations.get({ lang: Lang.en }),
|
||||
])
|
||||
|
||||
const city = locations.filter(isCityLocation).find((c) => {
|
||||
return (
|
||||
equalsIgnoreCaseAndAccents(c.name, cityParam) &&
|
||||
equalsIgnoreCaseAndAccents(c.countryName, countryParam)
|
||||
)
|
||||
})
|
||||
|
||||
if (!city) {
|
||||
throw notFound(
|
||||
`City "${cityParam.toLowerCase()}" not found in country "${countryParam.toLowerCase()}"`
|
||||
)
|
||||
}
|
||||
|
||||
const hotels = locations
|
||||
.filter(isHotelLocation)
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isActive &&
|
||||
x.isPublished &&
|
||||
equalsIgnoreCaseAndAccents(x.relationships.city.name, cityParam)
|
||||
)
|
||||
|
||||
if (hotels.length === 0) {
|
||||
throw notFound(
|
||||
`No hotels found in city "${cityParam.toLowerCase()}" and country "${countryParam.toLowerCase()}"`
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
createDataResponse({ countryParam, cityParam, hotels }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
noStore()
|
||||
|
||||
const noCacheHeader: HeadersInit = {
|
||||
"Cache-Control": `no-store, max-age=0`,
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
switch (error.code) {
|
||||
case "GATEWAY_TIMEOUT":
|
||||
case "INTERNAL_SERVER_ERROR": {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.cause?.toString() || error.message,
|
||||
},
|
||||
{
|
||||
status: httpStatusByErrorCode(error),
|
||||
headers: {
|
||||
...noCacheHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
case "NOT_FOUND": {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.cause?.toString() || error.message,
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.captureException(error)
|
||||
return NextResponse.json(
|
||||
{ message: "Internal Server Error" },
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...noCacheHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export function createDataResponse(
|
||||
{
|
||||
countryParam,
|
||||
cityParam,
|
||||
hotels,
|
||||
}: {
|
||||
countryParam: string
|
||||
cityParam?: string
|
||||
hotels: Array<{
|
||||
name: string
|
||||
distanceToCentre?: number | undefined
|
||||
relationships: { city: { name: string } }
|
||||
images?: { large?: string } | undefined
|
||||
}>
|
||||
},
|
||||
options?: { includeCity: boolean }
|
||||
) {
|
||||
const { includeCity = false } = options || {}
|
||||
return {
|
||||
country: countryParam.toLowerCase(),
|
||||
city: cityParam?.toLowerCase(),
|
||||
hotels: hotels
|
||||
.map((h) => ({
|
||||
name: h.name,
|
||||
city: includeCity ? h.relationships.city.name : undefined,
|
||||
metersToCityCentre: h.distanceToCentre,
|
||||
images: {
|
||||
tiny: createImageUrl({ src: h.images?.large, width: 300 }),
|
||||
small: createImageUrl({ src: h.images?.large, width: 500 }),
|
||||
medium: createImageUrl({ src: h.images?.large, width: 1080 }),
|
||||
large: createImageUrl({ src: h.images?.large, width: 1920 }),
|
||||
},
|
||||
}))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.toSorted((a, b) => {
|
||||
return (
|
||||
(a.metersToCityCentre ?? Infinity) -
|
||||
(b.metersToCityCentre ?? Infinity)
|
||||
)
|
||||
})
|
||||
.toSorted((a, b) => {
|
||||
if (!includeCity) return 0
|
||||
if (!a.city || !b.city) return 0
|
||||
|
||||
return a.city.localeCompare(b.city)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function createImageUrl({
|
||||
src,
|
||||
width,
|
||||
}: {
|
||||
src: string | null | undefined
|
||||
width: number
|
||||
}) {
|
||||
if (!src) return undefined
|
||||
return `https://img.scandichotels.com/.netlify/images?url=${encodeURIComponent(src)}&w=${width}&q=90`
|
||||
}
|
||||
137
apps/scandic-web/app/api/destinations/[country]/route.ts
Normal file
137
apps/scandic-web/app/api/destinations/[country]/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
import { TRPCError } from "@trpc/server"
|
||||
import { unstable_noStore as noStore } from "next/cache"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
import { equalsIgnoreCaseAndAccents } from "@scandic-hotels/common/utils/stringEquals"
|
||||
import {
|
||||
gatewayTimeout,
|
||||
httpStatusByErrorCode,
|
||||
notFound,
|
||||
} from "@scandic-hotels/trpc/errors"
|
||||
import { Country } from "@scandic-hotels/trpc/types/country"
|
||||
import {
|
||||
isCityLocation,
|
||||
isHotelLocation,
|
||||
} from "@scandic-hotels/trpc/types/locations"
|
||||
|
||||
import { serverClient } from "@/lib/trpc/server"
|
||||
|
||||
import { timeout } from "@/utils/timeout"
|
||||
|
||||
import { createDataResponse } from "./createDataResponse"
|
||||
|
||||
export const revalidate = 28_800 // 8 hours
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ country: string }> }
|
||||
) {
|
||||
try {
|
||||
const { country: countryParam } = await params
|
||||
|
||||
const country = Object.values(Country).find((c) =>
|
||||
equalsIgnoreCaseAndAccents(c, countryParam)
|
||||
)
|
||||
|
||||
if (!country) {
|
||||
throw notFound(`Country "${countryParam.toLowerCase()}" not found`)
|
||||
}
|
||||
|
||||
const caller = await serverClient()
|
||||
const locations = await Promise.any([
|
||||
timeout(3_000).then(() => {
|
||||
throw gatewayTimeout("Fetching locations timed out")
|
||||
}),
|
||||
caller.hotel.locations.get({ lang: Lang.en }),
|
||||
])
|
||||
|
||||
const cities = locations.filter(isCityLocation).filter((c) => {
|
||||
return equalsIgnoreCaseAndAccents(c.countryName, countryParam)
|
||||
})
|
||||
|
||||
if (cities.length === 0) {
|
||||
throw notFound(
|
||||
`No cities found in country "${countryParam.toLowerCase()}"`
|
||||
)
|
||||
}
|
||||
|
||||
const hotels = locations
|
||||
.filter(isHotelLocation)
|
||||
.filter(
|
||||
(x) =>
|
||||
x.isActive &&
|
||||
x.isPublished &&
|
||||
cities.some((c) =>
|
||||
equalsIgnoreCaseAndAccents(x.relationships.city.name, c.name)
|
||||
)
|
||||
)
|
||||
|
||||
if (hotels.length === 0) {
|
||||
throw notFound(
|
||||
`No hotels found in country "${countryParam.toLowerCase()}"`
|
||||
)
|
||||
}
|
||||
return NextResponse.json(
|
||||
createDataResponse({ countryParam, hotels }, { includeCity: true }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
},
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
noStore()
|
||||
|
||||
const noCacheHeader: HeadersInit = {
|
||||
"Cache-Control": `no-store, max-age=0`,
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError) {
|
||||
switch (error.code) {
|
||||
case "GATEWAY_TIMEOUT":
|
||||
case "INTERNAL_SERVER_ERROR": {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.cause?.toString() || error.message,
|
||||
},
|
||||
{
|
||||
status: httpStatusByErrorCode(error),
|
||||
headers: {
|
||||
...noCacheHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
case "NOT_FOUND": {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error.cause?.toString() || error.message,
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
"Netlify-CDN-Cache-Control": `public, max-age=${revalidate}, stale-while-revalidate=86400`,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.captureException(error)
|
||||
return NextResponse.json(
|
||||
{ message: "Internal Server Error" },
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
...noCacheHeader,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable formatjs/no-literal-string-in-jsx */
|
||||
|
||||
import { getLang } from "@/i18n/serverContext"
|
||||
|
||||
import { texts } from "./Texts"
|
||||
|
||||
Reference in New Issue
Block a user