fix: move crunching of data to trpc layer

This commit is contained in:
Christel Westerberg
2024-10-30 15:21:51 +01:00
parent 7710d3f8f9
commit 46622d0515
10 changed files with 345 additions and 109 deletions

View File

@@ -1,13 +1,12 @@
import { notFound } from "next/navigation"
import { import {
getHotelData,
getProfileSafely, getProfileSafely,
getRoomAvailability, getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { HotelIncludeEnum } from "@/server/routers/hotels/input"
import Summary from "@/components/HotelReservation/EnterDetails/Summary" import Summary from "@/components/HotelReservation/EnterDetails/Summary"
import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils"
import { formatNumber } from "@/utils/format"
import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { LangParams, PageArgs, SearchParams } from "@/types/params" import { LangParams, PageArgs, SearchParams } from "@/types/params"
@@ -20,68 +19,61 @@ export default async function SummaryPage({
const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } = const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } =
getQueryParamsForEnterDetails(selectRoomParams) getQueryParamsForEnterDetails(selectRoomParams)
const [user, hotelData, availability] = await Promise.all([ if (!roomTypeCode || !rateCode) {
console.log("No roomTypeCode or rateCode")
return notFound()
}
const [user, availability] = await Promise.all([
getProfileSafely(), getProfileSafely(),
getHotelData({ getSelectedRoomAvailability({
hotelId: hotel,
language: params.lang,
include: [HotelIncludeEnum.RoomCategories],
}),
getRoomAvailability({
hotelId: parseInt(hotel), hotelId: parseInt(hotel),
adults, adults,
children, children,
roomStayStartDate: fromDate, roomStayStartDate: fromDate,
roomStayEndDate: toDate, roomStayEndDate: toDate,
rateCode,
roomTypeCode,
}), }),
]) ])
if (!hotelData?.data || !hotelData?.included || !availability) { if (!availability) {
console.error("No hotel or availability data", hotelData, availability) console.error("No hotel or availability data", availability)
// TODO: handle this case // TODO: handle this case
return null return null
} }
const cancellationText =
availability?.rateDefinitions.find((rate) => rate.rateCode === rateCode)
?.cancellationText ?? ""
const chosenRoom = availability.roomConfigurations.find(
(availRoom) => availRoom.roomTypeCode === roomTypeCode
)
if (!chosenRoom) {
// TODO: handle this case
console.error("No chosen room", chosenRoom)
return null
}
const memberRate = chosenRoom.products.find(
(rate) => rate.productType.member?.rateCode === rateCode
)?.productType.member
const publicRate = chosenRoom.products.find(
(rate) => rate.productType.public?.rateCode === rateCode
)?.productType.public
const prices = user const prices = user
? { ? {
local: memberRate?.localPrice.pricePerStay, local: {
euro: memberRate?.requestedPrice?.pricePerStay, price: availability.memberRate?.localPrice.pricePerStay,
currency: availability.memberRate?.localPrice.currency,
},
euro: {
price: availability.memberRate?.requestedPrice?.pricePerStay,
currency: availability.memberRate?.requestedPrice?.currency,
},
} }
: { : {
local: publicRate?.localPrice.pricePerStay, local: {
euro: publicRate?.requestedPrice?.pricePerStay, price: availability.publicRate?.localPrice.pricePerStay,
currency: availability.publicRate?.localPrice.currency,
},
euro: {
price: availability.publicRate?.requestedPrice?.pricePerStay,
currency: availability.publicRate?.requestedPrice?.currency,
},
} }
return ( return (
<Summary <Summary
isMember={!!user} isMember={!!user}
room={{ room={{
roomType: chosenRoom.roomType, roomType: availability.selectedRoom.roomType,
localPrice: formatNumber(parseInt(prices.local ?? "0")), localPrice: prices.local,
euroPrice: formatNumber(parseInt(prices.euro ?? "0")), euroPrice: prices.euro,
adults, adults,
cancellationText, cancellationText: availability.cancellationText,
}} }}
/> />
) )

View File

@@ -6,6 +6,7 @@ import {
getHotelData, getHotelData,
getProfileSafely, getProfileSafely,
getRoomAvailability, getRoomAvailability,
getSelectedRoomAvailability,
} from "@/lib/trpc/memoizedRequests" } from "@/lib/trpc/memoizedRequests"
import { HotelIncludeEnum } from "@/server/routers/hotels/input" import { HotelIncludeEnum } from "@/server/routers/hotels/input"
@@ -36,9 +37,7 @@ export default async function StepPage({
searchParams, searchParams,
}: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) { }: PageArgs<LangParams & { step: StepEnum }, SelectRateSearchParams>) {
const { lang } = params const { lang } = params
if (!searchParams.hotel) {
redirect(`/${lang}`)
}
void getBreakfastPackages(searchParams.hotel) void getBreakfastPackages(searchParams.hotel)
const intl = await getIntl() const intl = await getIntl()
@@ -62,24 +61,37 @@ export default async function StepPage({
rateCode rateCode
}) })
const hotelData = await getHotelData({
hotelId,
language: lang,
include: [HotelIncludeEnum.RoomCategories],
})
const user = await getProfileSafely() if (!rateCode || !roomTypeCode) {
const savedCreditCards = await getCreditCardsSafely() return notFound()
const breakfastPackages = await getBreakfastPackages(searchParams.hotel) }
const roomAvailability = await getRoomAvailability({ const [
hotelId: parseInt(hotelId), hotelData,
adults, user,
children, savedCreditCards,
roomStayStartDate: fromDate, breakfastPackages,
roomStayEndDate: toDate, roomAvailability,
rateCode ] = await Promise.all([
}) getHotelData({
hotelId,
language: lang,
include: [HotelIncludeEnum.RoomCategories],
}),
getProfileSafely(),
getCreditCardsSafely(),
getBreakfastPackages(searchParams.hotel),
getSelectedRoomAvailability({
hotelId: parseInt(searchParams.hotel),
adults,
children,
roomStayStartDate: fromDate,
roomStayEndDate: toDate,
rateCode,
roomTypeCode,
}),
])
if (!isValidStep(params.step) || !hotelData || !roomAvailability) { if (!isValidStep(params.step) || !hotelData || !roomAvailability) {
return notFound() return notFound()
@@ -100,10 +112,8 @@ export default async function StepPage({
id: "Select payment method", id: "Select payment method",
}) })
const availableRoom = roomAvailability?.roomConfigurations const availableRoom = roomAvailability.selectedRoom?.roomType
.filter((room) => room.status === "Available") const bedTypes = hotelData.included
.find((room) => room.roomTypeCode === roomTypeCode)?.roomType
const roomTypes = hotelData.included
?.find((room) => room.name === availableRoom) ?.find((room) => room.name === availableRoom)
?.roomTypes.map((room) => ({ ?.roomTypes.map((room) => ({
description: room.mainBed.description, description: room.mainBed.description,
@@ -116,13 +126,13 @@ export default async function StepPage({
<HistoryStateManager /> <HistoryStateManager />
{/* TODO: How to handle no beds found? */} {/* TODO: How to handle no beds found? */}
{roomTypes ? ( {bedTypes ? (
<SectionAccordion <SectionAccordion
header="Select bed" header="Select bed"
step={StepEnum.selectBed} step={StepEnum.selectBed}
label={intl.formatMessage({ id: "Request bedtype" })} label={intl.formatMessage({ id: "Request bedtype" })}
> >
<BedType roomTypes={roomTypes} /> <BedType bedTypes={bedTypes} />
</SectionAccordion> </SectionAccordion>
) : null} ) : null}

View File

@@ -14,28 +14,20 @@ import { bedTypeSchema } from "./schema"
import styles from "./bedOptions.module.css" import styles from "./bedOptions.module.css"
import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDetails/bedType" import type {
BedTypeProps,
BedTypeSchema,
} from "@/types/components/hotelReservation/enterDetails/bedType"
export default function BedType({ export default function BedType({ bedTypes }: BedTypeProps) {
roomTypes,
}: {
roomTypes: {
description: string
size: {
min: number
max: number
}
value: string
}[]
}) {
const intl = useIntl() const intl = useIntl()
const bedType = useEnterDetailsStore((state) => state.userData.bedType) const bedType = useEnterDetailsStore((state) => state.userData.bedType)
const methods = useForm<BedTypeSchema>({ const methods = useForm<BedTypeSchema>({
defaultValues: bedType defaultValues: bedType
? { ? {
bedType, bedType,
} }
: undefined, : undefined,
criteriaMode: "all", criteriaMode: "all",
mode: "all", mode: "all",
@@ -64,7 +56,7 @@ export default function BedType({
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}> <form className={styles.form} onSubmit={methods.handleSubmit(onSubmit)}>
{roomTypes.map((roomType) => { {bedTypes.map((roomType) => {
const width = const width =
roomType.size.max === roomType.size.min roomType.size.max === roomType.size.min
? `${roomType.size.min} cm` ? `${roomType.size.min} cm`

View File

@@ -13,10 +13,12 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { formatNumber } from "@/utils/format"
import styles from "./summary.module.css" import styles from "./summary.module.css"
import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData" import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData"
import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast"
import { BreakfastPackageEnum } from "@/types/enums/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast"
export default function Summary({ export default function Summary({
@@ -27,15 +29,15 @@ export default function Summary({
room: RoomsData room: RoomsData
}) { }) {
const [chosenBed, setChosenBed] = useState<string>() const [chosenBed, setChosenBed] = useState<string>()
const [chosenBreakfast, setCosenBreakfast] = useState<string>() const [chosenBreakfast, setChosenBreakfast] = useState<
BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST
>()
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore( const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore(
(state) => ({ (state) => ({
fromDate: state.roomData.fromDate, fromDate: state.roomData.fromDate,
toDate: state.roomData.toDate, toDate: state.roomData.toDate,
rooms: state.roomData.room,
hotel: state.roomData.hotel,
bedType: state.userData.bedType, bedType: state.userData.bedType,
breakfast: state.userData.breakfast, breakfast: state.userData.breakfast,
}) })
@@ -55,10 +57,9 @@ export default function Summary({
useEffect(() => { useEffect(() => {
setChosenBed(bedType) setChosenBed(bedType)
if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) {
setCosenBreakfast("No breakfast") if (breakfast) {
} else if (breakfast) { setChosenBreakfast(breakfast)
setCosenBreakfast("Breakfast buffet")
} }
}, [bedType, breakfast]) }, [bedType, breakfast])
@@ -80,7 +81,10 @@ export default function Summary({
<Caption color={color}> <Caption color={color}>
{intl.formatMessage( {intl.formatMessage(
{ id: "{amount} {currency}" }, { id: "{amount} {currency}" },
{ amount: room.localPrice, currency: "SEK" } {
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
currency: room.localPrice.currency,
}
)} )}
</Caption> </Caption>
</div> </div>
@@ -118,24 +122,41 @@ export default function Summary({
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{intl.formatMessage( {intl.formatMessage(
{ id: "{amount} {currency}" }, { id: "{amount} {currency}" },
{ amount: "0", currency: "SEK" } { amount: "0", currency: room.localPrice.currency }
)} )}
</Caption> </Caption>
</div> </div>
) : null} ) : null}
{chosenBreakfast ? ( {chosenBreakfast ? (
<div className={styles.entry}> chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? (
<Body color="textHighContrast"> <div className={styles.entry}>
{intl.formatMessage({ id: chosenBreakfast })} <Body color="textHighContrast">
</Body> {intl.formatMessage({ id: "No breakfast" })}
<Caption color="uiTextMediumContrast"> </Body>
{intl.formatMessage( <Caption color="uiTextMediumContrast">
{ id: "{amount} {currency}" }, {intl.formatMessage(
{ amount: "0", currency: "SEK" } { id: "{amount} {currency}" },
)} { amount: "0", currency: room.localPrice.currency }
</Caption> )}
</div> </Caption>
</div>
) : (
<div className={styles.entry}>
<Body color="textHighContrast">
{intl.formatMessage({ id: "Breakfast buffet" })}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{ id: "{amount} {currency}" },
{
amount: chosenBreakfast.totalPrice,
currency: chosenBreakfast.currency,
}
)}
</Caption>
</div>
)
) : null} ) : null}
</div> </div>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
@@ -156,14 +177,20 @@ export default function Summary({
<Body textTransform="bold"> <Body textTransform="bold">
{intl.formatMessage( {intl.formatMessage(
{ id: "{amount} {currency}" }, { id: "{amount} {currency}" },
{ amount: room.localPrice, currency: "SEK" } // TODO: calculate total price {
amount: formatNumber(parseInt(room.localPrice.price ?? "0")),
currency: room.localPrice.currency,
}
)} )}
</Body> </Body>
<Caption color="uiTextMediumContrast"> <Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}{" "} {intl.formatMessage({ id: "Approx." })}{" "}
{intl.formatMessage( {intl.formatMessage(
{ id: "{amount} {currency}" }, { id: "{amount} {currency}" },
{ amount: room.euroPrice, currency: "EUR" } {
amount: formatNumber(parseInt(room.euroPrice.price ?? "0")),
currency: room.euroPrice.currency,
}
)} )}
</Caption> </Caption>
</div> </div>

View File

@@ -23,10 +23,15 @@ interface ListCardProps extends BaseCardProps {
interface TextCardProps extends BaseCardProps { interface TextCardProps extends BaseCardProps {
list?: never list?: never
text?: React.ReactNode text: React.ReactNode
} }
export type CardProps = ListCardProps | TextCardProps interface CleanCardProps extends BaseCardProps {
list?: never
text?: never
}
export type CardProps = ListCardProps | TextCardProps | CleanCardProps
export type CheckboxProps = export type CheckboxProps =
| Omit<ListCardProps, "type"> | Omit<ListCardProps, "type">
@@ -34,6 +39,7 @@ export type CheckboxProps =
export type RadioProps = export type RadioProps =
| Omit<ListCardProps, "type"> | Omit<ListCardProps, "type">
| Omit<TextCardProps, "type"> | Omit<TextCardProps, "type">
| Omit<CleanCardProps, "type">
export interface ListProps extends Pick<ListCardProps, "declined"> { export interface ListProps extends Pick<ListCardProps, "declined"> {
list?: ListCardProps["list"] list?: ListCardProps["list"]

View File

@@ -3,6 +3,7 @@ import { cache } from "react"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { import {
GetRoomsAvailabilityInput, GetRoomsAvailabilityInput,
GetSelectedRoomAvailabilityInput,
HotelIncludeEnum, HotelIncludeEnum,
} from "@/server/routers/hotels/input" } from "@/server/routers/hotels/input"
@@ -95,6 +96,14 @@ export const getRoomAvailability = cache(
} }
) )
export const getSelectedRoomAvailability = cache(
async function getMemoizedRoomAvailability(
args: GetSelectedRoomAvailabilityInput
) {
return serverClient().hotel.availability.room(args)
}
)
export const getFooter = cache(async function getMemoizedFooter() { export const getFooter = cache(async function getMemoizedFooter() {
return serverClient().contentstack.base.footer() return serverClient().contentstack.base.footer()
}) })

View File

@@ -29,6 +29,23 @@ export const getRoomsAvailabilityInputSchema = z.object({
rateCode: z.string().optional(), rateCode: z.string().optional(),
}) })
export const getSelectedRoomAvailabilityInputSchema = z.object({
hotelId: z.number(),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
adults: z.number(),
children: z.string().optional(),
promotionCode: z.string().optional(),
reservationProfileType: z.string().optional().default(""),
attachedProfileId: z.string().optional().default(""),
rateCode: z.string(),
roomTypeCode: z.string(),
})
export type GetSelectedRoomAvailabilityInput = z.input<
typeof getSelectedRoomAvailabilityInputSchema
>
export type GetRoomsAvailabilityInput = z.input< export type GetRoomsAvailabilityInput = z.input<
typeof getRoomsAvailabilityInputSchema typeof getRoomsAvailabilityInputSchema
> >

View File

@@ -37,6 +37,7 @@ import {
getHotelsAvailabilityInputSchema, getHotelsAvailabilityInputSchema,
getRatesInputSchema, getRatesInputSchema,
getRoomsAvailabilityInputSchema, getRoomsAvailabilityInputSchema,
getSelectedRoomAvailabilityInputSchema,
} from "./input" } from "./input"
import { import {
breakfastPackagesSchema, breakfastPackagesSchema,
@@ -93,6 +94,16 @@ const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail" "trpc.hotel.availability.rooms-fail"
) )
const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast") const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast")
const breakfastPackagesSuccessCounter = meter.createCounter( const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success" "trpc.package.breakfast-success"
@@ -545,6 +556,161 @@ export const hotelQueryRouter = router({
return validateAvailabilityData.data return validateAvailabilityData.data
}), }),
room: serviceProcedure
.input(getSelectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
attachedProfileId,
rateCode,
roomTypeCode,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
promotionCode,
reservationProfileType,
attachedProfileId,
}
selectedRoomAvailabilityCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.selectedRoomAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponseAvailability.ok) {
const text = await apiResponseAvailability.text()
selectedRoomAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "http_error",
error: JSON.stringify({
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
}),
})
console.error(
"api.hotels.selectedRoomAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
},
})
)
return null
}
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData =
getRoomsAvailabilitySchema.safeParse(apiJsonAvailability)
if (!validateAvailabilityData.success) {
selectedRoomAvailabilityFailCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.selectedRoomAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
const selectedRoom = validateAvailabilityData.data.roomConfigurations
.filter((room) => room.status === "Available")
.find((room) => room.roomTypeCode === roomTypeCode)
if (!selectedRoom) {
console.error("No matching room found")
return null
}
const memberRate = selectedRoom.products.find(
(rate) => rate.productType.member?.rateCode === rateCode
)?.productType.member
const publicRate = selectedRoom.products.find(
(rate) => rate.productType.public?.rateCode === rateCode
)?.productType.public
const mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.filter(
(rate) => rate.rateCode === rateCode
)[0].mustBeGuaranteed
const cancellationText =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.cancellationText ?? ""
selectedRoomAvailabilitySuccessCounter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
promotionCode,
reservationProfileType,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return {
selectedRoom,
mustBeGuaranteed,
cancellationText,
memberRate,
publicRate,
}
}),
}), }),
rates: router({ rates: router({
get: publicProcedure get: publicProcedure

View File

@@ -2,4 +2,16 @@ import { z } from "zod"
import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema" import { bedTypeSchema } from "@/components/HotelReservation/EnterDetails/BedType/schema"
type BedType = {
description: string
size: {
min: number
max: number
}
value: string
}
export type BedTypeProps = {
bedTypes: BedType[]
}
export interface BedTypeSchema extends z.output<typeof bedTypeSchema> {} export interface BedTypeSchema extends z.output<typeof bedTypeSchema> {}

View File

@@ -16,10 +16,15 @@ export interface BookingData {
room: Room[] room: Room[]
} }
type Price = {
price?: string
currency?: string
}
export type RoomsData = { export type RoomsData = {
roomType: string roomType: string
localPrice: string localPrice: Price
euroPrice: string euroPrice: Price
adults: number adults: number
children?: Child[] children?: Child[]
cancellationText: string cancellationText: string