feat: update getHotel to use real hotel api endpoint, support for service tokens, type modifications
This commit is contained in:
@@ -11,6 +11,7 @@ CURITY_CLIENT_SECRET_SERVICE=""
|
||||
CURITY_CLIENT_ID_USER=""
|
||||
CURITY_CLIENT_SECRET_USER=""
|
||||
CURITY_ISSUER_USER="https://testlogin.scandichotels.com"
|
||||
CURITY_ISSUER_SERVICE="https://testlogin.scandichotels.com"
|
||||
CYPRESS_BASE_URL="http://localhost:3000"
|
||||
# See next.config.js for info
|
||||
DEPLOY_PRIME_URL="http://localhost:3000"
|
||||
|
||||
@@ -18,14 +18,20 @@ export default async function SelectHotelPage({
|
||||
const intl = await getIntl()
|
||||
setLang(params.lang)
|
||||
|
||||
const { attributes } = await serverClient().hotel.getHotel({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
const hotelData = await serverClient().hotel.getHotel({
|
||||
hotelId: "879",
|
||||
language: getLang(),
|
||||
})
|
||||
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { attributes } = hotelData
|
||||
const hotels = [attributes]
|
||||
|
||||
const hotelFilters = await serverClient().hotel.getFilters({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
hotelId: "879",
|
||||
})
|
||||
|
||||
const tempSearchTerm = "Stockholm"
|
||||
|
||||
@@ -15,10 +15,15 @@ export default async function SelectRate({ params }: PageArgs<LangParams>) {
|
||||
setLang(params.lang)
|
||||
|
||||
// TODO: pass the correct hotel ID
|
||||
const { attributes: hotel } = await serverClient().hotel.getHotel({
|
||||
hotelId: "d98c7ab1-ebaa-4102-b351-758daf1ddf55",
|
||||
const hotel = await serverClient().hotel.getHotel({
|
||||
hotelId: "879",
|
||||
language: getLang(),
|
||||
})
|
||||
|
||||
if (!hotel) return null
|
||||
|
||||
const { attributes } = hotel
|
||||
|
||||
const rooms = await serverClient().hotel.getRates({
|
||||
// TODO: pass the correct hotel ID and all other parameters that should be included in the search
|
||||
hotelId: "1",
|
||||
@@ -28,7 +33,7 @@ export default async function SelectRate({ params }: PageArgs<LangParams>) {
|
||||
<div className={styles.page}>
|
||||
<main className={styles.content}>
|
||||
<div className={styles.hotelInfo}>
|
||||
<HotelCard hotel={hotel} />
|
||||
<HotelCard hotel={attributes} />
|
||||
</div>
|
||||
<RoomSelection rooms={rooms} />
|
||||
<FlexibilitySelection />
|
||||
|
||||
@@ -18,12 +18,18 @@ export default async function HotelPage() {
|
||||
return null
|
||||
}
|
||||
const lang = getLang()
|
||||
const { attributes, roomCategories } = await serverClient().hotel.getHotel({
|
||||
|
||||
const hotelData = await serverClient().hotel.getHotel({
|
||||
hotelId: hotelPageIdentifierData.hotel_page_id,
|
||||
language: lang,
|
||||
include: ["RoomCategories"],
|
||||
})
|
||||
|
||||
if (!hotelData) {
|
||||
return null
|
||||
}
|
||||
const { attributes, roomCategories } = hotelData
|
||||
|
||||
return (
|
||||
<div className={styles.pageContainer}>
|
||||
<TabNavigation />
|
||||
@@ -34,7 +40,7 @@ export default async function HotelPage() {
|
||||
hotelDescription={attributes.hotelContent.texts.descriptions.short}
|
||||
location={attributes.location}
|
||||
address={attributes.address}
|
||||
tripAdvisor={attributes.ratings.tripAdvisor}
|
||||
tripAdvisor={attributes.ratings?.tripAdvisor}
|
||||
/>
|
||||
<SidePeeks />
|
||||
<AmenitiesList detailedFacilities={attributes.detailedFacilities} />
|
||||
|
||||
@@ -30,10 +30,14 @@ export default async function IntroSection({
|
||||
)
|
||||
const lang = getLang()
|
||||
const formattedLocationText = `${streetAddress}, ${city} (${formattedDistanceText})`
|
||||
const formattedTripAdvisorText = intl.formatMessage(
|
||||
{ id: "Tripadvisor reviews" },
|
||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||
)
|
||||
const hasTripAdvisorData =
|
||||
tripAdvisor?.rating && tripAdvisor?.numberOfReviews && tripAdvisor?.webUrl
|
||||
const formattedTripAdvisorText = hasTripAdvisorData
|
||||
? intl.formatMessage(
|
||||
{ id: "Tripadvisor reviews" },
|
||||
{ rating: tripAdvisor.rating, count: tripAdvisor.numberOfReviews }
|
||||
)
|
||||
: ""
|
||||
|
||||
return (
|
||||
<section className={styles.introSection}>
|
||||
@@ -45,17 +49,19 @@ export default async function IntroSection({
|
||||
<Title level="h2">{hotelName}</Title>
|
||||
</div>
|
||||
<Body color="textMediumContrast">{formattedLocationText}</Body>
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
textDecoration="underline"
|
||||
color="peach80"
|
||||
href={tripAdvisor.webUrl}
|
||||
>
|
||||
<TripAdvisorIcon color="peach80" />
|
||||
{formattedTripAdvisorText}
|
||||
</Link>
|
||||
{hasTripAdvisorData && (
|
||||
<Link
|
||||
className={styles.introLink}
|
||||
target="_blank"
|
||||
variant="icon"
|
||||
textDecoration="underline"
|
||||
color="peach80"
|
||||
href={tripAdvisor.webUrl}
|
||||
>
|
||||
<TripAdvisorIcon color="peach80" />
|
||||
{formattedTripAdvisorText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.subtitleContent}>
|
||||
<Preamble>{hotelDescription}</Preamble>
|
||||
|
||||
6
env/server.ts
vendored
6
env/server.ts
vendored
@@ -21,8 +21,11 @@ export const env = createEnv({
|
||||
CMS_PREVIEW_URL: z.string(),
|
||||
CMS_URL: z.string(),
|
||||
CURITY_CLIENT_ID_USER: z.string(),
|
||||
CURITY_CLIENT_ID_SERVICE: z.string(),
|
||||
CURITY_CLIENT_SECRET_SERVICE: z.string(),
|
||||
CURITY_CLIENT_SECRET_USER: z.string(),
|
||||
CURITY_ISSUER_USER: z.string(),
|
||||
CURITY_ISSUER_SERVICE: z.string(),
|
||||
CYPRESS_BASE_URL: z.string().default("http://127.0.0.1:3000"),
|
||||
DESIGN_SYSTEM_ACCESS_TOKEN: z.string(),
|
||||
ENVTEST: z.string().optional(),
|
||||
@@ -76,8 +79,11 @@ export const env = createEnv({
|
||||
CMS_PREVIEW_URL: process.env.CMS_PREVIEW_URL,
|
||||
CMS_URL: process.env.CMS_URL,
|
||||
CURITY_CLIENT_ID_USER: process.env.CURITY_CLIENT_ID_USER,
|
||||
CURITY_CLIENT_ID_SERVICE: process.env.CURITY_CLIENT_ID_SERVICE,
|
||||
CURITY_CLIENT_SECRET_SERVICE: process.env.CURITY_CLIENT_SECRET_SERVICE,
|
||||
CURITY_CLIENT_SECRET_USER: process.env.CURITY_CLIENT_SECRET_USER,
|
||||
CURITY_ISSUER_USER: process.env.CURITY_ISSUER_USER,
|
||||
CURITY_ISSUER_SERVICE: process.env.CURITY_ISSUER_SERVICE,
|
||||
CYPRESS_BASE_URL: process.env.CYPRESS_TEST_URL,
|
||||
DESIGN_SYSTEM_ACCESS_TOKEN: process.env.DESIGN_SYSTEM_ACCESS_TOKEN,
|
||||
ENVTEST: process.env.ENVTEST,
|
||||
|
||||
@@ -12,8 +12,14 @@ export namespace endpoints {
|
||||
friendTransactions = "profile/v1/Transaction/friendTransactions",
|
||||
upcomingStays = "booking/v1/Stays/future",
|
||||
previousStays = "booking/v1/Stays/past",
|
||||
hotel = "hotel/v1/Hotels",
|
||||
hotels = "hotel/v1/Hotels",
|
||||
}
|
||||
}
|
||||
|
||||
export type Endpoint = endpoints.v0 | endpoints.v1
|
||||
export const getHotelEndpoint = (hotelId: string | number) =>
|
||||
`${endpoints.v1.hotels}/${hotelId}` as const
|
||||
|
||||
export type Endpoint =
|
||||
| endpoints.v0
|
||||
| endpoints.v1
|
||||
| ReturnType<typeof getHotelEndpoint>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
} from "@/types/fetch"
|
||||
import type { Endpoint } from "./endpoints"
|
||||
|
||||
export { endpoints } from "./endpoints"
|
||||
export { endpoints, getHotelEndpoint } from "./endpoints"
|
||||
|
||||
const defaultOptions: RequestInit = {
|
||||
cache: "no-store",
|
||||
|
||||
@@ -2,29 +2,31 @@ import { z } from "zod"
|
||||
|
||||
import { fromUppercaseToLangEnum } from "@/utils/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 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(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
|
||||
const AddressSchema = z.object({
|
||||
streetAddress: z.string(),
|
||||
@@ -88,7 +90,7 @@ const InteriorSchema = z.object({
|
||||
numberOfFloors: z.number(),
|
||||
numberOfRooms: z.object({
|
||||
connected: z.number(),
|
||||
forEllergics: z.number(),
|
||||
forEllergics: z.number().optional(),
|
||||
forDisabled: z.number(),
|
||||
nonSmoking: z.number(),
|
||||
pet: z.number(),
|
||||
@@ -151,7 +153,7 @@ const DetailedFacilitySchema = z.object({
|
||||
code: z.string().optional(),
|
||||
applyToAllHotels: z.boolean(),
|
||||
public: z.boolean(),
|
||||
icon: z.number(),
|
||||
icon: z.string(), //Check output.
|
||||
iconName: z.string().optional(),
|
||||
sortOrder: z.number(),
|
||||
})
|
||||
@@ -302,7 +304,7 @@ const SocialMediaSchema = z.object({
|
||||
|
||||
const MetaSpecialAlertSchema = z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
description: z.string().optional(),
|
||||
displayInBookingFlow: z.boolean(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as api from "@/lib/api"
|
||||
import { getHotelEndpoint } from "@/lib/api/endpoints"
|
||||
import { badRequestError } from "@/server/errors/trpc"
|
||||
import { publicProcedure, router } from "@/server/trpc"
|
||||
import {
|
||||
anonymousOrAuthProcedure,
|
||||
publicProcedure,
|
||||
router,
|
||||
} from "@/server/trpc"
|
||||
|
||||
import {
|
||||
getFiltersInputSchema,
|
||||
@@ -14,65 +19,61 @@ import {
|
||||
RoomSchema,
|
||||
} from "./output"
|
||||
import tempFilterData from "./tempFilterData.json"
|
||||
import tempHotelData from "./tempHotelData.json"
|
||||
// import tempHotelData from "./tempHotelData.json"
|
||||
import tempRatesData from "./tempRatesData.json"
|
||||
import { toApiLang } from "./utils"
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
getHotel: publicProcedure
|
||||
getHotel: anonymousOrAuthProcedure
|
||||
.input(getHotelInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { hotelId, language, include } = input
|
||||
|
||||
const params = new URLSearchParams()
|
||||
const apiLang = toApiLang(language)
|
||||
params.set("hotelId", hotelId.toString())
|
||||
params.set("language", apiLang)
|
||||
if (include) {
|
||||
params.set("include", include.join(","))
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
// NOTE: We can pass an "include" param to the hotel API to retrieve
|
||||
// additional data for an individual hotel.
|
||||
// Example "included" data can be found in our tempHotelData file.
|
||||
const { included, ...apiJsonWithoutIncluded } = tempHotelData
|
||||
const validatedHotelData = getHotelDataSchema.safeParse(
|
||||
apiJsonWithoutIncluded
|
||||
const authToken = await ctx.getToken()
|
||||
const apiResponse = await api.get(
|
||||
getHotelEndpoint(hotelId),
|
||||
{
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
console.info(`API Response Failed - Getting Hotel`)
|
||||
console.error(apiResponse)
|
||||
return null
|
||||
}
|
||||
const apiJson = await apiResponse.json()
|
||||
const validatedHotelData = getHotelDataSchema.safeParse(apiJson)
|
||||
if (!validatedHotelData.success) {
|
||||
console.error(`Get Individual Hotel Data - Verified Data Error`)
|
||||
console.error(validatedHotelData.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
|
||||
const included = validatedHotelData.data.included || []
|
||||
|
||||
const roomCategories = included
|
||||
? included
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = RoomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
console.error(`Get Room Category Data - Verified Data Error`)
|
||||
console.error(validatedRoom.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
return validatedRoom.data
|
||||
})
|
||||
: []
|
||||
.filter((item) => item.type === "roomcategories")
|
||||
.map((roomCategory) => {
|
||||
const validatedRoom = RoomSchema.safeParse(roomCategory)
|
||||
if (!validatedRoom.success) {
|
||||
console.error(`Get Room Category Data - Verified Data Error`)
|
||||
console.error(validatedRoom.error)
|
||||
throw badRequestError()
|
||||
}
|
||||
return validatedRoom.data
|
||||
})
|
||||
|
||||
return {
|
||||
attributes: validatedHotelData.data.data.attributes,
|
||||
|
||||
43
server/tokenManager.ts
Normal file
43
server/tokenManager.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { env } from "@/env/server"
|
||||
|
||||
import { ServiceTokenResponse } from "@/types/tokens"
|
||||
|
||||
const SERVICE_TOKEN_REVALIDATE_SECONDS = 3599 // 59 minutes and 59 seconds.
|
||||
|
||||
async function fetchServiceToken(): Promise<ServiceTokenResponse> {
|
||||
try {
|
||||
const response = await fetch(`${env.CURITY_ISSUER_USER}/oauth/v2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: env.CURITY_CLIENT_ID_SERVICE,
|
||||
client_secret: env.CURITY_CLIENT_SECRET_SERVICE,
|
||||
}),
|
||||
next: {
|
||||
revalidate: SERVICE_TOKEN_REVALIDATE_SECONDS,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to obtain service token")
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error("Error fetching service token:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthToken(userToken?: string | null): Promise<string> {
|
||||
if (userToken) {
|
||||
return userToken
|
||||
}
|
||||
|
||||
const { access_token } = await fetchServiceToken()
|
||||
return access_token
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
sessionExpiredError,
|
||||
unauthorizedError,
|
||||
} from "./errors/trpc"
|
||||
import { getAuthToken } from "./tokenManager"
|
||||
import { transformer } from "./transformer"
|
||||
import { langInput } from "./utils"
|
||||
|
||||
@@ -99,3 +100,17 @@ export const safeProtectedProcedure = t.procedure.use(async function (opts) {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export const anonymousOrAuthProcedure = t.procedure.use(async function (opts) {
|
||||
const session: Session | null = await opts.ctx.auth()
|
||||
const userToken = session?.token?.access_token || null
|
||||
|
||||
const getToken = async () => await getAuthToken(userToken)
|
||||
|
||||
return opts.next({
|
||||
ctx: {
|
||||
session,
|
||||
getToken,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { getHotelDataSchema,RoomSchema } from "@/server/routers/hotels/output"
|
||||
import { getHotelDataSchema, RoomSchema } from "@/server/routers/hotels/output"
|
||||
|
||||
export type HotelData = z.infer<typeof getHotelDataSchema>
|
||||
|
||||
export type Hotel = HotelData["data"]["attributes"]
|
||||
export type HotelAddress = HotelData["data"]["attributes"]["address"]
|
||||
export type HotelLocation = HotelData["data"]["attributes"]["location"]
|
||||
|
||||
type HotelRatings = HotelData["data"]["attributes"]["ratings"]
|
||||
export type HotelTripAdvisor =
|
||||
HotelData["data"]["attributes"]["ratings"]["tripAdvisor"]
|
||||
| NonNullable<HotelRatings>["tripAdvisor"]
|
||||
| undefined
|
||||
|
||||
export type RoomData = z.infer<typeof RoomSchema>
|
||||
|
||||
6
types/tokens.ts
Normal file
6
types/tokens.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface ServiceTokenResponse {
|
||||
access_token: string
|
||||
scope?: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
}
|
||||
Reference in New Issue
Block a user