diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx index 4258d94d8..f1e1897e1 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/@summary/page.tsx @@ -1,13 +1,12 @@ +import { notFound } from "next/navigation" + import { - getHotelData, getProfileSafely, - getRoomAvailability, + getSelectedRoomAvailability, } from "@/lib/trpc/memoizedRequests" -import { HotelIncludeEnum } from "@/server/routers/hotels/input" import Summary from "@/components/HotelReservation/EnterDetails/Summary" import { getQueryParamsForEnterDetails } from "@/components/HotelReservation/SelectRate/RoomSelection/utils" -import { formatNumber } from "@/utils/format" import { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { LangParams, PageArgs, SearchParams } from "@/types/params" @@ -20,68 +19,61 @@ export default async function SummaryPage({ const { hotel, adults, children, roomTypeCode, rateCode, fromDate, toDate } = 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(), - getHotelData({ - hotelId: hotel, - language: params.lang, - include: [HotelIncludeEnum.RoomCategories], - }), - getRoomAvailability({ + getSelectedRoomAvailability({ hotelId: parseInt(hotel), adults, children, roomStayStartDate: fromDate, roomStayEndDate: toDate, + rateCode, + roomTypeCode, }), ]) - if (!hotelData?.data || !hotelData?.included || !availability) { - console.error("No hotel or availability data", hotelData, availability) + if (!availability) { + console.error("No hotel or availability data", availability) // TODO: handle this case 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 ? { - local: memberRate?.localPrice.pricePerStay, - euro: memberRate?.requestedPrice?.pricePerStay, + local: { + 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, - euro: publicRate?.requestedPrice?.pricePerStay, + local: { + price: availability.publicRate?.localPrice.pricePerStay, + currency: availability.publicRate?.localPrice.currency, + }, + euro: { + price: availability.publicRate?.requestedPrice?.pricePerStay, + currency: availability.publicRate?.requestedPrice?.currency, + }, } return ( ) diff --git a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx index adfcbc34a..dc07ed3f0 100644 --- a/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx +++ b/app/[lang]/(live)/(public)/hotelreservation/(standard)/[step]/page.tsx @@ -6,6 +6,7 @@ import { getHotelData, getProfileSafely, getRoomAvailability, + getSelectedRoomAvailability, } from "@/lib/trpc/memoizedRequests" import { HotelIncludeEnum } from "@/server/routers/hotels/input" @@ -36,9 +37,7 @@ export default async function StepPage({ searchParams, }: PageArgs) { const { lang } = params - if (!searchParams.hotel) { - redirect(`/${lang}`) - } + void getBreakfastPackages(searchParams.hotel) const intl = await getIntl() @@ -62,24 +61,37 @@ export default async function StepPage({ rateCode }) - const hotelData = await getHotelData({ - hotelId, - language: lang, - include: [HotelIncludeEnum.RoomCategories], - }) - const user = await getProfileSafely() - const savedCreditCards = await getCreditCardsSafely() - const breakfastPackages = await getBreakfastPackages(searchParams.hotel) + if (!rateCode || !roomTypeCode) { + return notFound() + } - const roomAvailability = await getRoomAvailability({ - hotelId: parseInt(hotelId), - adults, - children, - roomStayStartDate: fromDate, - roomStayEndDate: toDate, - rateCode - }) + const [ + hotelData, + user, + savedCreditCards, + breakfastPackages, + roomAvailability, + ] = 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) { return notFound() @@ -100,10 +112,8 @@ export default async function StepPage({ id: "Select payment method", }) - const availableRoom = roomAvailability?.roomConfigurations - .filter((room) => room.status === "Available") - .find((room) => room.roomTypeCode === roomTypeCode)?.roomType - const roomTypes = hotelData.included + const availableRoom = roomAvailability.selectedRoom?.roomType + const bedTypes = hotelData.included ?.find((room) => room.name === availableRoom) ?.roomTypes.map((room) => ({ description: room.mainBed.description, @@ -116,13 +126,13 @@ export default async function StepPage({ {/* TODO: How to handle no beds found? */} - {roomTypes ? ( + {bedTypes ? ( - + ) : null} diff --git a/components/HotelReservation/EnterDetails/BedType/index.tsx b/components/HotelReservation/EnterDetails/BedType/index.tsx index 646ec1311..f822024c1 100644 --- a/components/HotelReservation/EnterDetails/BedType/index.tsx +++ b/components/HotelReservation/EnterDetails/BedType/index.tsx @@ -14,28 +14,20 @@ import { bedTypeSchema } from "./schema" 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({ - roomTypes, -}: { - roomTypes: { - description: string - size: { - min: number - max: number - } - value: string - }[] -}) { +export default function BedType({ bedTypes }: BedTypeProps) { const intl = useIntl() const bedType = useEnterDetailsStore((state) => state.userData.bedType) const methods = useForm({ defaultValues: bedType ? { - bedType, - } + bedType, + } : undefined, criteriaMode: "all", mode: "all", @@ -64,7 +56,7 @@ export default function BedType({ return (
- {roomTypes.map((roomType) => { + {bedTypes.map((roomType) => { const width = roomType.size.max === roomType.size.min ? `${roomType.size.min} cm` diff --git a/components/HotelReservation/EnterDetails/Summary/index.tsx b/components/HotelReservation/EnterDetails/Summary/index.tsx index 094c9e860..84c6280af 100644 --- a/components/HotelReservation/EnterDetails/Summary/index.tsx +++ b/components/HotelReservation/EnterDetails/Summary/index.tsx @@ -13,10 +13,12 @@ import Body from "@/components/TempDesignSystem/Text/Body" import Caption from "@/components/TempDesignSystem/Text/Caption" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import useLang from "@/hooks/useLang" +import { formatNumber } from "@/utils/format" import styles from "./summary.module.css" import { RoomsData } from "@/types/components/hotelReservation/enterDetails/bookingData" +import { BreakfastPackage } from "@/types/components/hotelReservation/enterDetails/breakfast" import { BreakfastPackageEnum } from "@/types/enums/breakfast" export default function Summary({ @@ -27,15 +29,15 @@ export default function Summary({ room: RoomsData }) { const [chosenBed, setChosenBed] = useState() - const [chosenBreakfast, setCosenBreakfast] = useState() + const [chosenBreakfast, setChosenBreakfast] = useState< + BreakfastPackage | BreakfastPackageEnum.NO_BREAKFAST + >() const intl = useIntl() const lang = useLang() const { fromDate, toDate, bedType, breakfast } = useEnterDetailsStore( (state) => ({ fromDate: state.roomData.fromDate, toDate: state.roomData.toDate, - rooms: state.roomData.room, - hotel: state.roomData.hotel, bedType: state.userData.bedType, breakfast: state.userData.breakfast, }) @@ -55,10 +57,9 @@ export default function Summary({ useEffect(() => { setChosenBed(bedType) - if (breakfast === BreakfastPackageEnum.NO_BREAKFAST) { - setCosenBreakfast("No breakfast") - } else if (breakfast) { - setCosenBreakfast("Breakfast buffet") + + if (breakfast) { + setChosenBreakfast(breakfast) } }, [bedType, breakfast]) @@ -80,7 +81,10 @@ export default function Summary({ {intl.formatMessage( { id: "{amount} {currency}" }, - { amount: room.localPrice, currency: "SEK" } + { + amount: formatNumber(parseInt(room.localPrice.price ?? "0")), + currency: room.localPrice.currency, + } )} @@ -118,24 +122,41 @@ export default function Summary({ {intl.formatMessage( { id: "{amount} {currency}" }, - { amount: "0", currency: "SEK" } + { amount: "0", currency: room.localPrice.currency } )} ) : null} {chosenBreakfast ? ( -
- - {intl.formatMessage({ id: chosenBreakfast })} - - - {intl.formatMessage( - { id: "{amount} {currency}" }, - { amount: "0", currency: "SEK" } - )} - -
+ chosenBreakfast === BreakfastPackageEnum.NO_BREAKFAST ? ( +
+ + {intl.formatMessage({ id: "No breakfast" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { amount: "0", currency: room.localPrice.currency } + )} + +
+ ) : ( +
+ + {intl.formatMessage({ id: "Breakfast buffet" })} + + + {intl.formatMessage( + { id: "{amount} {currency}" }, + { + amount: chosenBreakfast.totalPrice, + currency: chosenBreakfast.currency, + } + )} + +
+ ) ) : null} @@ -156,14 +177,20 @@ export default function Summary({ {intl.formatMessage( { id: "{amount} {currency}" }, - { amount: room.localPrice, currency: "SEK" } // TODO: calculate total price + { + amount: formatNumber(parseInt(room.localPrice.price ?? "0")), + currency: room.localPrice.currency, + } )} {intl.formatMessage({ id: "Approx." })}{" "} {intl.formatMessage( { id: "{amount} {currency}" }, - { amount: room.euroPrice, currency: "EUR" } + { + amount: formatNumber(parseInt(room.euroPrice.price ?? "0")), + currency: room.euroPrice.currency, + } )} diff --git a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts index 8fe8476d3..7d24e46d7 100644 --- a/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts +++ b/components/TempDesignSystem/Form/ChoiceCard/_Card/card.ts @@ -23,10 +23,15 @@ interface ListCardProps extends BaseCardProps { interface TextCardProps extends BaseCardProps { 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 = | Omit @@ -34,6 +39,7 @@ export type CheckboxProps = export type RadioProps = | Omit | Omit + | Omit export interface ListProps extends Pick { list?: ListCardProps["list"] diff --git a/lib/trpc/memoizedRequests/index.ts b/lib/trpc/memoizedRequests/index.ts index 547ff7874..63cde2d84 100644 --- a/lib/trpc/memoizedRequests/index.ts +++ b/lib/trpc/memoizedRequests/index.ts @@ -3,6 +3,7 @@ import { cache } from "react" import { Lang } from "@/constants/languages" import { GetRoomsAvailabilityInput, + GetSelectedRoomAvailabilityInput, HotelIncludeEnum, } 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() { return serverClient().contentstack.base.footer() }) diff --git a/server/routers/hotels/input.ts b/server/routers/hotels/input.ts index ff8368b2a..bfff4cd97 100644 --- a/server/routers/hotels/input.ts +++ b/server/routers/hotels/input.ts @@ -29,6 +29,23 @@ export const getRoomsAvailabilityInputSchema = z.object({ 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< typeof getRoomsAvailabilityInputSchema > diff --git a/server/routers/hotels/query.ts b/server/routers/hotels/query.ts index 4aacd6ff2..b4dc240a5 100644 --- a/server/routers/hotels/query.ts +++ b/server/routers/hotels/query.ts @@ -37,6 +37,7 @@ import { getHotelsAvailabilityInputSchema, getRatesInputSchema, getRoomsAvailabilityInputSchema, + getSelectedRoomAvailabilityInputSchema, } from "./input" import { breakfastPackagesSchema, @@ -93,6 +94,16 @@ const roomsAvailabilityFailCounter = meter.createCounter( "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 breakfastPackagesSuccessCounter = meter.createCounter( "trpc.package.breakfast-success" @@ -545,6 +556,161 @@ export const hotelQueryRouter = router({ 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 = { + 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({ get: publicProcedure diff --git a/types/components/hotelReservation/enterDetails/bedType.ts b/types/components/hotelReservation/enterDetails/bedType.ts index c4e6e4ff0..35f41ee27 100644 --- a/types/components/hotelReservation/enterDetails/bedType.ts +++ b/types/components/hotelReservation/enterDetails/bedType.ts @@ -2,4 +2,16 @@ import { z } from "zod" 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 {} diff --git a/types/components/hotelReservation/enterDetails/bookingData.ts b/types/components/hotelReservation/enterDetails/bookingData.ts index 058d67990..249bb466d 100644 --- a/types/components/hotelReservation/enterDetails/bookingData.ts +++ b/types/components/hotelReservation/enterDetails/bookingData.ts @@ -16,10 +16,15 @@ export interface BookingData { room: Room[] } +type Price = { + price?: string + currency?: string +} + export type RoomsData = { roomType: string - localPrice: string - euroPrice: string + localPrice: Price + euroPrice: Price adults: number children?: Child[] cancellationText: string