From afb37d0cc523026ac2bcccdec56a786fb23ffb24 Mon Sep 17 00:00:00 2001 From: Simon Emanuelsson Date: Mon, 7 Apr 2025 13:43:52 +0200 Subject: [PATCH] feat: bedtypes is selectable again --- .../(standard)/details/page.tsx | 105 +- .../(standard)/select-rate/page.tsx | 2 +- .../Summary/UI/PriceDetailsTable/index.tsx | 10 +- .../Actions/ModifyStay/hooks/useModifyStay.ts | 42 +- .../ActionPanel/Actions/ModifyStay/index.tsx | 4 +- .../MyStay/ReferenceCard/index.tsx | 6 +- .../RateSummary/MobileSummary/index.tsx | 25 +- .../RateSummary/MobileSummary/mapRate.ts | 6 +- .../RoomsContainer/RateSummary/index.tsx | 25 +- .../RoomsContainer/RateSummary/utils.ts | 50 +- .../SelectedRoomPanel/index.tsx | 40 +- .../Rooms/NoAvailabilityAlert/index.tsx | 6 +- .../RoomPackageFilter/Checkbox/index.tsx | 54 - .../Rooms/RoomPackageFilter/index.tsx | 196 --- .../bookingCodeFilter.module.css | 0 .../BookingCodeFilter/index.tsx | 62 +- .../Form/Checkboxes/PetRoomMessage/index.tsx | 40 + .../PetRoomMessage/petRoom.module.css | 8 + .../Form/Checkboxes}/checkbox.module.css | 0 .../Form/Checkboxes/index.tsx | 80 ++ .../Form/Checkboxes/utils.ts | 18 + .../RoomPackageFilter/Form/form.module.css | 11 + .../RoomPackageFilter/Form/formValues.ts | 5 + .../RoomPackageFilter/Form/index.tsx | 98 ++ .../RoomsHeader/RoomPackageFilter/index.tsx | 101 ++ .../roomPackageFilter.module.css | 41 +- .../RoomPackageFilter/utils.ts | 7 +- .../Rooms/RoomsHeader/index.tsx | 20 +- .../RoomsList/RoomListItem/Rates/index.tsx | 15 +- .../RoomListItem/Rates/totalPricePerNight.ts | 4 +- .../RoomListItem/RoomImage/index.tsx | 14 +- .../Rooms/RoomsList/RoomListItem/index.tsx | 5 +- .../RoomsContainer/Rooms/RoomsList/index.tsx | 6 +- .../SelectRate/RoomsContainer/index.tsx | 56 +- .../HotelReservation/SelectRate/index.tsx | 8 +- .../HotelReservation/SelectRate/utils.ts | 63 - apps/scandic-web/i18n/dictionaries/da.json | 2 +- apps/scandic-web/i18n/dictionaries/de.json | 2 +- apps/scandic-web/i18n/dictionaries/en.json | 2 +- apps/scandic-web/i18n/dictionaries/fi.json | 2 +- apps/scandic-web/i18n/dictionaries/no.json | 2 +- apps/scandic-web/i18n/dictionaries/sv.json | 2 +- .../lib/trpc/memoizedRequests/index.ts | 22 +- apps/scandic-web/providers/RatesProvider.tsx | 4 +- .../providers/SelectRate/RoomProvider.tsx | 127 +- .../server/routers/booking/query.ts | 2 +- .../routers/contentstack/metadata/query.ts | 2 +- .../server/routers/hotels/input.ts | 139 +- .../server/routers/hotels/metrics.ts | 12 +- .../server/routers/hotels/output.ts | 70 +- .../server/routers/hotels/query.ts | 1217 ++++------------- .../server/routers/hotels/schemas/rate.ts | 21 - .../server/routers/hotels/tempRatesData.json | 104 -- .../server/routers/hotels/utils.ts | 891 +++++++++++- .../scandic-web/stores/select-rate/helpers.ts | 18 +- apps/scandic-web/stores/select-rate/index.ts | 438 +++--- .../selectRate/flexibilityOption.ts | 39 - .../hotelReservation/selectRate/rates.ts | 4 +- .../hotelReservation/selectRate/roomFilter.ts | 1 - .../selectRate/roomListItem.ts | 21 +- .../selectRate/roomsContainer.ts | 11 +- .../hotelReservation/selectRate/selectRate.ts | 7 +- .../types/contexts/select-rate/room.ts | 9 +- apps/scandic-web/types/providers/rates.ts | 2 - apps/scandic-web/types/requests/packages.ts | 5 + apps/scandic-web/types/stores/rates.ts | 39 +- .../types/trpc/routers/hotel/availability.ts | 24 +- .../types/trpc/routers/hotel/rate.ts | 5 - .../trpc/routers/hotel/roomAvailability.ts | 5 - 69 files changed, 2135 insertions(+), 2349 deletions(-) delete mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/index.tsx delete mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/index.tsx rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/{ => RoomsHeader}/BookingCodeFilter/bookingCodeFilter.module.css (100%) rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/{ => RoomsHeader}/BookingCodeFilter/index.tsx (51%) create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/petRoom.module.css rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/{RoomPackageFilter/Checkbox => RoomsHeader/RoomPackageFilter/Form/Checkboxes}/checkbox.module.css (100%) create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/utils.ts create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/form.module.css create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/formValues.ts create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/{ => RoomsHeader}/RoomPackageFilter/roomPackageFilter.module.css (65%) rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/{ => RoomsHeader}/RoomPackageFilter/utils.ts (70%) delete mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/utils.ts delete mode 100644 apps/scandic-web/server/routers/hotels/schemas/rate.ts delete mode 100644 apps/scandic-web/server/routers/hotels/tempRatesData.json delete mode 100644 apps/scandic-web/types/components/hotelReservation/selectRate/flexibilityOption.ts delete mode 100644 apps/scandic-web/types/trpc/routers/hotel/rate.ts diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx index d37487b5c..3b032bb36 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/details/page.tsx @@ -1,14 +1,12 @@ import { notFound, redirect } from "next/navigation" import { Suspense } from "react" -import { REDEMPTION } from "@/constants/booking" import { selectRate } from "@/constants/routes/hotelReservation" import { getBreakfastPackages, getHotel, - getPackages, getProfileSafely, - getSelectedRoomAvailability, + getSelectedRoomsAvailability, } from "@/lib/trpc/memoizedRequests" import HotelHeader from "@/components/HotelReservation/EnterDetails/Header" @@ -17,7 +15,6 @@ import Multiroom from "@/components/HotelReservation/EnterDetails/Room/Multiroom import RoomOne from "@/components/HotelReservation/EnterDetails/Room/One" import DesktopSummary from "@/components/HotelReservation/EnterDetails/Summary/Desktop" import MobileSummary from "@/components/HotelReservation/EnterDetails/Summary/Mobile" -import { generateChildrenString } from "@/components/HotelReservation/utils" import Alert from "@/components/TempDesignSystem/Alert" import TrackingSDK from "@/components/TrackingSDK" import { getIntl } from "@/i18n" @@ -29,7 +26,6 @@ import { getTracking } from "./tracking" import styles from "./page.module.css" -import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import { AlertTypeEnum } from "@/types/enums/alert" import type { LangParams, PageArgs } from "@/types/params" @@ -40,92 +36,65 @@ export default async function DetailsPage({ searchParams, }: PageArgs) { const selectRoomParams = new URLSearchParams(searchParams) - selectRoomParams.delete("modifyRateIndex") const booking = convertSearchParamsToObj(searchParams) if ("modifyRateIndex" in booking) { + selectRoomParams.delete("modifyRateIndex") delete booking.modifyRateIndex } - void getProfileSafely() - const breakfastInput = { adults: 1, fromDate: booking.fromDate, hotelId: booking.hotelId, toDate: booking.toDate, } - const breakfastPackages = await getBreakfastPackages(breakfastInput) + const hotelInput = { + hotelId: booking.hotelId, + // TODO: Remove this from input since it forces + // waterfalls for no other reason than to + // set merchantInformationData.alternatePaymentOptions + // to an empty array + isCardOnlyPayment: false, + language: lang, + } + void getHotel(hotelInput) + void getBreakfastPackages(breakfastInput) + void getProfileSafely() + + const roomsAvailability = await getSelectedRoomsAvailability({ + booking, + lang, + }) + const rooms: Room[] = [] - - for (let room of booking.rooms) { - const childrenAsString = - room.childrenInRoom && generateChildrenString(room.childrenInRoom) - - const packages = room.packages - ? await getPackages({ - adults: room.adults, - children: room.childrenInRoom?.length, - endDate: booking.toDate, - hotelId: booking.hotelId, - packageCodes: room.packages, - startDate: booking.fromDate, - lang, - }) - : null - - const roomAvailability = await getSelectedRoomAvailability({ - adults: room.adults, - bookingCode: booking.bookingCode, - children: childrenAsString, - counterRateCode: room.counterRateCode, - hotelId: booking.hotelId, - packageCodes: room.packages, - rateCode: room.rateCode, - roomStayEndDate: booking.toDate, - roomStayStartDate: booking.fromDate, - roomTypeCode: room.roomTypeCode, - redemption: booking.searchType === REDEMPTION, - }) - - if (!roomAvailability) { + for (let room of roomsAvailability) { + if (!room) { + // TODO: This could be done in the route already. + // (possibly also add an error case to url?) + // ------------------------------------------------------- // redirect back to select-rate if availability call fails redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`) } - rooms.push({ - bedTypes: roomAvailability.bedTypes, - breakfastIncluded: roomAvailability.breakfastIncluded, - cancellationText: roomAvailability.cancellationText, - isFlexRate: roomAvailability.isFlexRate, - mustBeGuaranteed: roomAvailability.mustBeGuaranteed, - memberMustBeGuaranteed: roomAvailability.memberMustBeGuaranteed, - packages, - rate: roomAvailability.rate, - rateDefinitionTitle: roomAvailability.rateDefinitionTitle, - rateDetails: roomAvailability.rateDetails ?? [], - rateTitle: roomAvailability.rateTitle, - rateType: roomAvailability.rateType, - roomType: roomAvailability.selectedRoom.roomType, - roomTypeCode: roomAvailability.selectedRoom.roomTypeCode, - roomRate: roomAvailability.product, - isAvailable: - roomAvailability.selectedRoom.status === AvailabilityEnum.Available, - }) + rooms.push(room) } - const isCardOnlyPayment = rooms.some((room) => room?.mustBeGuaranteed) - const hotelData = await getHotel({ - hotelId: booking.hotelId, - isCardOnlyPayment, - language: lang, - }) - const user = await getProfileSafely() + const hotelData = await getHotel(hotelInput) - if (!hotelData || !rooms) { + if (!hotelData || !rooms.length) { return notFound() } + const breakfastPackages = await getBreakfastPackages(breakfastInput) + const user = await getProfileSafely() + + const isCardOnlyPayment = rooms.some((room) => room.mustBeGuaranteed) const { hotel } = hotelData + // TODO: Temp fix to avoid waterfall fetch and moving this + // logic from the route here for now + if (isCardOnlyPayment) { + hotel.merchantInformationData.alternatePaymentOptions = [] + } const { hotelsTrackingData, pageTrackingData } = getTracking( booking, diff --git a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx index 9f17fcc27..4736130f7 100644 --- a/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-rate/page.tsx @@ -37,7 +37,7 @@ export default async function SelectRatePage({ // If someone tries to update the url with // a bookingCode also, then we need to remove it if (isRedemption && searchParams.bookingCode) { - searchParams.bookingCode = "" + delete searchParams.bookingCode } return ( diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/PriceDetailsTable/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/PriceDetailsTable/index.tsx index b73d42045..a780b2cea 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/PriceDetailsTable/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/PriceDetailsTable/index.tsx @@ -149,9 +149,13 @@ export default function PriceDetailsTable({ {rooms.length > 1 && ( - - {intl.formatMessage({ id: "Room" })} {idx + 1} - + + + + {intl.formatMessage({ id: "Room" })} {idx + 1} + + + )} {price && ( diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/hooks/useModifyStay.ts b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/hooks/useModifyStay.ts index da7769e8d..826fc0622 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/hooks/useModifyStay.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/hooks/useModifyStay.ts @@ -1,6 +1,5 @@ import { useIntl } from "react-intl" -import { dt } from "@/lib/dt" import { trpc } from "@/lib/trpc/client" import { useManageStayStore } from "@/stores/my-stay/manageStayStore" import { useMyStayRoomDetailsStore } from "@/stores/my-stay/myStayRoomDetailsStore" @@ -44,24 +43,11 @@ export default function useModifyStay({ toast.error(intl.formatMessage({ id: "Failed to update your stay" })) return } - // Update room details with server response data - - const originalCheckIn = dt(bookedRoom.checkInDate) - const originalCheckOut = dt(bookedRoom.checkOutDate) - updateBookedRoom({ ...bookedRoom, - checkInDate: dt(updatedBooking.checkInDate) - .hour(originalCheckIn.hour()) - .minute(originalCheckIn.minute()) - .second(originalCheckIn.second()) - .toDate(), - checkOutDate: dt(updatedBooking.checkOutDate) - .hour(originalCheckOut.hour()) - .minute(originalCheckOut.minute()) - .second(originalCheckOut.second()) - .toDate(), + checkInDate: updatedBooking.checkInDate, + checkOutDate: updatedBooking.checkOutDate, }) toast.success(intl.formatMessage({ id: "Your stay was updated" })) @@ -90,22 +76,25 @@ export default function useModifyStay({ let totalNewPrice = 0 try { - const data = await utils.hotel.availability.room.fetch({ - hotelId: bookedRoom.hotelId, - roomStayStartDate: formValues.checkInDate, - roomStayEndDate: formValues.checkOutDate, - adults: bookedRoom.adults, - children: bookedRoom.childrenAsString, - bookingCode: bookedRoom.bookingCode ?? undefined, - rateCode: bookedRoom.rateDefinition.rateCode, - roomTypeCode: bookedRoom.roomTypeCode, + const data = await utils.hotel.availability.myStay.fetch({ + booking: { + fromDate: formValues.checkInDate, + hotelId: bookedRoom.hotelId, + room: { + adults: bookedRoom.adults, + bookingCode: bookedRoom.bookingCode ?? undefined, + childrenInRoom: bookedRoom.childrenInRoom, + rateCode: bookedRoom.rateDefinition.rateCode, + roomTypeCode: bookedRoom.roomTypeCode, + }, + toDate: formValues.checkOutDate, + }, lang, }) if (!data?.selectedRoom || data.selectedRoom.roomsLeft <= 0) { return { success: false, noAvailability: true } } - let roomPrice = 0 if (isLoggedIn && "member" in data.product && data.product.member) { roomPrice = data.product.member.localPrice.pricePerStay @@ -123,7 +112,6 @@ export default function useModifyStay({ ) { roomPrice = data.product.redemption.localPrice.additionalPricePerStay } - totalNewPrice += roomPrice availabilityResults.push(data) } catch (error) { diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/index.tsx index 7ea52ce85..1985c4b74 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ManageStay/ActionPanel/Actions/ModifyStay/index.tsx @@ -167,9 +167,7 @@ export default function ModifyStay({ isLoggedIn }: ModifyStayProps) { label: isFirstStep ? intl.formatMessage({ id: "Check availability" }) : intl.formatMessage({ id: "Confirm" }), - onClick: isFirstStep - ? () => void onCheckAvailability() - : () => void handleModifyStay(), + onClick: isFirstStep ? onCheckAvailability : handleModifyStay, intent: isFirstStep ? "secondary" : "primary", isLoading: isLoading, disabled: isLoading, diff --git a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx index e98bedd9e..d88d96957 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/ReferenceCard/index.tsx @@ -94,6 +94,8 @@ export function ReferenceCard({ const { confirmationNumber, cancellationNumber, + checkInDate, + checkOutDate, isCancelled, bookingCode, rateDefinition, @@ -222,7 +224,7 @@ export function ReferenceCard({

- {`${dt(booking.checkInDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${hotel.hotelFacts.checkin.checkInTime}`} + {`${dt(checkInDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "from" })} ${hotel.hotelFacts.checkin.checkInTime}`}

@@ -232,7 +234,7 @@ export function ReferenceCard({

- {`${dt(booking.checkOutDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${hotel.hotelFacts.checkin.checkOutTime}`} + {`${dt(checkOutDate).locale(lang).format("dddd, D MMMM")} ${intl.formatMessage({ id: "until" })} ${hotel.hotelFacts.checkin.checkOutTime}`}

diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx index ae9bbf012..6d41ed81b 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx @@ -27,21 +27,14 @@ export default function MobileSummary({ const scrollY = useRef(0) const [isSummaryOpen, setIsSummaryOpen] = useState(false) - const { - booking, - bookingRooms, - roomsAvailability, - rateSummary, - vat, - packages, - } = useRatesStore((state) => ({ - booking: state.booking, - bookingRooms: state.booking.rooms, - roomsAvailability: state.roomsAvailability, - rateSummary: state.rateSummary, - vat: state.vat, - packages: state.packages, - })) + const { booking, bookingRooms, roomsAvailability, rateSummary, vat } = + useRatesStore((state) => ({ + booking: state.booking, + bookingRooms: state.booking.rooms, + roomsAvailability: state.roomsAvailability, + rateSummary: state.rateSummary, + vat: state.vat, + })) function toggleSummaryOpen() { setIsSummaryOpen(!isSummaryOpen) @@ -78,7 +71,7 @@ export default function MobileSummary({ } const rooms = rateSummary.map((room, index) => - room ? mapRate(room, index, bookingRooms, packages) : null + room ? mapRate(room, index, bookingRooms, room.packages) : null ) const containsBookingCodeRate = rateSummary.find( diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapRate.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapRate.ts index 6c9b05218..f75be9805 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapRate.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapRate.ts @@ -11,10 +11,6 @@ export function mapRate( bookingRooms: Room[], packages: NonNullable ) { - const roomPackages = room.packages - .map((code) => packages.find((pkg) => pkg.code === code)) - .filter((pkg): pkg is NonNullable => Boolean(pkg)) - const rate = { adults: bookingRooms[index].adults, cancellationText: room.product.rateDefinition?.cancellationText ?? "", @@ -39,7 +35,7 @@ export function mapRate( }, roomRate: room.product, roomType: room.roomType, - packages: roomPackages, + packages, } if ("corporateCheque" in room.product) { diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx index 64ea46345..8a4108cf7 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx @@ -30,7 +30,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { bookingCode, bookingRooms, dates, - petRoomPackage, + isFetchingPackages, rateSummary, roomsAvailability, searchParams, @@ -41,7 +41,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { checkInDate: state.booking.fromDate, checkOutDate: state.booking.toDate, }, - petRoomPackage: state.petRoomPackage, + isFetchingPackages: state.rooms.some((room) => room.isFetchingPackages), rateSummary: state.rateSummary, roomsAvailability: state.roomsAvailability, searchParams: state.searchParams, @@ -123,7 +123,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { }) } - if (!rateSummary.length) { + if (!rateSummary.length || isFetchingPackages) { return null } @@ -149,8 +149,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { const totalPriceToShow = getTotalPrice( mainRoomProduct, rateSummary, - isUserLoggedIn, - petRoomPackage + isUserLoggedIn ) const rateProduct = rateSummary.find((rate) => rate?.product)?.product @@ -248,7 +247,7 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { return total } - const { features, packages: roomPackages, product } = rate + const { packages: roomPackages, product } = rate const memberExists = "member" in product && product.member const publicExists = "public" in product && product.public @@ -266,21 +265,15 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) { return total } - const hasSelectedPetRoom = roomPackages.includes( - RoomPackageCodeEnum.PET_ROOM + const hasSelectedPetRoom = roomPackages.find( + (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM ) if (!hasSelectedPetRoom) { return total + price } - const isPetRoom = features.find( - (feature) => - feature.code === RoomPackageCodeEnum.PET_ROOM + return ( + total + price + hasSelectedPetRoom.localPrice.totalPrice ) - const petRoomPrice = - isPetRoom && petRoomPackage - ? Number(petRoomPackage.localPrice.totalPrice) - : 0 - return total + price + petRoomPrice }, 0), currency: mainRoomCurrency, }} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts index eb78b198c..087e9c320 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts @@ -1,17 +1,11 @@ import type { Price } from "@/types/components/hotelReservation/price" -import { - type RoomPackage, - RoomPackageCodeEnum, -} from "@/types/components/hotelReservation/selectRate/roomFilter" import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" import { CurrencyEnum } from "@/types/enums/currency" -import type { Packages } from "@/types/requests/packages" import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability" export function calculateTotalPrice( selectedRateSummary: Rate[], - isUserLoggedIn: boolean, - petRoomPackage: RoomPackage | undefined + isUserLoggedIn: boolean ) { return selectedRateSummary.reduce( (total, room, idx) => { @@ -32,35 +26,26 @@ export function calculateTotalPrice( return total } - const isPetRoom = room.features.find( - (feature) => feature.code === RoomPackageCodeEnum.PET_ROOM + const packagesPrice = room.packages.reduce( + (total, pkg) => { + total.local = total.local + pkg.localPrice.totalPrice + if (pkg.requestedPrice.totalPrice) { + total.requested = total.requested + pkg.requestedPrice.totalPrice + } + return total + }, + { local: 0, requested: 0 } ) - let petRoomPriceLocal = 0 - if ( - petRoomPackage && - isPetRoom && - room.packages.includes(RoomPackageCodeEnum.PET_ROOM) - ) { - petRoomPriceLocal = Number(petRoomPackage.localPrice.totalPrice) - } - let petRoomPriceRequested = 0 - if ( - petRoomPackage && - isPetRoom && - room.packages.includes(RoomPackageCodeEnum.PET_ROOM) - ) { - petRoomPriceRequested = Number(petRoomPackage.requestedPrice.totalPrice) - } total.local.currency = rate.localPrice.currency total.local.price = - total.local.price + rate.localPrice.pricePerStay + petRoomPriceLocal + total.local.price + rate.localPrice.pricePerStay + packagesPrice.local if (rate.localPrice.regularPricePerStay) { total.local.regularPrice = (total.local.regularPrice || 0) + rate.localPrice.regularPricePerStay + - petRoomPriceLocal + packagesPrice.local } if (rate.requestedPrice) { @@ -78,13 +63,13 @@ export function calculateTotalPrice( total.requested.price = total.requested.price + rate.requestedPrice.pricePerStay + - petRoomPriceRequested + packagesPrice.requested if (rate.requestedPrice.regularPricePerStay) { total.requested.regularPrice = (total.requested.regularPrice || 0) + rate.requestedPrice.regularPricePerStay + - petRoomPriceRequested + packagesPrice.requested } } @@ -199,8 +184,7 @@ export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) { export function getTotalPrice( mainRoomProduct: Rate | null, rateSummary: Array, - isUserLoggedIn: boolean, - petRoomPackage: NonNullable[number] | undefined + isUserLoggedIn: boolean ): Price | null { const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null) @@ -209,7 +193,7 @@ export function getTotalPrice( } if (!mainRoomProduct) { - return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage) + return calculateTotalPrice(summaryArray, isUserLoggedIn) } const { product } = mainRoomProduct @@ -222,5 +206,5 @@ export function getTotalPrice( return calculateVoucherPrice(summaryArray) } - return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage) + return calculateTotalPrice(summaryArray, isUserLoggedIn) } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx index e6286acdc..5bbac88a1 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx @@ -3,6 +3,7 @@ import { useIntl } from "react-intl" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { dt } from "@/lib/dt" import { useRatesStore } from "@/stores/select-rate" import Image from "@/components/Image" @@ -15,22 +16,31 @@ import { useRoomContext } from "@/contexts/SelectRate/Room" import styles from "./selectedRoomPanel.module.css" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { CurrencyEnum } from "@/types/enums/currency" import { RateEnum } from "@/types/enums/rate" export default function SelectedRoomPanel() { const intl = useIntl() - const { isUserLoggedIn, roomCategories, rooms } = useRatesStore((state) => ({ - isUserLoggedIn: state.isUserLoggedIn, - roomCategories: state.roomCategories, - rooms: state.rooms, - })) + const { dates, isUserLoggedIn, roomCategories, rooms } = useRatesStore( + (state) => ({ + dates: { + from: state.booking.fromDate, + to: state.booking.toDate, + }, + isUserLoggedIn: state.isUserLoggedIn, + roomCategories: state.roomCategories, + rooms: state.rooms, + }) + ) const { actions: { modifyRate }, isMainRoom, roomNr, + selectedPackages, selectedRate, } = useRoomContext() + const nights = dt(dates.to).diff(dt(dates.from), "days") const images = roomCategories.find((roomCategory) => roomCategory.roomTypes.some( @@ -60,8 +70,16 @@ export default function SelectedRoomPanel() { return null } + let petRoomPrice = 0 + const petRoomPackageSelected = selectedPackages.find( + (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM + ) + if (petRoomPackageSelected) { + petRoomPrice = petRoomPackageSelected.localPrice.totalPrice / nights + } + + const night = intl.formatMessage({ id: "night" }) let selectedProduct - let isPerNight = true if ( isUserLoggedIn && isMainRoom && @@ -69,19 +87,17 @@ export default function SelectedRoomPanel() { selectedRate.product.member ) { const { localPrice } = selectedRate.product.member - selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}` + selectedProduct = `${localPrice.pricePerNight + petRoomPrice} ${localPrice.currency} / ${night}` } else if ("public" in selectedRate.product && selectedRate.product.public) { const { localPrice } = selectedRate.product.public - selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}` + selectedProduct = `${localPrice.pricePerNight + petRoomPrice} ${localPrice.currency} / ${night}` } else if ("corporateCheque" in selectedRate.product) { - isPerNight = false const { localPrice } = selectedRate.product.corporateCheque selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}` if (localPrice.additionalPricePerStay && localPrice.currency) { selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay} ${localPrice.currency}` } } else if ("voucher" in selectedRate.product) { - isPerNight = false selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}` } @@ -109,9 +125,7 @@ export default function SelectedRoomPanel() { {getRateTitle(selectedRate.product.rate)} - - {`${selectedProduct}${isPerNight ? "/" + intl.formatMessage({ id: "night" }) : ""}`} - + {selectedProduct}
{images?.[0]?.imageSizes?.tiny ? ( diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/NoAvailabilityAlert/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/NoAvailabilityAlert/index.tsx index b832059fe..8e1a85545 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/NoAvailabilityAlert/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/NoAvailabilityAlert/index.tsx @@ -17,12 +17,16 @@ export default function NoAvailabilityAlert() { const lang = useLang() const intl = useIntl() const bookingCode = useRatesStore((state) => state.booking.bookingCode) - const { rooms } = useRoomContext() + const { isFetchingPackages, rooms } = useRoomContext() const noAvailableRooms = rooms.every( (roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable ) + if (isFetchingPackages) { + return null + } + if (noAvailableRooms) { const text = intl.formatMessage({ id: "There are no rooms available that match your request.", diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/index.tsx deleted file mode 100644 index 6fc58734f..000000000 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/index.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { Checkbox as AriaCheckbox } from "react-aria-components" - -import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" -import { Typography } from "@scandic-hotels/design-system/Typography" - -import styles from "./checkbox.module.css" - -import type { MaterialSymbolProps } from "react-material-symbols" - -interface CheckboxProps { - name: string - value: string - isSelected: boolean - iconName: MaterialSymbolProps["icon"] - isDisabled: boolean - onChange: (value: string) => void -} - -export default function Checkbox({ - isSelected, - name, - value, - iconName, - isDisabled, - onChange, -}: CheckboxProps) { - return ( - onChange(value)} - > - {({ isSelected }) => ( - <> - - {isSelected && } - - - {name} - - {iconName ? ( - - ) : null} - - )} - - ) -} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/index.tsx deleted file mode 100644 index fd536780e..000000000 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/index.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client" -import { useEffect, useState } from "react" -import { - Button as AriaButton, - Dialog, - DialogTrigger, - Popover, -} from "react-aria-components" -import { Controller, useForm } from "react-hook-form" -import { useIntl } from "react-intl" - -import { Button } from "@scandic-hotels/design-system/Button" -import { ChipButton } from "@scandic-hotels/design-system/ChipButton" -import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" -import { Typography } from "@scandic-hotels/design-system/Typography" - -import { useRatesStore } from "@/stores/select-rate" - -import Divider from "@/components/TempDesignSystem/Divider" -import { useRoomContext } from "@/contexts/SelectRate/Room" - -import Checkbox from "./Checkbox" -import { getIconNameByPackageCode } from "./utils" - -import styles from "./roomPackageFilter.module.css" - -import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" - -type FormValues = { - selectedPackages: RoomPackageCodeEnum[] -} - -export default function RoomPackageFilter() { - const intl = useIntl() - - const [isOpen, setIsOpen] = useState(false) - const packageOptions = useRatesStore((state) => state.packageOptions) - const { - actions: { togglePackages }, - selectedPackages, - } = useRoomContext() - - const { setValue, handleSubmit, control } = useForm({ - defaultValues: { - selectedPackages: selectedPackages, - }, - }) - - useEffect(() => { - setValue("selectedPackages", selectedPackages) - }, [selectedPackages, setValue]) - - function onSubmit(data: FormValues) { - togglePackages(data.selectedPackages) - setIsOpen(false) - } - - return ( -
- {selectedPackages.map((pkg) => ( - { - const packages = selectedPackages.filter((s) => s !== pkg) - togglePackages(packages) - }} - className={styles.activeFilterButton} - > - - - - ))} - - - {intl.formatMessage({ id: "Room preferences" })} - - - - -
- ( -
- {packageOptions.map((option) => { - const isPetRoom = - option.code === RoomPackageCodeEnum.PET_ROOM - - const isAllergyRoom = - option.code === RoomPackageCodeEnum.ALLERGY_ROOM - - const hasPetRoom = field.value.includes( - RoomPackageCodeEnum.PET_ROOM - ) - - const hasAllergyRoom = field.value.includes( - RoomPackageCodeEnum.ALLERGY_ROOM - ) - - const isDisabled = - (isPetRoom && hasAllergyRoom) || - (isAllergyRoom && hasPetRoom) - - return ( - <> - { - const isSelected = field.value.includes( - option.code - ) - const newValue = isSelected - ? field.value.filter( - (pkg) => pkg !== option.code - ) - : [...field.value, option.code] - field.onChange(newValue) - }} - /> - {option.code === RoomPackageCodeEnum.PET_ROOM && ( - -

- {intl.formatMessage( - { - id: "200 SEK/night Important information on pricing and features of pet-friendly rooms.", - }, - { - b: (str) => ( - - - {str} - - - ), - } - )} -

-
- )} - - ) - })} -
- )} - /> -
- -
- - - - - - -
-
- -
-
-
-
- ) -} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/BookingCodeFilter/bookingCodeFilter.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/BookingCodeFilter/bookingCodeFilter.module.css similarity index 100% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/BookingCodeFilter/bookingCodeFilter.module.css rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/BookingCodeFilter/bookingCodeFilter.module.css diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/BookingCodeFilter/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/BookingCodeFilter/index.tsx similarity index 51% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/BookingCodeFilter/index.tsx rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/BookingCodeFilter/index.tsx index fe48cf38d..baad29dc0 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/BookingCodeFilter/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/BookingCodeFilter/index.tsx @@ -2,10 +2,12 @@ import { useIntl } from "react-intl" +import { trpc } from "@/lib/trpc/client" import { useRatesStore } from "@/stores/select-rate" import Select from "@/components/TempDesignSystem/Select" import { useRoomContext } from "@/contexts/SelectRate/Room" +import useLang from "@/hooks/useLang" import styles from "./bookingCodeFilter.module.css" @@ -16,12 +18,16 @@ import { RateTypeEnum } from "@/types/enums/rateType" export default function BookingCodeFilter() { const intl = useIntl() + const lang = useLang() + const utils = trpc.useUtils() const { - actions: { selectFilter }, - selectedFilter, + actions: { appendRegularRates, selectFilter }, + bookingRoom, rooms, + selectedFilter, + selectedPackages, } = useRoomContext() - const bookingCode = useRatesStore((state) => state.booking.bookingCode) + const booking = useRatesStore((state) => state.booking) const bookingCodeFilterItems = [ { @@ -38,25 +44,45 @@ export default function BookingCodeFilter() { }, ] - function handleChangeFilter(selectedFilter: Key) { + async function handleChangeFilter(selectedFilter: Key) { selectFilter(selectedFilter as BookingCodeFilterEnum) + const room = await utils.hotel.availability.selectRate.room.fetch({ + booking: { + ...booking, + room: { + ...bookingRoom, + bookingCode: + selectedFilter === BookingCodeFilterEnum.Discounted + ? booking.bookingCode + : undefined, + packages: selectedPackages.map((pkg) => pkg.code), + }, + }, + lang, + }) + appendRegularRates(room?.roomConfigurations) } - const hideFilterDespiteBookingCode = rooms.every((room) => - room.products.every((product) => { - const isRedemption = Array.isArray(product) - if (isRedemption) { - return true - } - const isCorporateCheque = - product.rateDefinition?.rateType === RateTypeEnum.CorporateCheque - const isVoucher = - product.rateDefinition?.rateType === RateTypeEnum.Voucher - return isCorporateCheque || isVoucher - }) - ) + const hideFilterDespiteBookingCode = + rooms.length && + rooms.every((room) => + room.products.every((product) => { + const isRedemption = Array.isArray(product) + if (isRedemption) { + return true + } + const isCorporateCheque = + product.rateDefinition?.rateType === RateTypeEnum.CorporateCheque + const isVoucher = + product.rateDefinition?.rateType === RateTypeEnum.Voucher + return isCorporateCheque || isVoucher + }) + ) - if ((bookingCode && hideFilterDespiteBookingCode) || !bookingCode) { + if ( + (booking.bookingCode && hideFilterDespiteBookingCode) || + !booking.bookingCode + ) { return null } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/index.tsx new file mode 100644 index 000000000..59a6c526d --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/index.tsx @@ -0,0 +1,40 @@ +"use client" +import { useIntl } from "react-intl" + +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { useRoomContext } from "@/contexts/SelectRate/Room" +import { formatPrice } from "@/utils/numberFormatting" + +import styles from "./petRoom.module.css" + +export default function PetRoomMessage() { + const intl = useIntl() + const { petRoomPackage } = useRoomContext() + if (!petRoomPackage) { + return null + } + return ( + +

+ {intl.formatMessage( + { + id: "Pet-friendly rooms include a charge of approx. {price}/stay", + }, + { + b: (str) => ( + + {str} + + ), + price: formatPrice( + intl, + petRoomPackage.localPrice.price, + petRoomPackage.localPrice.currency + ), + } + )} +

+
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/petRoom.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/petRoom.module.css new file mode 100644 index 000000000..e54847db8 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/PetRoomMessage/petRoom.module.css @@ -0,0 +1,8 @@ +.additionalInformation { + color: var(--Text-Tertiary); + padding: var(--Space-x1) var(--Space-x15); +} + +.additionalInformationPrice { + color: var(--Text-Default); +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/checkbox.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/checkbox.module.css similarity index 100% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/Checkbox/checkbox.module.css rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/checkbox.module.css diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/index.tsx new file mode 100644 index 000000000..055f613a7 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/index.tsx @@ -0,0 +1,80 @@ +"use client" +import { Fragment } from "react" +import { Checkbox, CheckboxGroup } from "react-aria-components" +import { Controller, useFormContext } from "react-hook-form" + +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { useRatesStore } from "@/stores/select-rate" + +import { getIconNameByPackageCode } from "../../utils" +import PetRoomMessage from "./PetRoomMessage" +import { + checkIsAllergyRoom, + checkIsPetRoom, + includesAllergyRoom, + includesPetRoom, +} from "./utils" + +import styles from "./checkbox.module.css" + +import type { FormValues } from "../formValues" + +export default function Checkboxes() { + const packageOptions = useRatesStore((state) => state.packageOptions) + const { control } = useFormContext() + return ( + { + const allergyRoomSelected = includesAllergyRoom(field.value) + const petRoomSelected = includesPetRoom(field.value) + return ( + +
+ {packageOptions.map((option) => { + const isAllergyRoom = checkIsAllergyRoom(option.code) + const isPetRoom = checkIsPetRoom(option.code) + const isDisabled = + (isPetRoom && allergyRoomSelected) || + (isAllergyRoom && petRoomSelected) + + const isSelected = field.value.includes(option.code) + const iconName = getIconNameByPackageCode(option.code) + + return ( + + + + {isSelected ? ( + + ) : null} + + + {option.description} + + {iconName ? ( + + ) : null} + + {isPetRoom ? : null} + + ) + })} +
+
+ ) + }} + /> + ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/utils.ts new file mode 100644 index 000000000..073f0fa14 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/Checkboxes/utils.ts @@ -0,0 +1,18 @@ +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { PackageEnum } from "@/types/requests/packages" + +export function includesAllergyRoom(codes: PackageEnum[]) { + return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM) +} + +export function includesPetRoom(codes: PackageEnum[]) { + return codes.includes(RoomPackageCodeEnum.PET_ROOM) +} + +export function checkIsAllergyRoom(code: PackageEnum) { + return code === RoomPackageCodeEnum.ALLERGY_ROOM +} + +export function checkIsPetRoom(code: PackageEnum) { + return code === RoomPackageCodeEnum.PET_ROOM +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/form.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/form.module.css new file mode 100644 index 000000000..b3d7a69ae --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/form.module.css @@ -0,0 +1,11 @@ +.footer { + display: grid; + gap: var(--Space-x1); + padding: 0 var(--Space-x15); +} + +.buttonContainer { + align-items: center; + display: flex; + justify-content: space-between; +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/formValues.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/formValues.ts new file mode 100644 index 000000000..9d71d124e --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/formValues.ts @@ -0,0 +1,5 @@ +import type { PackageEnum } from "@/types/requests/packages" + +export type FormValues = { + selectedPackages: PackageEnum[] +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/index.tsx new file mode 100644 index 000000000..f64539d4f --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/Form/index.tsx @@ -0,0 +1,98 @@ +"use client" +import { FormProvider, useForm } from "react-hook-form" +import { useIntl } from "react-intl" + +import { Button } from "@scandic-hotels/design-system/Button" +import { Typography } from "@scandic-hotels/design-system/Typography" + +import { trpc } from "@/lib/trpc/client" +import { useRatesStore } from "@/stores/select-rate" + +import Divider from "@/components/TempDesignSystem/Divider" +import { useRoomContext } from "@/contexts/SelectRate/Room" +import useLang from "@/hooks/useLang" + +import Checkboxes from "./Checkboxes" + +import styles from "./form.module.css" + +import type { PackageEnum } from "@/types/requests/packages" +import type { FormValues } from "./formValues" + +export default function Form({ close }: { close: VoidFunction }) { + const intl = useIntl() + const lang = useLang() + const utils = trpc.useUtils() + + const { + actions: { removeSelectedPackages, selectPackages, updateRooms }, + bookingRoom, + selectedPackages, + } = useRoomContext() + const booking = useRatesStore((state) => state.booking) + + const methods = useForm({ + values: { + selectedPackages: selectedPackages.map((pkg) => pkg.code), + }, + }) + + async function getFilteredRates(packages: PackageEnum[]) { + const filterRates = await utils.hotel.availability.selectRate.room.fetch({ + booking: { + fromDate: booking.fromDate, + hotelId: booking.hotelId, + searchType: booking.searchType, + toDate: booking.toDate, + room: { + ...bookingRoom, + bookingCode: bookingRoom.rateCode + ? bookingRoom.bookingCode + : booking.bookingCode, + packages, + }, + }, + lang, + }) + updateRooms(filterRates?.roomConfigurations) + } + + function clearSelectedPackages() { + removeSelectedPackages() + close() + getFilteredRates([]) + } + + function onSubmit(data: FormValues) { + selectPackages(data.selectedPackages) + close() + getFilteredRates(data.selectedPackages) + } + + return ( + +
+ +
+ +
+ + + + + + +
+
+ +
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx new file mode 100644 index 000000000..ecaba9afc --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/index.tsx @@ -0,0 +1,101 @@ +"use client" +import { useState } from "react" +import { + Button as AriaButton, + Dialog, + DialogTrigger, + Popover, +} from "react-aria-components" +import { useIntl } from "react-intl" + +import { ChipButton } from "@scandic-hotels/design-system/ChipButton" +import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" + +import { trpc } from "@/lib/trpc/client" +import { useRatesStore } from "@/stores/select-rate" + +import { useRoomContext } from "@/contexts/SelectRate/Room" +import useLang from "@/hooks/useLang" + +import Form from "./Form" +import { getIconNameByPackageCode } from "./utils" + +import styles from "./roomPackageFilter.module.css" + +import type { PackageEnum } from "@/types/requests/packages" + +export default function RoomPackageFilter() { + const intl = useIntl() + const lang = useLang() + const utils = trpc.useUtils() + + const [isOpen, setIsOpen] = useState(false) + + const { + actions: { removeSelectedPackage, updateRooms }, + bookingRoom, + selectedPackages, + } = useRoomContext() + const booking = useRatesStore((state) => state.booking) + + async function deleteSelectedPackage(code: PackageEnum) { + removeSelectedPackage(code) + const filterRates = await utils.hotel.availability.selectRate.room.fetch({ + booking: { + fromDate: booking.fromDate, + hotelId: booking.hotelId, + searchType: booking.searchType, + toDate: booking.toDate, + room: { + ...bookingRoom, + bookingCode: bookingRoom.rateCode + ? bookingRoom.bookingCode + : booking.bookingCode, + packages: selectedPackages + .filter((pkg) => pkg.code !== code) + .map((pkg) => pkg.code), + }, + }, + lang, + }) + updateRooms(filterRates?.roomConfigurations) + } + + return ( +
+ {selectedPackages.map((pkg) => ( + deleteSelectedPackage(pkg.code)} + > + + + + ))} + + + {intl.formatMessage({ id: "Room preferences" })} + + + + +
setIsOpen(false)} /> +
+
+
+
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/roomPackageFilter.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/roomPackageFilter.module.css similarity index 65% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/roomPackageFilter.module.css rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/roomPackageFilter.module.css index f16475506..d696e113d 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/roomPackageFilter.module.css +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/roomPackageFilter.module.css @@ -3,33 +3,6 @@ gap: var(--Space-x1); } -.dialog { - display: grid; - gap: var(--Space-x1); - padding: var(--Space-x2); - flex-direction: column; - align-items: flex-end; - border-radius: var(--Corner-radius-md); - background-color: var(--Surface-Primary-Default); - box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); - max-width: 340px; -} - -.footer { - display: grid; - gap: var(--Space-x1); - padding: 0 var(--Space-x15); -} - -.additionalInformation { - color: var(--Text-Tertiary); - padding: var(--Space-x1) var(--Space-x15); -} - -.additionalInformationPrice { - color: var(--Text-Default); -} - .activeFilterButton { display: flex; justify-content: center; @@ -42,8 +15,14 @@ cursor: pointer; } -.buttonContainer { - display: flex; - justify-content: space-between; - align-items: center; +.dialog { + display: grid; + gap: var(--Space-x1); + padding: var(--Space-x2); + flex-direction: column; + align-items: flex-end; + border-radius: var(--Corner-radius-md); + background-color: var(--Surface-Primary-Default); + box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1); + max-width: 340px; } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/utils.ts similarity index 70% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/utils.ts rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/utils.ts index 7f0da0eff..d965467f3 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomPackageFilter/utils.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/RoomPackageFilter/utils.ts @@ -1,10 +1,11 @@ -import type { MaterialSymbolProps } from "react-material-symbols" +import type { SymbolCodepoints } from "react-material-symbols" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { PackageEnum } from "@/types/requests/packages" export function getIconNameByPackageCode( - packageCode: RoomPackageCodeEnum -): MaterialSymbolProps["icon"] { + packageCode: PackageEnum +): SymbolCodepoints { switch (packageCode) { case RoomPackageCodeEnum.PET_ROOM: return "pets" diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx index 6a5ca73e2..7b536c42f 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx @@ -5,15 +5,15 @@ import { Typography } from "@scandic-hotels/design-system/Typography" import { useRoomContext } from "@/contexts/SelectRate/Room" -import BookingCodeFilter from "../BookingCodeFilter" -import RoomPackageFilter from "../RoomPackageFilter" +import BookingCodeFilter from "./BookingCodeFilter" +import RoomPackageFilter from "./RoomPackageFilter" import styles from "./roomsHeader.module.css" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" export default function RoomsHeader() { - const { rooms, totalRooms } = useRoomContext() + const { isFetchingPackages, rooms, totalRooms } = useRoomContext() const intl = useIntl() const availableRooms = rooms.filter( @@ -42,11 +42,15 @@ export default function RoomsHeader() { return (
-

- {availableRooms !== totalRooms - ? notAllRoomsAvailableText - : allRoomsAvailableText} -

+ {isFetchingPackages ? ( +

+ ) : ( +

+ {availableRooms !== totalRooms + ? notAllRoomsAvailableText + : allRoomsAvailableText} +

+ )}
diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/index.tsx index 938046a6d..22627e10f 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/index.tsx @@ -37,24 +37,21 @@ export default function Rates({ selectedFilter, selectedPackages, } = useRoomContext() - const { nights, petRoomPackage } = useRatesStore((state) => ({ - nights: dt(state.booking.toDate).diff(state.booking.fromDate, "days"), - petRoomPackage: state.petRoomPackage, - })) - + const nights = useRatesStore((state) => + dt(state.booking.toDate).diff(state.booking.fromDate, "days") + ) function handleSelectRate(product: Product) { selectRate({ features, product, roomType, roomTypeCode }) } - const petRoomPackageSelected = selectedPackages.includes( - RoomPackageCodeEnum.PET_ROOM + const petRoomPackageSelected = selectedPackages.find( + (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM ) const sharedProps = { handleSelectRate, nights, - petRoomPackage: - petRoomPackageSelected && petRoomPackage ? petRoomPackage : undefined, + petRoomPackage: petRoomPackageSelected, roomTypeCode, } const showAllRates = selectedFilter === BookingCodeFilterEnum.All diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/totalPricePerNight.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/totalPricePerNight.ts index df9de9c05..2de166c38 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/totalPricePerNight.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/totalPricePerNight.ts @@ -1,10 +1,10 @@ -import type { RoomPackage } from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { Package } from "@/types/requests/packages" export function calculatePricePerNightPriceProduct( pricePerNight: number, requestedPricePerNight: number | undefined, nights: number, - petRoomPackage?: RoomPackage + petRoomPackage?: Package ) { const totalPrice = petRoomPackage?.localPrice ? Math.floor(pricePerNight + petRoomPackage.localPrice.price / nights) diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/RoomImage/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/RoomImage/index.tsx index 3fb93a93e..999e99e1c 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/RoomImage/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/RoomImage/index.tsx @@ -14,7 +14,7 @@ import styles from "./image.module.css" import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem" export default function RoomImage({ - features, + roomPackages, roomsLeft, roomType, roomTypeCode, @@ -44,11 +44,13 @@ export default function RoomImage({ ) : null} - {features - .filter((feature) => selectedPackages.includes(feature.code)) - .map((feature) => ( - - {IconForFeatureCode({ featureCode: feature.code, size: 16 })} + {roomPackages + .filter((pkg) => + selectedPackages.find((spkg) => spkg.code === pkg.code) + ) + .map((pkg) => ( + + {IconForFeatureCode({ featureCode: pkg.code, size: 16 })} ))}
diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/index.tsx index dbe1fc0d1..4538fb119 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/index.tsx @@ -1,5 +1,7 @@ "use client" +import { useRoomContext } from "@/contexts/SelectRate/Room" + import Details from "./Details" import { listItemVariants } from "./listItemVariants" import Rates from "./Rates" @@ -12,6 +14,7 @@ import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHote import type { RoomListItemProps } from "@/types/components/hotelReservation/selectRate/roomListItem" export default function RoomListItem({ roomConfiguration }: RoomListItemProps) { + const { roomPackages } = useRoomContext() const classNames = listItemVariants({ availability: roomConfiguration.status === AvailabilityEnum.NotAvailable @@ -22,7 +25,7 @@ export default function RoomListItem({ roomConfiguration }: RoomListItemProps) { return (
  • } - return ( <> diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/index.tsx index 6593c9fd1..e540ce7c8 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/index.tsx @@ -1,11 +1,10 @@ "use client" -import { dt } from "@/lib/dt" +import { trpc } from "@/lib/trpc/client" import useLang from "@/hooks/useLang" import RatesProvider from "@/providers/RatesProvider" -import { useHotelPackages, useRoomsAvailability } from "../utils" import RateSummary from "./RateSummary" import Rooms from "./Rooms" import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton" @@ -13,57 +12,34 @@ import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton" import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer" export function RoomsContainer({ - adultArray, booking, - childArray, - fromDate, - hotelData, + hotelType, isUserLoggedIn, - toDate, + roomCategories, + vat, }: RoomsContainerProps) { const lang = useLang() - const fromDateString = dt(fromDate).format("YYYY-MM-DD") - const toDateString = dt(toDate).format("YYYY-MM-DD") + const roomsAvailability = trpc.hotel.availability.selectRate.rooms.useQuery({ + booking, + lang, + }) - const { data: roomsAvailability, isPending: isLoadingAvailability } = - useRoomsAvailability( - adultArray, - hotelData.hotel.id, - fromDateString, - toDateString, - lang, - childArray, - booking - ) - - const { data: packages, isPending: isLoadingPackages } = useHotelPackages( - adultArray, - childArray, - fromDateString, - toDateString, - hotelData.hotel.id, - lang - ) - - if (isLoadingAvailability || isLoadingPackages) { + if ( + (roomsAvailability.isFetching || !roomsAvailability.data) && + !roomsAvailability.isFetched + ) { return } - if (packages === null) { - // TODO: Log packages error - console.error("[RoomsContainer] unable to fetch packages") - } - return ( diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx index ceab19826..3c4a330f7 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/index.tsx @@ -74,13 +74,11 @@ export default async function SelectRatePage({ diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts deleted file mode 100644 index 9cead0710..000000000 --- a/apps/scandic-web/components/HotelReservation/SelectRate/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { REDEMPTION } from "@/constants/booking" -import { trpc } from "@/lib/trpc/client" - -import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" -import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" -import type { Lang } from "@/constants/languages" -import type { ChildrenInRoom } from "@/utils/hotelSearchDetails" - -export function useRoomsAvailability( - adultsCount: number[], - hotelId: string, - fromDateString: string, - toDateString: string, - lang: Lang, - childArray: ChildrenInRoom, - booking: SelectRateSearchParams -) { - const redemption = booking.searchType - ? booking.searchType === REDEMPTION - : undefined - - const roomFeatureCodesArray = booking.rooms.map( - (room) => room.packages ?? null - ) - - const roomsAvailability = - trpc.hotel.availability.roomsCombinedAvailability.useQuery({ - adultsCount, - childArray, - hotelId, - lang, - redemption, - roomStayEndDate: toDateString, - roomStayStartDate: fromDateString, - bookingCode: booking.bookingCode, - roomFeatureCodesArray, - }) - - return roomsAvailability -} - -export function useHotelPackages( - adultArray: number[], - childArray: ChildrenInRoom, - fromDateString: string, - toDateString: string, - hotelId: string, - lang: Lang -) { - return trpc.hotel.packages.get.useQuery({ - adults: adultArray[0], // Using the first adult count - children: childArray?.[0]?.length, // Using the first children count - endDate: toDateString, - hotelId, - packageCodes: [ - RoomPackageCodeEnum.ACCESSIBILITY_ROOM, - RoomPackageCodeEnum.PET_ROOM, - RoomPackageCodeEnum.ALLERGY_ROOM, - ], - startDate: fromDateString, - lang: lang, - }) -} diff --git a/apps/scandic-web/i18n/dictionaries/da.json b/apps/scandic-web/i18n/dictionaries/da.json index 355732eed..0dcab0a38 100644 --- a/apps/scandic-web/i18n/dictionaries/da.json +++ b/apps/scandic-web/i18n/dictionaries/da.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/nat per voksen", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/nat Vigtig information om priser og funktioner i kæledyrsvenlige værelser.", "Included (based on availability)": "Inkluderet (baseret pÃ¥ tilgængelighed)", "Total price (incl VAT)": "Samlet pris (inkl. moms)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -610,6 +609,7 @@ "Pet room charge including VAT": "Gebyr for kæledyrsværelse inkl. moms", "Pet-friendly": "Kæledyrsvenlig", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kæledyrsrum har en ekstra gebyr pÃ¥ 20 EUR per ophold", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Kæledyrsvenlige værelser inkluderer et gebyr pÃ¥ ca. {price}/ophold", "Phone": "Telefon", "Phone is required": "Telefonnummer er pÃ¥krævet", "Phone number": "Telefonnummer", diff --git a/apps/scandic-web/i18n/dictionaries/de.json b/apps/scandic-web/i18n/dictionaries/de.json index 7bd43b15b..b7ff56fc1 100644 --- a/apps/scandic-web/i18n/dictionaries/de.json +++ b/apps/scandic-web/i18n/dictionaries/de.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/Nacht pro Erwachsenem", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/Nacht Wichtige Informationen zu Preisen und Eigenschaften von haustierfreundlichen Zimmern.", "Included (based on availability)": "Inbegriffen (je nach Verfügbarkeit)", "Total price (incl VAT)": "Gesamtpreis (inkl. MwSt.)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -609,6 +608,7 @@ "Pet room charge including VAT": "Haustierzimmergebühr inkl. MwSt.", "Pet-friendly": "Haustierfreundlich", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Haustierzimmer haben einen zusätzlichen Preis von 20 EUR pro Aufenthalt", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Für haustierfreundliche Zimmer fällt eine Gebühr von ca. {price}/Aufenthalt an.", "Phone": "Telefon", "Phone is required": "Telefon ist erforderlich", "Phone number": "Telefonnummer", diff --git a/apps/scandic-web/i18n/dictionaries/en.json b/apps/scandic-web/i18n/dictionaries/en.json index 1c02dd1ed..4b31c20d3 100644 --- a/apps/scandic-web/i18n/dictionaries/en.json +++ b/apps/scandic-web/i18n/dictionaries/en.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/night per adult", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/night Important information on pricing and features of pet-friendly rooms.", "Included (based on availability)": "Included (based on availability)", "Total price (incl VAT)": "Total price (incl VAT)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -610,6 +609,7 @@ "Pet room charge including VAT": "Pet room charge including VAT", "Pet-friendly": "Pet-friendly", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Pet-friendly rooms have an additional fee of 20 EUR per stay", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Pet-friendly rooms include a charge of approx. {price}/stay", "Phone": "Phone", "Phone is required": "Phone is required", "Phone number": "Phone number", diff --git a/apps/scandic-web/i18n/dictionaries/fi.json b/apps/scandic-web/i18n/dictionaries/fi.json index c877b316f..64a4731b6 100644 --- a/apps/scandic-web/i18n/dictionaries/fi.json +++ b/apps/scandic-web/i18n/dictionaries/fi.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/yötä aikuista kohti", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/yö Tärkeitä tietoja hinnoista ja lemmikkieläinystävällisten huoneiden ominaisuuksista.", "Included (based on availability)": "Sisältyy (saatavuuden mukaan)", "Total price (incl VAT)": "Kokonaishinta (sis. ALV)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -608,6 +607,7 @@ "Pet room charge including VAT": "Lemmikkihuoneen maksu sis. ALV", "Pet-friendly": "Lemmikkiystävällinen", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Lemmikkihuoneen lisäkustannus on 20 EUR per majoitus", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Lemmikkiystävälliset huoneet sisältävät n. {price}/yöpyminen", "Phone": "Puhelin", "Phone is required": "Puhelin vaaditaan", "Phone number": "Puhelinnumero", diff --git a/apps/scandic-web/i18n/dictionaries/no.json b/apps/scandic-web/i18n/dictionaries/no.json index 24e70df73..70bf26a23 100644 --- a/apps/scandic-web/i18n/dictionaries/no.json +++ b/apps/scandic-web/i18n/dictionaries/no.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/natt per voksen", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/natt Viktig informasjon om priser og funksjoner for dyrevennlige rom.", "Included (based on availability)": "Inkludert (basert pÃ¥ tilgjengelighet)", "Total price (incl VAT)": "Totalpris (inkl. mva)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -607,6 +606,7 @@ "Pet room charge including VAT": "Kjæledyrromsgebyr inkl. MVA", "Pet-friendly": "Dyrevennlig", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Kjæledyrsrom har en tilleggsavgift pÃ¥ 20 EUR per opphold", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Kjæledyrvennlige rom inkluderer en kostnad pÃ¥ ca. {price}/opphold", "Phone": "Telefon", "Phone is required": "Telefon kreves", "Phone number": "Telefonnummer", diff --git a/apps/scandic-web/i18n/dictionaries/sv.json b/apps/scandic-web/i18n/dictionaries/sv.json index f5f29aa1b..098ff8c96 100644 --- a/apps/scandic-web/i18n/dictionaries/sv.json +++ b/apps/scandic-web/i18n/dictionaries/sv.json @@ -2,7 +2,6 @@ "+46 8 517 517 00": "+46 8 517 517 00", "/night per adult": "/natt per vuxen", "1 EuroBonus point = 2 Scandic Friends points": "1 EuroBonus point = 2 Scandic Friends points", - "200 SEK/night Important information on pricing and features of pet-friendly rooms.": "200 SEK/natt Viktig information om prissättning och funktioner för husdjursvänliga rum.", "Included (based on availability)": "IngÃ¥r (baserat pÃ¥ tillgänglighet)", "Total price (incl VAT)": "Totalpris (inkl moms)", "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points": "{sasPoints, number} EuroBonus points to {scandicPoints, number} Scandic Friends points", @@ -607,6 +606,7 @@ "Pet room charge including VAT": "Avgift för husdjursrum inkl. moms", "Pet-friendly": "Husdjursvänlig", "Pet-friendly rooms have an additional fee of 20 EUR per stay": "Husdjursrum har en extra avgift pÃ¥ 20 EUR per vistelse", + "Pet-friendly rooms include a charge of approx. {price}/stay": "Husdjursvänliga rum har en avgift pÃ¥ ca. {price}/vistelse", "Phone": "Telefon", "Phone is required": "Telefonnummer är obligatorisk", "Phone number": "Telefonnummer", diff --git a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts index 16deee137..ed26a0f6d 100644 --- a/apps/scandic-web/lib/trpc/memoizedRequests/index.ts +++ b/apps/scandic-web/lib/trpc/memoizedRequests/index.ts @@ -11,15 +11,13 @@ import type { BreackfastPackagesInput, PackagesInput, } from "@/types/requests/packages" +import type { RoomsAvailabilityExtendedInputSchema } from "@/types/trpc/routers/hotel/availability" import type { CityCoordinatesInput, HotelInput, } from "@/types/trpc/routers/hotel/hotel" import type { Lang } from "@/constants/languages" -import type { - GetHotelsByCSFilterInput, - GetSelectedRoomAvailabilityInput, -} from "@/server/routers/hotels/input" +import type { GetHotelsByCSFilterInput } from "@/server/routers/hotels/input" import type { GetSavedPaymentCardsInput } from "@/server/routers/user/input" export const getLocations = cache(async function getMemoizedLocations() { @@ -92,14 +90,6 @@ export const getHotelPage = cache(async function getMemoizedHotelPage() { return serverClient().contentstack.hotelPage.get() }) -export const getSelectedRoomAvailability = cache( - function getMemoizedSelectedRoomAvailability( - input: GetSelectedRoomAvailabilityInput - ) { - return serverClient().hotel.availability.room(input) - } -) - export const getFooter = cache(async function getMemoizedFooter() { return serverClient().contentstack.base.footer() }) @@ -352,3 +342,11 @@ export const getJumpToData = cache(async function getMemoizedJumpToData() { return null }) + +export const getSelectedRoomsAvailability = cache( + async function getMemoizedSelectedRoomsAvailability( + input: RoomsAvailabilityExtendedInputSchema + ) { + return serverClient().hotel.availability.enterDetails(input) + } +) diff --git a/apps/scandic-web/providers/RatesProvider.tsx b/apps/scandic-web/providers/RatesProvider.tsx index 309a3d68d..eea09aa14 100644 --- a/apps/scandic-web/providers/RatesProvider.tsx +++ b/apps/scandic-web/providers/RatesProvider.tsx @@ -15,7 +15,6 @@ export default function RatesProvider({ children, hotelType, isUserLoggedIn, - packages, roomCategories, roomsAvailability, vat, @@ -35,11 +34,10 @@ export default function RatesProvider({ allergyRoom: intl.formatMessage({ id: "Allergy-friendly room" }), petRoom: intl.formatMessage({ id: "Pet room" }), }, - packages: packages ?? [], pathname, roomCategories, roomsAvailability, - searchParams, + searchParams: new URLSearchParams(searchParams), vat, }) } diff --git a/apps/scandic-web/providers/SelectRate/RoomProvider.tsx b/apps/scandic-web/providers/SelectRate/RoomProvider.tsx index 02665527e..8e942c760 100644 --- a/apps/scandic-web/providers/SelectRate/RoomProvider.tsx +++ b/apps/scandic-web/providers/SelectRate/RoomProvider.tsx @@ -1,15 +1,10 @@ "use client" -import { useEffect } from "react" -import { REDEMPTION } from "@/constants/booking" -import { trpc } from "@/lib/trpc/client" import { useRatesStore } from "@/stores/select-rate" import { RoomContext } from "@/contexts/SelectRate/Room" -import useLang from "@/hooks/useLang" -import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" -import { RateTypeEnum } from "@/types/enums/rateType" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import type { RoomProviderProps } from "@/types/providers/select-rate/room" export default function RoomProvider({ @@ -17,128 +12,28 @@ export default function RoomProvider({ idx, room, }: RoomProviderProps) { - const lang = useLang() - const { - activeRoom, - booking, - roomAvailability, - searchParams, - selectedFilter, - selectedPackages, - } = useRatesStore((state) => ({ - activeRoom: state.activeRoom, - booking: state.booking, - roomAvailability: state.roomsAvailability?.[idx], - searchParams: state.searchParams, - selectedFilter: state.rooms[idx].selectedFilter, - selectedPackages: state.rooms[idx].selectedPackages, - })) - const { appendRegularRates, addRoomFeatures, ...actions } = room.actions - const roomNr = idx + 1 - - const redemptionSearch = searchParams.has("searchType") - ? searchParams.get("searchType") === REDEMPTION - : false - const hasRedemptionRates = - redemptionSearch || room.rooms.some((room) => room.redemptions.length) - const hasCorporateChequeOrVoucherRates = room.rooms.some((room) => - room.code.some((product) => { - if ("corporateCheque" in product) { - return product.corporateCheque.rateType === RateTypeEnum.CorporateCheque - } else if ("voucher" in product) { - return product.voucher.rateType === RateTypeEnum.Voucher - } - return false + const { activeRoom, roomAvailability, roomPackages } = useRatesStore( + (state) => ({ + activeRoom: state.activeRoom, + roomPackages: state.roomsPackages[idx], + roomAvailability: state.roomsAvailability?.[idx], }) ) + const roomNr = idx + 1 - const dontShowRegularRates = - hasRedemptionRates || hasCorporateChequeOrVoucherRates - - // Since input would be the same on single room as already - // done in useRoomsAvailability hook, data is already present - // and thus runs the appendRegularRates updater resulting in - // duplicate data - const enabled = !!( - booking.bookingCode && - selectedFilter !== BookingCodeFilterEnum.Discounted && - !dontShowRegularRates + const petRoomPackage = roomPackages.find( + (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM ) - // Extra query needed to fetch regular rates upon user - // selecting to view all rates. - // TODO: Setup route to handle singular availability call - const { data, isFetched, isFetching } = - trpc.hotel.availability.roomsCombinedAvailability.useQuery( - { - adultsCount: [room.bookingRoom.adults], - childArray: room.bookingRoom.childrenInRoom - ? [room.bookingRoom.childrenInRoom] - : undefined, - hotelId: booking.hotelId, - lang, - roomStayEndDate: booking.toDate, - roomStayStartDate: booking.fromDate, - }, - { - enabled, - } - ) - - useEffect(() => { - if (isFetched && !isFetching && data?.length && enabled) { - const regularRates = data[0] - if ("roomConfigurations" in regularRates) { - appendRegularRates(regularRates.roomConfigurations) - } - } - }, [appendRegularRates, data, enabled, isFetched, isFetching]) - - const { - data: roomFeaturesData, - isFetched: isRoomFeaturesFetched, - isFetching: isRoomFeaturesFetching, - } = trpc.hotel.availability.roomFeatures.useQuery( - { - adults: room.bookingRoom.adults, - childrenInRoom: room.bookingRoom.childrenInRoom, - hotelId: booking.hotelId, - startDate: booking.fromDate, - endDate: booking.toDate, - roomFeatureCodes: selectedPackages, - roomIndex: idx, // Creates a unique query key for each room - }, - { - enabled: !!selectedPackages.length, - } - ) - - useEffect(() => { - if ( - isRoomFeaturesFetched && - !isRoomFeaturesFetching && - roomFeaturesData?.length - ) { - addRoomFeatures(roomFeaturesData) - } - }, [ - addRoomFeatures, - roomFeaturesData, - isRoomFeaturesFetched, - isRoomFeaturesFetching, - ]) return ( - -export const ratesInputSchema = z.object({ +const baseBookingSchema = z.object({ + bookingCode: z.string().optional(), + fromDate: z.string(), hotelId: z.string(), + searchType: z.string().optional(), + toDate: z.string(), }) +export const selectRateRoomsAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + rooms: z.array( + baseRoomSchema.extend({ + rateCode: z.string().optional(), + roomTypeCode: z.string().optional(), + }) + ), + }), + lang: z.nativeEnum(Lang), +}) + +export const selectRateRoomAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + room: baseRoomSchema.extend({ + rateCode: z.string().optional(), + roomTypeCode: z.string().optional(), + }), + }), + lang: z.nativeEnum(Lang), +}) + +export const enterDetailsRoomsAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + rooms: z.array(baseRoomSchema.merge(selectedRoomSchema)), + }), + lang: z.nativeEnum(Lang), +}) + +export const myStayRoomAvailabilityInputSchema = z.object({ + booking: baseBookingSchema.extend({ + room: baseRoomSchema.merge(selectedRoomSchema), + }), + lang: z.nativeEnum(Lang), +}) + +export const roomFeaturesInputSchema = z.object({ + adults: z.number(), + childrenInRoom: childrenInRoomSchema, + endDate: z.string(), + hotelId: z.string(), + lang: z.nativeEnum(Lang), + roomFeatureCodes: z + .array(z.nativeEnum({ ...BreakfastPackageEnum, ...RoomPackageCodeEnum })) + .optional(), + startDate: z.string(), +}) + +export type RoomFeaturesInput = z.input + export const hotelInputSchema = z.object({ hotelId: z.string(), isCardOnlyPayment: z.boolean(), @@ -91,7 +125,7 @@ export const getHotelsByCSFilterInput = z.object({ hotelsToInclude: z.array(z.string()), }) export interface GetHotelsByCSFilterInput - extends z.infer {} + extends z.infer { } export const nearbyHotelIdsInput = z.object({ hotelId: z.string(), @@ -127,13 +161,13 @@ export const ancillaryPackageInputSchema = z.object({ }) export const roomPackagesInputSchema = z.object({ - hotelId: z.string(), - startDate: z.string(), - endDate: z.string(), adults: z.number(), children: z.number().optional().default(0), - packageCodes: z.array(z.string()).optional().default([]), + endDate: z.string(), + hotelId: z.string(), lang: z.nativeEnum(Lang), + packageCodes: z.array(z.string()).optional().default([]), + startDate: z.string(), }) export const cityCoordinatesInputSchema = z.object({ city: z.string(), @@ -167,22 +201,3 @@ export const getLocationsInput = z.object({ export const getLocationsUrlsInput = z.object({ lang: z.nativeEnum(Lang), }) - -export const roomFeaturesInputSchema = z.object({ - hotelId: z.string(), - startDate: z.string(), - endDate: z.string(), - adults: z.number(), - childrenInRoom: z - .array( - z.object({ - age: z.number(), - bed: z.nativeEnum(ChildBedMapEnum), - }) - ) - .optional(), - roomFeatureCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(), - roomIndex: z.number().optional(), -}) - -export type RoomFeaturesInput = z.input diff --git a/apps/scandic-web/server/routers/hotels/metrics.ts b/apps/scandic-web/server/routers/hotels/metrics.ts index 856866331..690746226 100644 --- a/apps/scandic-web/server/routers/hotels/metrics.ts +++ b/apps/scandic-web/server/routers/hotels/metrics.ts @@ -70,14 +70,10 @@ export const metrics = { fail: meter.createCounter("trpc.hotel.packages.get-fail"), success: meter.createCounter("trpc.hotel.packages.get-success"), }, - roomsCombinedAvailability: { - counter: meter.createCounter("trpc.hotel.roomsCombinedAvailability.rooms"), - fail: meter.createCounter( - "trpc.hotel.roomsCombinedAvailability.rooms-fail" - ), - success: meter.createCounter( - "trpc.hotel.roomsCombinedAvailability.rooms-success" - ), + roomsAvailability: { + counter: meter.createCounter("trpc.hotel.roomsAvailability.rooms"), + fail: meter.createCounter("trpc.hotel.roomsAvailability.rooms-fail"), + success: meter.createCounter("trpc.hotel.roomsAvailability.rooms-success"), }, selectedRoomAvailability: { counter: meter.createCounter("trpc.hotel.availability.room"), diff --git a/apps/scandic-web/server/routers/hotels/output.ts b/apps/scandic-web/server/routers/hotels/output.ts index 6993496d6..4f3df6c62 100644 --- a/apps/scandic-web/server/routers/hotels/output.ts +++ b/apps/scandic-web/server/routers/hotels/output.ts @@ -23,10 +23,10 @@ import { breakfastPackageSchema, packageSchema, } from "./schemas/packages" -import { rateSchema } from "./schemas/rate" import { relationshipsSchema } from "./schemas/relationships" import { roomConfigurationSchema } from "./schemas/roomAvailability/configuration" import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition" +import { sortRoomConfigs } from "./utils" import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" @@ -42,7 +42,6 @@ import type { import type { Product, RateDefinition, - RoomConfiguration, } from "@/types/trpc/routers/hotel/roomAvailability" // NOTE: Find schema at: https://aks-test.scandichotels.com/hotel/swagger/v1/index.html @@ -136,18 +135,6 @@ const cancellationRules = { NotCancellable: 0, } as const -// Used to ensure `Available` rooms -// are shown before all `NotAvailable` -const statusLookup = { - [AvailabilityEnum.Available]: 1, - [AvailabilityEnum.NotAvailable]: 2, -} - -function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) { - // @ts-expect-error - array indexing - return statusLookup[a.status] - statusLookup[b.status] -} - export const roomsAvailabilitySchema = z .object({ data: z.object({ @@ -158,48 +145,10 @@ export const roomsAvailabilitySchema = z hotelId: z.number(), mustBeGuaranteed: z.boolean().optional(), occupancy: occupancySchema.optional(), + packages: z.array(packageSchema).optional().default([]), rateDefinitions: z.array(rateDefinitionSchema), - roomConfigurations: z - .array(roomConfigurationSchema) - .transform((data) => { - // Initial sort to guarantee if one bed is NotAvailable and whereas - // the other is Available to make sure data is added to the correct - // roomConfig - const configs = data.sort(sortRoomConfigs) - const roomConfigs = new Map() - for (const roomConfig of configs) { - if (roomConfigs.has(roomConfig.roomType)) { - const currentRoomConf = roomConfigs.get(roomConfig.roomType) - if (currentRoomConf) { - currentRoomConf.features = roomConfig.features.reduce( - (feats, feature) => { - const currentFeatureIndex = feats.findIndex( - (f) => f.code === feature.code - ) - if (currentFeatureIndex !== -1) { - feats[currentFeatureIndex].inventory = - feats[currentFeatureIndex].inventory + - feature.inventory - } else { - feats.push(feature) - } - return feats - }, - currentRoomConf.features - ) - currentRoomConf.roomsLeft = - currentRoomConf.roomsLeft + roomConfig.roomsLeft - roomConfigs.set(currentRoomConf.roomType, currentRoomConf) - } - } else { - roomConfigs.set(roomConfig.roomType, roomConfig) - } - } - return Array.from(roomConfigs.values()) - }), + roomConfigurations: z.array(roomConfigurationSchema), }), - relationships: relationshipsSchema.optional(), - type: z.string().optional(), }), }) .transform(({ data: { attributes } }) => { @@ -425,8 +374,6 @@ export const roomsAvailabilitySchema = z } }) -export const ratesSchema = z.array(rateSchema) - export const citiesByCountrySchema = z.object({ data: z.array( citySchema.transform((data) => { @@ -597,17 +544,6 @@ export const packagesSchema = z hotelId: z.number(), packages: z.array(packageSchema).default([]), }), - relationships: z - .object({ - links: z.array( - z.object({ - type: z.string(), - url: z.string(), - }) - ), - }) - .optional(), - type: z.string(), }) .optional(), }) diff --git a/apps/scandic-web/server/routers/hotels/query.ts b/apps/scandic-web/server/routers/hotels/query.ts index bd4d3d3b6..e4d181bb4 100644 --- a/apps/scandic-web/server/routers/hotels/query.ts +++ b/apps/scandic-web/server/routers/hotels/query.ts @@ -1,4 +1,4 @@ -import { CancellationRuleEnum } from "@/constants/booking" +import { REDEMPTION } from "@/constants/booking" import { Lang } from "@/constants/languages" import { env } from "@/env/server" import * as api from "@/lib/api" @@ -14,9 +14,7 @@ import { } from "@/server/trpc" import { toApiLang } from "@/server/utils" -import { generateChildrenString } from "@/components/HotelReservation/utils" import { getCacheClient } from "@/services/dataCache" -import { cache } from "@/utils/cache" import { getHotelPageUrls } from "../contentstack/hotelPage/utils" import { getVerifiedUser } from "../user/query" @@ -26,6 +24,7 @@ import { ancillaryPackageInputSchema, breakfastPackageInputSchema, cityCoordinatesInputSchema, + enterDetailsRoomsAvailabilityInputSchema, getAdditionalDataInputSchema, getDestinationsMapDataInput, getHotelsByCityIdentifierInput, @@ -37,517 +36,48 @@ import { getMeetingRoomsInputSchema, hotelInputSchema, hotelsAvailabilityInputSchema, + myStayRoomAvailabilityInputSchema, nearbyHotelIdsInput, - ratesInputSchema, - type RoomFeaturesInput, - roomFeaturesInputSchema, roomPackagesInputSchema, - roomsCombinedAvailabilityInputSchema, - selectedRoomAvailabilityInputSchema, + selectRateRoomAvailabilityInputSchema, + selectRateRoomsAvailabilityInputSchema, } from "./input" import { metrics } from "./metrics" import { ancillaryPackagesSchema, breakfastPackagesSchema, getNearbyHotelIdsSchema, - hotelsAvailabilitySchema, - hotelSchema, - packagesSchema, - ratesSchema, - roomFeaturesSchema, - roomsAvailabilitySchema, } from "./output" import { locationsUrlsCounter, locationsUrlsFailCounter, locationsUrlsSuccessCounter, } from "./telemetry" -import tempRatesData from "./tempRatesData.json" import { + getBedTypes, getCitiesByCountry, getCountries, + getHotel, getHotelIdsByCityId, getHotelIdsByCityIdentifier, getHotelIdsByCountry, + getHotelsAvailabilityByCity, + getHotelsAvailabilityByHotelIds, getHotelsByHotelIds, getLocations, + getPackages, + getRoomsAvailability, getSelectedRoomAvailability, + mergeRoomTypes, } from "./utils" -import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" +import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" import { BreakfastPackageEnum } from "@/types/enums/breakfast" -import { HotelTypeEnum } from "@/types/enums/hotelType" +import { RateEnum } from "@/types/enums/rate" import { RateTypeEnum } from "@/types/enums/rateType" import type { DestinationPagesHotelData, HotelDataWithUrl } from "@/types/hotel" -import type { - HotelsAvailabilityInputSchema, - HotelsByHotelIdsAvailabilityInputSchema, -} from "@/types/trpc/routers/hotel/availability" -import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { CityLocation } from "@/types/trpc/routers/hotel/locations" -export const getHotel = cache( - async (input: HotelInput, serviceToken: string) => { - const callable = async function ( - hotelId: HotelInput["hotelId"], - language: HotelInput["language"], - isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] - ) { - /** - * Since API expects the params appended and not just - * a comma separated string we need to initialize the - * SearchParams with a sequence of pairs - * (include=City&include=NearbyHotels&include=Restaurants etc.) - **/ - const params = new URLSearchParams([ - ["include", "AdditionalData"], - ["include", "City"], - ["include", "NearbyHotels"], - ["include", "Restaurants"], - ["include", "RoomCategories"], - ["language", toApiLang(language)], - ]) - metrics.hotel.counter.add(1, { - hotelId, - language, - }) - console.info( - "api.hotels.hotelData start", - JSON.stringify({ query: { hotelId, params: params.toString() } }) - ) - - const apiResponse = await api.get( - api.endpoints.v1.Hotel.Hotels.hotel(hotelId), - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotel.fail.add(1, { - hotelId, - language, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.hotelData error", - JSON.stringify({ - query: { hotelId, params: params.toString() }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - return null - } - - const apiJson = await apiResponse.json() - const validateHotelData = hotelSchema.safeParse(apiJson) - - if (!validateHotelData.success) { - metrics.hotel.fail.add(1, { - hotelId, - language, - error_type: "validation_error", - error: JSON.stringify(validateHotelData.error), - }) - - console.error( - "api.hotels.hotelData validation error", - JSON.stringify({ - query: { hotelId, params: params.toString() }, - error: validateHotelData.error, - }) - ) - throw badRequestError() - } - - metrics.hotel.success.add(1, { - hotelId, - language, - }) - console.info( - "api.hotels.hotelData success", - JSON.stringify({ - query: { hotelId, params: params.toString() }, - }) - ) - const hotelData = validateHotelData.data - - if (isCardOnlyPayment) { - hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] - } - - const gallery = hotelData.additionalData?.gallery - if (gallery) { - const smallerImages = gallery.smallerImages - const hotelGalleryImages = - hotelData.hotel.hotelType === HotelTypeEnum.Signature - ? smallerImages.slice(0, 10) - : smallerImages.slice(0, 6) - hotelData.hotel.galleryImages = hotelGalleryImages - } - - return hotelData - } - - const cacheClient = await getCacheClient() - return await cacheClient.cacheOrGet( - `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`, - async () => { - return callable(input.hotelId, input.language, input.isCardOnlyPayment) - }, - env.CACHE_TIME_HOTELS - ) - } -) - -async function getHotelsAvailabilityByCity( - input: HotelsAvailabilityInputSchema, - apiLang: string, - token: string, // Either service token or user access token in case of redemption search - userPoints: number = 0 -) { - const { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - } = input - - const params: Record = { - roomStayStartDate, - roomStayEndDate, - adults, - ...(children && { children }), - ...(bookingCode && { bookingCode }), - ...(redemption ? { isRedemption: "true" } : {}), - language: apiLang, - } - metrics.hotelsAvailability.counter.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - }) - console.info( - "api.hotels.hotelsAvailability start", - JSON.stringify({ query: { cityId, params } }) - ) - const apiResponse = await api.get( - api.endpoints.v1.Availability.city(cityId), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - params - ) - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotelsAvailability.fail.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.hotelsAvailability error", - JSON.stringify({ - query: { cityId, params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - - throw new Error("Failed to fetch hotels availability by city") - } - - const apiJson = await apiResponse.json() - const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) - if (!validateAvailabilityData.success) { - metrics.hotelsAvailability.fail.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - error_type: "validation_error", - error: JSON.stringify(validateAvailabilityData.error), - }) - console.error( - "api.hotels.hotelsAvailability validation error", - JSON.stringify({ - query: { cityId, params }, - error: validateAvailabilityData.error, - }) - ) - throw badRequestError() - } - metrics.hotelsAvailability.success.add(1, { - cityId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - redemption, - }) - console.info( - "api.hotels.hotelsAvailability success", - JSON.stringify({ - query: { cityId, params: params }, - }) - ) - if (redemption) { - validateAvailabilityData.data.data.forEach((data) => { - data.attributes.productType?.redemptions?.forEach((r) => { - r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay - }) - }) - } - - return { - availability: validateAvailabilityData.data.data.flatMap( - (hotels) => hotels.attributes - ), - } -} - -export const getHotelsAvailabilityByHotelIds = async ( - input: HotelsByHotelIdsAvailabilityInputSchema, - apiLang: string, - serviceToken: string -) => { - const { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - } = input - - const params = new URLSearchParams([ - ["roomStayStartDate", roomStayStartDate], - ["roomStayEndDate", roomStayEndDate], - ["adults", adults.toString()], - ["children", children ?? ""], - ["bookingCode", bookingCode], - ["language", apiLang], - ]) - - const cacheClient = await getCacheClient() - return cacheClient.cacheOrGet( - `${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, - async () => { - /** - * Since API expects the params appended and not just - * a comma separated string we need to initialize the - * SearchParams with a sequence of pairs - * (hotelIds=810&hotelIds=879&hotelIds=222 etc.) - **/ - - hotelIds.forEach((hotelId) => - params.append("hotelIds", hotelId.toString()) - ) - metrics.hotelsByHotelIdAvailability.counter.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - }) - console.info( - "api.hotels.hotelsByHotelIdAvailability start", - JSON.stringify({ query: { params } }) - ) - const apiResponse = await api.get( - api.endpoints.v1.Availability.hotels(), - { - headers: { - Authorization: `Bearer ${serviceToken}`, - }, - }, - params - ) - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.hotelsByHotelIdAvailability.fail.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }), - }) - console.error( - "api.hotels.hotelsByHotelIdAvailability error", - JSON.stringify({ - query: { params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - - throw new Error("Failed to fetch hotels availability by hotelIds") - } - const apiJson = await apiResponse.json() - const validateAvailabilityData = - hotelsAvailabilitySchema.safeParse(apiJson) - if (!validateAvailabilityData.success) { - metrics.hotelsByHotelIdAvailability.fail.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "validation_error", - error: JSON.stringify(validateAvailabilityData.error), - }) - console.error( - "api.hotels.hotelsByHotelIdAvailability validation error", - JSON.stringify({ - query: { params }, - error: validateAvailabilityData.error, - }) - ) - throw badRequestError() - } - metrics.hotelsByHotelIdAvailability.success.add(1, { - hotelIds, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - }) - console.info( - "api.hotels.hotelsByHotelIdAvailability success", - JSON.stringify({ - query: { params }, - }) - ) - return { - availability: validateAvailabilityData.data.data.flatMap( - (hotels) => hotels.attributes - ), - } - }, - env.CACHE_TIME_CITY_SEARCH - ) -} - -async function getRoomFeatures( - { - hotelId, - startDate, - endDate, - adults, - childrenInRoom, - roomFeatureCodes, - }: RoomFeaturesInput, - token: string -) { - const params = { - hotelId, - roomStayStartDate: startDate, - roomStayEndDate: endDate, - adults, - ...(childrenInRoom?.length && { - children: generateChildrenString(childrenInRoom), - }), - roomFeatureCode: roomFeatureCodes, - } - - metrics.roomFeatures.counter.add(1, params) - - const apiResponse = await api.get( - api.endpoints.v1.Availability.roomFeatures(hotelId), - { - headers: { - Authorization: `Bearer ${token}`, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = apiResponse.text() - console.error( - "api.availability.roomfeature error", - JSON.stringify({ - query: { hotelId, params }, - error: { - status: apiResponse.status, - statusText: apiResponse.statusText, - text, - }, - }) - ) - metrics.roomFeatures.fail.add(1, params) - return null - } - - const data = await apiResponse.json() - const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data) - if (!validatedRoomFeaturesData.success) { - console.error( - "api.availability.roomfeature error", - JSON.stringify({ - query: { hotelId, params }, - error: validatedRoomFeaturesData.error, - }) - ) - return null - } - - metrics.roomFeatures.success.add(1, params) - - return validatedRoomFeaturesData.data -} - export const hotelQueryRouter = router({ availability: router({ hotelsByCity: safeProtectedServiceProcedure @@ -615,10 +145,10 @@ export const hotelQueryRouter = router({ return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken) }), - roomsCombinedAvailability: safeProtectedServiceProcedure - .input(roomsCombinedAvailabilityInputSchema) + enterDetails: safeProtectedServiceProcedure + .input(enterDetailsRoomsAvailabilityInputSchema) .use(async ({ ctx, input, next }) => { - if (input.redemption) { + if (input.booking.searchType === REDEMPTION) { if (ctx.session?.token.access_token) { const verifiedUser = await getVerifiedUser({ session: ctx.session }) if (!verifiedUser?.error) { @@ -627,7 +157,6 @@ export const hotelQueryRouter = router({ token: ctx.session.token.access_token, userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, }, - input, }) } } @@ -637,389 +166,265 @@ export const hotelQueryRouter = router({ ctx: { token: ctx.serviceToken, }, - input, }) }) - .query( - async ({ - ctx, - input: { - adultsCount, - bookingCode, - childArray, - hotelId, - lang, - rateCode, - redemption, - roomStayEndDate, - roomStayStartDate, - roomFeatureCodesArray, - }, - }) => { - const apiLang = toApiLang(lang) - - const metricsData = { - hotelId, - roomStayStartDate, - roomStayEndDate, - adultsCount, - childArray: childArray ? JSON.stringify(childArray) : undefined, - bookingCode, - } - - metrics.roomsCombinedAvailability.counter.add(1, metricsData) - - console.info( - "api.hotels.roomsCombinedAvailability start", - JSON.stringify({ query: { hotelId, params: metricsData } }) - ) - - const availabilityResponses = await Promise.allSettled( - adultsCount.map(async (adultCount: number, idx: number) => { - const kids = childArray?.[idx] - const params: Record = { - roomStayStartDate, - roomStayEndDate, - adults: adultCount, - ...(kids?.length && { - children: generateChildrenString(kids), - }), - ...(bookingCode && { bookingCode }), - language: apiLang, - ...(redemption ? { isRedemption: "true" } : {}), - } - - const apiResponse = await api.get( - api.endpoints.v1.Availability.hotel(hotelId.toString()), - { - headers: { - Authorization: `Bearer ${ctx.token}`, - }, - }, - params - ) - - if (!apiResponse.ok) { - const text = await apiResponse.text() - metrics.roomsCombinedAvailability.fail.add(1, metricsData) - console.error("Failed API call", { params, text }) - return { error: "http_error", details: text } - } - - const apiJson = await apiResponse.json() - const validateAvailabilityData = - roomsAvailabilitySchema.safeParse(apiJson) - if (!validateAvailabilityData.success) { - console.error("Validation error", { - params, - error: validateAvailabilityData.error, - }) - metrics.roomsCombinedAvailability.fail.add(1, metricsData) - return { - error: "validation_error", - details: validateAvailabilityData.error, - } - } - - const roomFeatureCodes = roomFeatureCodesArray?.[idx] - if (roomFeatureCodes?.length) { - const roomFeaturesResponse = await getRoomFeatures( - { - hotelId, - startDate: roomStayStartDate, - endDate: roomStayEndDate, - adults: adultCount, - childrenInRoom: kids ?? undefined, - roomFeatureCodes, - }, - ctx.serviceToken - ) - - if (roomFeaturesResponse) { - validateAvailabilityData.data.roomConfigurations.forEach( - (room) => { - const features = roomFeaturesResponse.find( - (feat) => feat.roomTypeCode === room.roomTypeCode - )?.features - - if (features) { - room.features = features - } - } - ) - } - } - - if (rateCode) { - validateAvailabilityData.data.mustBeGuaranteed = - validateAvailabilityData.data.rateDefinitions.find( - (rate) => rate.rateCode === rateCode - )?.mustBeGuaranteed - } - - if (redemption) { - validateAvailabilityData.data.roomConfigurations.forEach( - (data) => { - data.redemptions?.forEach((r) => { - r.redemption.hasEnoughPoints = - ctx.userPoints >= r.redemption.localPrice.pointsPerStay - }) - } - ) - } - - return validateAvailabilityData.data - }) - ) - metrics.roomsCombinedAvailability.success.add(1, metricsData) - - const data = availabilityResponses.map((availability) => { - if (availability.status === "fulfilled") { - return availability.value - } - return { - details: availability.reason, - error: "request_failure", - } - }) - - return data - } - ), - room: safeProtectedServiceProcedure - .input(selectedRoomAvailabilityInputSchema) - .use(async ({ ctx, input, next }) => { - if (input.redemption) { - if (ctx.session?.token.access_token) { - const verifiedUser = await getVerifiedUser({ session: ctx.session }) - if (!verifiedUser?.error) { - return next({ - ctx: { - token: ctx.session.token.access_token, - userPoints: verifiedUser?.data.membership?.currentPoints, - }, - input, - }) - } - } - throw unauthorizedError() - } - return next({ - ctx: { - token: ctx.serviceToken, - }, + .query(async function ({ ctx, input }) { + const availability = await getRoomsAvailability( input, - }) - }) - .query(async ({ input, ctx }) => { - const lang = toApiLang(input.lang || ctx.lang) - let selectedRoomData = await getSelectedRoomAvailability( - input, - lang, ctx.token, + ctx.serviceToken, ctx.userPoints ) - const { - adults, - bookingCode, - children, - counterRateCode, - hotelId, - roomStayEndDate, - roomStayStartDate, - roomTypeCode, - } = input - - if (!selectedRoomData) { - // There is no way to differentiate if a rateCode - // selected is a bookingCode rateCode or just a - // regular rateCode, hence we need to make a second - // request without the bookingCode if no availability - // is found - if (bookingCode) { - metrics.selectedRoomAvailability.fail.add(1, { - hotelId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - roomTypeCode, - error_type: "not_found", - error: `Couldn't find selected room with input: ${roomTypeCode}`, - }) - console.error( - "No matching room found when making the request with bookingCode, attempting without" - ) - - metrics.selectedRoomAvailability.counter.add(1, { - adults, - children, - hotelId, - roomStayEndDate, - roomStayStartDate, - }) - - const { bookingCode: extractedBookingCode, ...regularRatesInput } = - input - selectedRoomData = await getSelectedRoomAvailability( - regularRatesInput, - toApiLang(ctx.lang), - ctx.token - ) - - if (!selectedRoomData) { - metrics.selectedRoomAvailability.fail.add(1, { - adults, - children, - hotelId, - roomStayEndDate, - roomStayStartDate, - roomTypeCode, - error_type: "not_found", - error: `Couldn't find selected room with input: ${roomTypeCode}`, - }) - console.error("No matching room found even without bookingCode") - return null - } - } else { - metrics.selectedRoomAvailability.fail.add(1, { - hotelId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - roomTypeCode, - error_type: "not_found", - error: `Couldn't find selected room with input: ${roomTypeCode}`, - }) - console.error("No matching room found") - return null - } - } - const hotelData = await getHotel( { - hotelId, + hotelId: input.booking.hotelId, isCardOnlyPayment: false, language: input.lang || ctx.lang, }, ctx.serviceToken ) - const { - product, - rateDefinition, - rateDefinitions, - rooms, - selectedRoom, - } = selectedRoomData + const selectedRooms = [] + for (const [idx, room] of availability.entries()) { + if (!room || "error" in room) { + console.info(`Availability failed: ${room.error}`) + console.error(room.details) + selectedRooms.push(null) + continue + } + const bookingRoom = input.booking.rooms[idx] + const selected = getSelectedRoomAvailability( + bookingRoom.rateCode, + room.rateDefinitions, + room.roomConfigurations, + bookingRoom.roomTypeCode, + ctx.userPoints + ) + if (!selected) { + console.error("Unable to find selected room") + selectedRooms.push(null) + continue + } - const availableRoomsInCategory = rooms.filter( - (room) => room.roomType === selectedRoom?.roomType + const { + rateDefinition, + rateDefinitions, + product, + rooms, + selectedRoom, + } = selected + + const bedTypes = getBedTypes( + rooms, + selectedRoom.roomType, + hotelData?.roomCategories + ) + + const counterRateCode = input.booking.rooms[idx].counterRateCode + let memberRateDefinition = undefined + if ("member" in product && product.member && counterRateCode) { + memberRateDefinition = rateDefinitions.find( + (rate) => rate.rateCode === counterRateCode && rate.isMemberRate + ) + } + + const selectedPackages = input.booking.rooms[idx].packages + selectedRooms.push({ + bedTypes, + breakfastIncluded: rateDefinition.breakfastIncluded, + cancellationText: rateDefinition.cancellationText, + isAvailable: selectedRoom.status === AvailabilityEnum.Available, + isFlexRate: product.rate === RateEnum.flex, + memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed, + mustBeGuaranteed: rateDefinition.mustBeGuaranteed, + packages: room.packages.filter((pkg) => + selectedPackages?.includes(pkg.code) + ), + rate: product.rate, + rateDefinitionTitle: rateDefinition.title, + rateDetails: rateDefinition.generalTerms, + // Send rate Title when it is a booking code rate + rateTitle: + rateDefinition.rateType !== RateTypeEnum.Regular + ? rateDefinition.title + : undefined, + rateType: rateDefinition.rateType, + roomRate: product, + roomType: selectedRoom.roomType, + roomTypeCode: selectedRoom.roomTypeCode, + }) + } + + return selectedRooms + }), + myStay: safeProtectedServiceProcedure + .input(myStayRoomAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ session: ctx.session }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + const [availability] = await getRoomsAvailability( + { + booking: { + ...input.booking, + rooms: [input.booking.room], + }, + lang: input.lang, + }, + ctx.token, + ctx.serviceToken, + ctx.userPoints ) - if (!product) { - metrics.selectedRoomAvailability.fail.add(1, { - hotelId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - error_type: "not_found", - error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`, - }) - console.error("No matching rate found") + if (!availability || "error" in availability) { return null } - let memberRateDefinition = undefined - if ("member" in product) { - memberRateDefinition = rateDefinitions.find( - (rate) => rate.rateCode === counterRateCode - ) - } - - const bedTypes = availableRoomsInCategory - .map((availRoom) => { - const matchingRoom = hotelData?.roomCategories - ?.find((room) => - room.roomTypes - .map((roomType) => roomType.code) - .includes(availRoom.roomTypeCode) - ) - ?.roomTypes.find( - (roomType) => roomType.code === availRoom.roomTypeCode - ) - - if (matchingRoom) { - return { - description: matchingRoom.description, - size: matchingRoom.mainBed.widthRange, - value: matchingRoom.code, - type: matchingRoom.mainBed.type, - extraBed: matchingRoom.fixedExtraBed - ? { - type: matchingRoom.fixedExtraBed.type, - description: matchingRoom.fixedExtraBed.description, - } - : undefined, - } - } - }) - .filter((bed): bed is BedTypeSelection => Boolean(bed)) - - metrics.selectedRoomAvailability.success.add(1, { - hotelId, - roomStayStartDate, - roomStayEndDate, - adults, - children, - bookingCode, - }) - console.info( - "api.hotels.selectedRoomAvailability success", - JSON.stringify({ - query: { - hotelId, - params: { - roomStayStartDate, - roomStayEndDate, - adults, - ...(children && { children }), - ...(bookingCode && { bookingCode }), - language: lang, - }, - }, - }) + const bookingRoom = input.booking.room + const selected = getSelectedRoomAvailability( + bookingRoom.rateCode, + availability.rateDefinitions, + availability.roomConfigurations, + bookingRoom.roomTypeCode, + ctx.userPoints ) + if (!selected) { + console.error("Unable to find selected room") + return null + } + return { - bedTypes, - breakfastIncluded: !!rateDefinition?.breakfastIncluded, - cancellationRule: rateDefinition?.cancellationRule, - cancellationText: rateDefinition?.cancellationText ?? "", - isFlexRate: - rateDefinition?.cancellationRule === - CancellationRuleEnum.CancellableBefore6PM, - memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed, - mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed, - product, - rate: product.rate, - rateDefinitionTitle: rateDefinition?.title ?? "", - rateDetails: rateDefinition?.generalTerms, - // Send rate Title when it is a booking code rate - rateTitle: - rateDefinition?.rateType !== RateTypeEnum.Regular - ? rateDefinition?.title - : undefined, - rateType: rateDefinition?.rateType ?? "", - selectedRoom, + product: selected.product, + selectedRoom: selected.selectedRoom, } }), + selectRate: router({ + room: safeProtectedServiceProcedure + .input(selectRateRoomAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ + session: ctx.session, + }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: + verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + const [availability] = await getRoomsAvailability( + { + booking: { + ...input.booking, + rooms: [input.booking.room], + }, + lang: input.lang, + }, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + + if (!availability || "error" in availability) { + return null + } + + const roomConfigurations = mergeRoomTypes( + availability.roomConfigurations + ) + + return { + ...availability, + roomConfigurations, + } + }), + + rooms: safeProtectedServiceProcedure + .input(selectRateRoomsAvailabilityInputSchema) + .use(async ({ ctx, input, next }) => { + if (input.booking.searchType === REDEMPTION) { + if (ctx.session?.token.access_token) { + const verifiedUser = await getVerifiedUser({ + session: ctx.session, + }) + if (!verifiedUser?.error) { + return next({ + ctx: { + token: ctx.session.token.access_token, + userPoints: + verifiedUser?.data.membership?.currentPoints ?? 0, + }, + }) + } + } + throw unauthorizedError() + } + return next({ + ctx: { + token: ctx.serviceToken, + }, + }) + }) + .query(async function ({ ctx, input }) { + input.booking.rooms = input.booking.rooms.map((room) => ({ + ...room, + bookingCode: room.rateCode + ? room.bookingCode + : input.booking.bookingCode, + })) + const availability = await getRoomsAvailability( + input, + ctx.token, + ctx.serviceToken, + ctx.userPoints + ) + + for (const room of availability) { + if (!room || "error" in room) { + continue + } + + room.roomConfigurations = mergeRoomTypes(room.roomConfigurations) + } + + return availability + }), + }), + hotelsByCityWithBookingCode: serviceProcedure .input(hotelsAvailabilityInputSchema) .query(async ({ input, ctx }) => { @@ -1086,42 +491,6 @@ export const hotelQueryRouter = router({ .concat(unavailableHotels.availability), } }), - roomFeatures: serviceProcedure - .input(roomFeaturesInputSchema) - .query(async ({ input, ctx }) => { - return await getRoomFeatures(input, ctx.serviceToken) - }), - }), - rates: router({ - get: publicProcedure.input(ratesInputSchema).query(async () => { - // TODO: Do a real API call when the endpoint is ready - // const { hotelId } = input - - // const params = new URLSearchParams() - // const apiLang = toApiLang(language) - // params.set("hotelId", hotelId.toString()) - // params.set("language", apiLang) - - console.info("api.hotels.rates start", JSON.stringify({})) - const validatedHotelData = ratesSchema.safeParse(tempRatesData) - - if (!tempRatesData) { - console.error("api.hotels.rates error", JSON.stringify({ error: null })) - //Can't return null here since consuming component does not handle null yet - // return null - } - if (!validatedHotelData.success) { - console.error( - "api.hotels.rates validation error", - JSON.stringify({ - error: validatedHotelData.error, - }) - ) - throw badRequestError() - } - console.info("api.hotels.rates success", JSON.stringify({})) - return validatedHotelData.data - }), }), get: serviceProcedure .input(hotelInputSchema) @@ -1782,90 +1151,8 @@ export const hotelQueryRouter = router({ packages: router({ get: serviceProcedure .input(roomPackagesInputSchema) - .query(async ({ input, ctx }) => { - const { hotelId, startDate, endDate, adults, children, packageCodes } = - input - - const { lang } = input - - const apiLang = toApiLang(lang) - - const searchParams = new URLSearchParams({ - startDate, - endDate, - adults: adults.toString(), - children: children.toString(), - language: apiLang, - }) - - packageCodes.forEach((code) => { - searchParams.append("packageCodes", code) - }) - - const params = searchParams.toString() - - metrics.packages.counter.add(1, { - hotelId, - }) - console.info( - "api.hotels.packages start", - JSON.stringify({ query: { hotelId, params } }) - ) - - const apiResponse = await api.get( - api.endpoints.v1.Package.Packages.hotel(hotelId), - { - headers: { - Authorization: `Bearer ${ctx.serviceToken}`, - }, - }, - searchParams - ) - - if (!apiResponse.ok) { - metrics.packages.fail.add(1, { - hotelId, - error_type: "http_error", - error: JSON.stringify({ - status: apiResponse.status, - statusText: apiResponse.statusText, - }), - }) - console.error( - "api.hotels.packages error", - JSON.stringify({ query: { hotelId, params } }) - ) - return null - } - - const apiJson = await apiResponse.json() - const validatedPackagesData = packagesSchema.safeParse(apiJson) - if (!validatedPackagesData.success) { - metrics.packages.fail.add(1, { - hotelId, - error_type: "validation_error", - error: JSON.stringify(validatedPackagesData.error), - }) - - console.error( - "api.hotels.packages validation error", - JSON.stringify({ - query: { hotelId, params }, - error: validatedPackagesData.error, - }) - ) - return null - } - - metrics.packages.success.add(1, { - hotelId, - }) - console.info( - "api.hotels.packages success", - JSON.stringify({ query: { hotelId, params: params } }) - ) - - return validatedPackagesData.data + .query(async ({ ctx, input }) => { + return getPackages(input, ctx.serviceToken) }), breakfast: safeProtectedServiceProcedure .input(breakfastPackageInputSchema) diff --git a/apps/scandic-web/server/routers/hotels/schemas/rate.ts b/apps/scandic-web/server/routers/hotels/schemas/rate.ts deleted file mode 100644 index f82345261..000000000 --- a/apps/scandic-web/server/routers/hotels/schemas/rate.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod" - -const flexibilityPrice = z.object({ - member: z.number(), - standard: z.number(), -}) - -export const rateSchema = z.object({ - breakfastIncluded: z.boolean(), - description: z.string(), - id: z.number(), - imageSrc: z.string(), - name: z.string(), - prices: z.object({ - currency: z.string(), - freeCancellation: flexibilityPrice, - freeRebooking: flexibilityPrice, - nonRefundable: flexibilityPrice, - }), - size: z.string(), -}) diff --git a/apps/scandic-web/server/routers/hotels/tempRatesData.json b/apps/scandic-web/server/routers/hotels/tempRatesData.json deleted file mode 100644 index 4cdf68cdd..000000000 --- a/apps/scandic-web/server/routers/hotels/tempRatesData.json +++ /dev/null @@ -1,104 +0,0 @@ -[ - { - "id": 1, - "name": "Cabin", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "17 - 24 m² (1 - 2 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - }, - { - "id": 2, - "name": "Standard", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "19 - 30 m² (1 - 2 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - }, - { - "id": 3, - "name": "Superior", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "22 - 40 m² (1 - 3 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - }, - { - "id": 4, - "name": "Superior Family", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "29 - 49 m² (3 - 4 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - }, - { - "id": 5, - "name": "Superior PLUS", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "21 - 28 m² (2 - 3 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - }, - { - "id": 6, - "name": "Junior Suite", - "description": "Stylish, peaceful and air-conditioned room. The rooms have small clerestory windows.", - "size": "35 - 43 m² (2 - 4 persons)", - "imageSrc": "https://www.scandichotels.se/imageVault/publishedmedia/xnmqnmz6mz0uhuat0917/scandic-helsinki-hub-room-standard-KR-7.jpg", - "breakfastIncluded": false, - "prices": { - "currency": "SEK", - "nonRefundable": { - "standard": 2315, - "member": 2247 - }, - "freeRebooking": { "standard": 2437, "member": 2365 }, - "freeCancellation": { "standard": 2620, "member": 2542 } - } - } -] diff --git a/apps/scandic-web/server/routers/hotels/utils.ts b/apps/scandic-web/server/routers/hotels/utils.ts index 49fb482cd..a1e04b75c 100644 --- a/apps/scandic-web/server/routers/hotels/utils.ts +++ b/apps/scandic-web/server/routers/hotels/utils.ts @@ -1,14 +1,19 @@ import deepmerge from "deepmerge" +import stringify from "json-stable-stringify-without-jsonify" +import { REDEMPTION } from "@/constants/booking" import { Lang } from "@/constants/languages" import { env } from "@/env/server" import * as api from "@/lib/api" import { badRequestError } from "@/server/errors/trpc" import { toApiLang } from "@/server/utils" +import { generateChildrenString } from "@/components/HotelReservation/utils" import { getCacheClient } from "@/services/dataCache" +import { cache } from "@/utils/cache" import { getHotelPageUrls } from "../contentstack/hotelPage/utils" +import { type RoomFeaturesInput, roomPackagesInputSchema } from "./input" import { metrics } from "./metrics" import { type Cities, @@ -16,15 +21,31 @@ import { citiesSchema, countriesSchema, getHotelIdsSchema, + hotelsAvailabilitySchema, + hotelSchema, locationsSchema, + packagesSchema, + roomFeaturesSchema, roomsAvailabilitySchema, } from "./output" -import { getHotel } from "./query" - -import type { z } from "zod" +import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType" +import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel" +import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" +import { HotelTypeEnum } from "@/types/enums/hotelType" import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest" -import type { DestinationPagesHotelData } from "@/types/hotel" +import type { + DestinationPagesHotelData, + Room as RoomCategory, +} from "@/types/hotel" +import type { PackagesInput } from "@/types/requests/packages" +import type { + HotelsAvailabilityInputSchema, + HotelsByHotelIdsAvailabilityInputSchema, + RoomsAvailabilityInputRoom, + RoomsAvailabilityInputSchema, +} from "@/types/trpc/routers/hotel/availability" +import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { CitiesGroupedByCountry, CityLocation, @@ -34,9 +55,9 @@ import type { Products, RateDefinition, RedemptionsProduct, + RoomConfiguration, } from "@/types/trpc/routers/hotel/roomAvailability" import type { Endpoint } from "@/lib/api/endpoints" -import type { selectedRoomAvailabilityInputSchema } from "./input" export function getPoiGroupByCategoryName(category: string | undefined) { if (!category) return PointOfInterestGroupEnum.LOCATION @@ -608,51 +629,184 @@ function findProduct(product: Products, rateDefinition: RateDefinition) { } } -export async function getSelectedRoomAvailability( - input: z.input, - lang: string, - serviceToken: string, - userPoints?: number +export const getHotel = cache( + async (input: HotelInput, serviceToken: string) => { + const callable = async function ( + hotelId: HotelInput["hotelId"], + language: HotelInput["language"], + isCardOnlyPayment?: HotelInput["isCardOnlyPayment"] + ) { + /** + * Since API expects the params appended and not just + * a comma separated string we need to initialize the + * SearchParams with a sequence of pairs + * (include=City&include=NearbyHotels&include=Restaurants etc.) + **/ + const params = new URLSearchParams([ + ["include", "AdditionalData"], + ["include", "City"], + ["include", "NearbyHotels"], + ["include", "Restaurants"], + ["include", "RoomCategories"], + ["language", toApiLang(language)], + ]) + metrics.hotel.counter.add(1, { + hotelId, + language, + }) + console.info( + "api.hotels.hotelData start", + JSON.stringify({ query: { hotelId, params: params.toString() } }) + ) + + const apiResponse = await api.get( + api.endpoints.v1.Hotel.Hotels.hotel(hotelId), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotel.fail.add(1, { + hotelId, + language, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.hotelData error", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + return null + } + + const apiJson = await apiResponse.json() + const validateHotelData = hotelSchema.safeParse(apiJson) + + if (!validateHotelData.success) { + metrics.hotel.fail.add(1, { + hotelId, + language, + error_type: "validation_error", + error: JSON.stringify(validateHotelData.error), + }) + + console.error( + "api.hotels.hotelData validation error", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + error: validateHotelData.error, + }) + ) + throw badRequestError() + } + + metrics.hotel.success.add(1, { + hotelId, + language, + }) + console.info( + "api.hotels.hotelData success", + JSON.stringify({ + query: { hotelId, params: params.toString() }, + }) + ) + const hotelData = validateHotelData.data + + if (isCardOnlyPayment) { + hotelData.hotel.merchantInformationData.alternatePaymentOptions = [] + } + + const gallery = hotelData.additionalData?.gallery + if (gallery) { + const smallerImages = gallery.smallerImages + const hotelGalleryImages = + hotelData.hotel.hotelType === HotelTypeEnum.Signature + ? smallerImages.slice(0, 10) + : smallerImages.slice(0, 6) + hotelData.hotel.galleryImages = hotelGalleryImages + } + + return hotelData + } + + const cacheClient = await getCacheClient() + return await cacheClient.cacheOrGet( + `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`, + async () => { + return callable(input.hotelId, input.language, input.isCardOnlyPayment) + }, + env.CACHE_TIME_HOTELS + ) + } +) + +export async function getHotelsAvailabilityByCity( + input: HotelsAvailabilityInputSchema, + apiLang: string, + token: string, // Either service token or user access token in case of redemption search + userPoints: number = 0 ) { const { - adults, - bookingCode, - children, - hotelId, - roomStayEndDate, + cityId, roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, redemption, } = input - const params: Record = { + const params: Record = { roomStayStartDate, roomStayEndDate, adults, ...(children && { children }), ...(bookingCode && { bookingCode }), - ...(redemption && { isRedemption: "true" }), - language: lang, + ...(redemption ? { isRedemption: "true" } : {}), + language: apiLang, } - - metrics.selectedRoomAvailability.counter.add(1, input) + metrics.hotelsAvailability.counter.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + }) console.info( - "api.hotels.selectedRoomAvailability start", - JSON.stringify({ query: { hotelId: input.hotelId, params } }) + "api.hotels.hotelsAvailability start", + JSON.stringify({ query: { cityId, params } }) ) - const apiResponseAvailability = await api.get( - api.endpoints.v1.Availability.hotel(hotelId.toString()), + const apiResponse = await api.get( + api.endpoints.v1.Availability.city(cityId), { headers: { - Authorization: `Bearer ${serviceToken}`, + Authorization: `Bearer ${token}`, }, }, params ) - - if (!apiResponseAvailability.ok) { - const text = await apiResponseAvailability.text() - metrics.selectedRoomAvailability.fail.add(1, { - hotelId, + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotelsAvailability.fail.add(1, { + cityId, roomStayStartDate, roomStayEndDate, adults, @@ -660,61 +814,611 @@ export async function getSelectedRoomAvailability( bookingCode, error_type: "http_error", error: JSON.stringify({ - status: apiResponseAvailability.status, - statusText: apiResponseAvailability.statusText, + status: apiResponse.status, + statusText: apiResponse.statusText, text, }), }) console.error( - "api.hotels.selectedRoomAvailability error", + "api.hotels.hotelsAvailability error", JSON.stringify({ - query: { hotelId, params }, + query: { cityId, params }, error: { - status: apiResponseAvailability.status, - statusText: apiResponseAvailability.statusText, + status: apiResponse.status, + statusText: apiResponse.statusText, text, }, }) ) - throw new Error("Failed to fetch selected room availability") + throw new Error("Failed to fetch hotels availability by city") } - const apiJsonAvailability = await apiResponseAvailability.json() - const validateAvailabilityData = - roomsAvailabilitySchema.safeParse(apiJsonAvailability) + + const apiJson = await apiResponse.json() + const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson) if (!validateAvailabilityData.success) { - metrics.selectedRoomAvailability.fail.add(1, { - hotelId, + metrics.hotelsAvailability.fail.add(1, { + cityId, roomStayStartDate, roomStayEndDate, adults, children, bookingCode, + redemption, error_type: "validation_error", error: JSON.stringify(validateAvailabilityData.error), }) console.error( - "api.hotels.selectedRoomAvailability validation error", + "api.hotels.hotelsAvailability validation error", JSON.stringify({ - query: { hotelId, params }, + query: { cityId, params }, error: validateAvailabilityData.error, }) ) throw badRequestError() } - - const { rateDefinitions, roomConfigurations } = validateAvailabilityData.data - - const rateDefinition = rateDefinitions.find( - (rd) => rd.rateCode === input.rateCode + metrics.hotelsAvailability.success.add(1, { + cityId, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + redemption, + }) + console.info( + "api.hotels.hotelsAvailability success", + JSON.stringify({ + query: { cityId, params: params }, + }) ) + if (redemption) { + validateAvailabilityData.data.data.forEach((data) => { + data.attributes.productType?.redemptions?.forEach((r) => { + r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay + }) + }) + } + + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } +} + +export async function getHotelsAvailabilityByHotelIds( + input: HotelsByHotelIdsAvailabilityInputSchema, + apiLang: string, + serviceToken: string +) { + const { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + } = input + + const params = new URLSearchParams([ + ["roomStayStartDate", roomStayStartDate], + ["roomStayEndDate", roomStayEndDate], + ["adults", adults.toString()], + ["children", children ?? ""], + ["bookingCode", bookingCode], + ["language", apiLang], + ]) + + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + `${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`, + async () => { + /** + * Since API expects the params appended and not just + * a comma separated string we need to initialize the + * SearchParams with a sequence of pairs + * (hotelIds=810&hotelIds=879&hotelIds=222 etc.) + **/ + + hotelIds.forEach((hotelId) => + params.append("hotelIds", hotelId.toString()) + ) + metrics.hotelsByHotelIdAvailability.counter.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + }) + console.info( + "api.hotels.hotelsByHotelIdAvailability start", + JSON.stringify({ query: { params } }) + ) + const apiResponse = await api.get( + api.endpoints.v1.Availability.hotels(), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + params + ) + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.hotelsByHotelIdAvailability.fail.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability error", + JSON.stringify({ + query: { params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + + throw new Error("Failed to fetch hotels availability by hotelIds") + } + const apiJson = await apiResponse.json() + const validateAvailabilityData = + hotelsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + metrics.hotelsByHotelIdAvailability.fail.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + error_type: "validation_error", + error: JSON.stringify(validateAvailabilityData.error), + }) + console.error( + "api.hotels.hotelsByHotelIdAvailability validation error", + JSON.stringify({ + query: { params }, + error: validateAvailabilityData.error, + }) + ) + throw badRequestError() + } + metrics.hotelsByHotelIdAvailability.success.add(1, { + hotelIds, + roomStayStartDate, + roomStayEndDate, + adults, + children, + bookingCode, + }) + console.info( + "api.hotels.hotelsByHotelIdAvailability success", + JSON.stringify({ + query: { params }, + }) + ) + return { + availability: validateAvailabilityData.data.data.flatMap( + (hotels) => hotels.attributes + ), + } + }, + env.CACHE_TIME_CITY_SEARCH + ) +} + +async function getRoomFeaturesInventory( + input: RoomFeaturesInput, + token: string +) { + const { + adults, + childrenInRoom, + endDate, + hotelId, + roomFeatureCodes, + startDate, + } = input + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + stringify(input), + async function () { + const params = { + adults, + hotelId, + roomFeatureCode: roomFeatureCodes, + roomStayEndDate: endDate, + roomStayStartDate: startDate, + ...(childrenInRoom?.length && { + children: generateChildrenString(childrenInRoom), + }), + } + + metrics.roomFeatures.counter.add(1, params) + + const apiResponse = await api.get( + api.endpoints.v1.Availability.roomFeatures(hotelId), + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + params + ) + + if (!apiResponse.ok) { + const text = apiResponse.text() + console.error( + "api.availability.roomfeature error", + JSON.stringify({ + query: { hotelId, params }, + error: { + status: apiResponse.status, + statusText: apiResponse.statusText, + text, + }, + }) + ) + metrics.roomFeatures.fail.add(1, params) + return null + } + + const data = await apiResponse.json() + const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data) + if (!validatedRoomFeaturesData.success) { + console.error( + "api.availability.roomfeature error", + JSON.stringify({ + query: { hotelId, params }, + error: validatedRoomFeaturesData.error, + }) + ) + return null + } + + metrics.roomFeatures.success.add(1, params) + + return validatedRoomFeaturesData.data + }, + "5m" + ) +} + +export async function getPackages( + rawInput: PackagesInput, + serviceToken: string +) { + const parsedInput = roomPackagesInputSchema.safeParse(rawInput) + if (!parsedInput.success) { + console.info(`Failed to parse input for Get Packages`) + console.error(parsedInput.error) + return null + } + const input = parsedInput.data + const cacheClient = await getCacheClient() + return cacheClient.cacheOrGet( + stringify(input), + async function () { + const { + adults, + children, + endDate, + hotelId, + lang, + packageCodes, + startDate, + } = input + const apiLang = toApiLang(lang) + + const searchParams = new URLSearchParams({ + adults: adults.toString(), + children: children.toString(), + endDate, + language: apiLang, + startDate, + }) + + packageCodes.forEach((code) => { + searchParams.append("packageCodes", code) + }) + + const params = searchParams.toString() + + metrics.packages.counter.add(1, { + hotelId, + }) + console.info( + "api.hotels.packages start", + JSON.stringify({ query: { hotelId, params } }) + ) + + const apiResponse = await api.get( + api.endpoints.v1.Package.Packages.hotel(hotelId), + { + headers: { + Authorization: `Bearer ${serviceToken}`, + }, + }, + searchParams + ) + + if (!apiResponse.ok) { + metrics.packages.fail.add(1, { + hotelId, + error_type: "http_error", + error: JSON.stringify({ + status: apiResponse.status, + statusText: apiResponse.statusText, + }), + }) + console.error( + "api.hotels.packages error", + JSON.stringify({ query: { hotelId, params } }) + ) + return null + } + + const apiJson = await apiResponse.json() + const validatedPackagesData = packagesSchema.safeParse(apiJson) + if (!validatedPackagesData.success) { + metrics.packages.fail.add(1, { + hotelId, + error_type: "validation_error", + error: JSON.stringify(validatedPackagesData.error), + }) + + console.error( + "api.hotels.packages validation error", + JSON.stringify({ + query: { hotelId, params }, + error: validatedPackagesData.error, + }) + ) + return null + } + + metrics.packages.success.add(1, { + hotelId, + }) + console.info( + "api.hotels.packages success", + JSON.stringify({ query: { hotelId, params: params } }) + ) + + return validatedPackagesData.data + }, + "3h" + ) +} + +export async function getRoomsAvailability( + input: RoomsAvailabilityInputSchema, + token: string, + serviceToken: string, + userPoints: number | undefined +) { + const { + booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate }, + lang, + } = input + + const redemption = searchType === REDEMPTION + + const apiLang = toApiLang(lang) + + const kids = rooms + .map((r) => r.childrenInRoom) + .filter(Boolean) + .map((kid) => JSON.stringify(kid)) + const metricsData = { + adultsCount: rooms.map((r) => r.adults), + bookingCode, + childArray: kids.length ? kids : undefined, + hotelId, + roomStayEndDate: toDate, + roomStayStartDate: fromDate, + } + + metrics.roomsAvailability.counter.add(1, metricsData) + + console.info( + "api.hotels.roomsAvailability start", + JSON.stringify({ query: { hotelId, params: metricsData } }) + ) + + const baseCacheKey = { + bookingCode, + fromDate, + hotelId, + lang, + searchType, + toDate, + } + + const cacheClient = await getCacheClient() + const availabilityResponses = await Promise.allSettled( + rooms.map(async (room: RoomsAvailabilityInputRoom) => { + const cacheKey = { + ...baseCacheKey, + room, + } + return await cacheClient.cacheOrGet( + stringify(cacheKey), + async function () { + { + const params = { + adults: room.adults, + language: apiLang, + roomStayStartDate: fromDate, + roomStayEndDate: toDate, + ...(room.childrenInRoom?.length && { + children: generateChildrenString(room.childrenInRoom), + }), + ...(room.bookingCode && { bookingCode: room.bookingCode }), + ...(redemption && { isRedemption: "true" }), + } + + const apiResponse = await api.get( + api.endpoints.v1.Availability.hotel(hotelId), + { + headers: { + Authorization: `Bearer ${token}`, + }, + next: { + revalidate: 60, + }, + }, + params + ) + + if (!apiResponse.ok) { + const text = await apiResponse.text() + metrics.roomsAvailability.fail.add(1, metricsData) + console.error("Failed API call", { params, text }) + return { error: "http_error", details: text } + } + + const apiJson = await apiResponse.json() + const validateAvailabilityData = + roomsAvailabilitySchema.safeParse(apiJson) + if (!validateAvailabilityData.success) { + console.error("Validation error", { + params, + error: validateAvailabilityData.error, + }) + metrics.roomsAvailability.fail.add(1, metricsData) + return { + error: "validation_error", + details: validateAvailabilityData.error, + } + } + + if (redemption) { + for (const roomConfig of validateAvailabilityData.data + .roomConfigurations) { + for (const product of roomConfig.redemptions) { + if (userPoints) { + product.redemption.hasEnoughPoints = + userPoints >= product.redemption.localPrice.pointsPerStay + } + } + } + } + + const roomFeatures = await getPackages( + { + adults: room.adults, + children: room.childrenInRoom?.length, + endDate: input.booking.toDate, + hotelId: input.booking.hotelId, + lang, + packageCodes: [ + RoomPackageCodeEnum.ACCESSIBILITY_ROOM, + RoomPackageCodeEnum.ALLERGY_ROOM, + RoomPackageCodeEnum.PET_ROOM, + ], + startDate: input.booking.fromDate, + }, + serviceToken + ) + + if (roomFeatures) { + validateAvailabilityData.data.packages = roomFeatures + } + + // Fetch packages + if (room.packages?.length) { + const roomFeaturesInventory = await getRoomFeaturesInventory( + { + adults: room.adults, + childrenInRoom: room.childrenInRoom, + endDate: input.booking.toDate, + hotelId: input.booking.hotelId, + lang, + roomFeatureCodes: room.packages, + startDate: input.booking.fromDate, + }, + serviceToken + ) + + if (roomFeaturesInventory) { + const features = roomFeaturesInventory.reduce< + Record + >((fts, feat) => { + fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0 + return fts + }, {}) + + const updatedRoomConfigurations = + validateAvailabilityData.data.roomConfigurations + // This filter is needed since we can get availability + // back from roomFeatures yet the availability call + // says there are no rooms left... + .filter((rc) => rc.roomsLeft) + .filter((rc) => features?.[rc.roomTypeCode]) + .map((rc) => ({ + ...rc, + roomsLeft: features[rc.roomTypeCode], + status: AvailabilityEnum.Available, + })) + + validateAvailabilityData.data.roomConfigurations = + updatedRoomConfigurations + } + } + + return validateAvailabilityData.data + } + }, + "1m" + ) + }) + ) + metrics.roomsAvailability.success.add(1, metricsData) + + const data = availabilityResponses.map((availability) => { + if (availability.status === "fulfilled") { + return availability.value + } + return { + details: availability.reason, + error: "request_failure", + } + }) + + return data +} + +export function getSelectedRoomAvailability( + rateCode: string, + rateDefinitions: RateDefinition[], + roomConfigurations: RoomConfiguration[], + roomTypeCode: string, + userPoints: number | undefined +) { + const rateDefinition = rateDefinitions.find((rd) => rd.rateCode === rateCode) if (!rateDefinition) { return null } const selectedRoom = roomConfigurations.find( (room) => - room.roomTypeCode === input.roomTypeCode && + room.roomTypeCode === roomTypeCode && room.products.find((product) => findProduct(product, rateDefinition)) ) @@ -753,3 +1457,90 @@ export async function getSelectedRoomAvailability( selectedRoom, } } + +// Used to ensure `Available` rooms +// are shown before all `NotAvailable` +const statusLookup = { + [AvailabilityEnum.Available]: 1, + [AvailabilityEnum.NotAvailable]: 2, +} + +export function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) { + // @ts-expect-error - array indexing + return statusLookup[a.status] - statusLookup[b.status] +} + +export function getBedTypes( + rooms: RoomConfiguration[], + roomType: string, + roomCategories?: RoomCategory[] +) { + if (!roomCategories) { + return [] + } + + return rooms + .filter((room) => room.roomType === roomType) + .map((availRoom) => { + const matchingRoom = roomCategories + ?.find((room) => + room.roomTypes + .map((roomType) => roomType.code) + .includes(availRoom.roomTypeCode) + ) + ?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode) + + if (matchingRoom) { + return { + description: matchingRoom.description, + size: matchingRoom.mainBed.widthRange, + value: matchingRoom.code, + type: matchingRoom.mainBed.type, + extraBed: matchingRoom.fixedExtraBed + ? { + type: matchingRoom.fixedExtraBed.type, + description: matchingRoom.fixedExtraBed.description, + } + : undefined, + } + } + }) + .filter((bed): bed is BedTypeSelection => Boolean(bed)) +} + +export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) { + // Initial sort to guarantee if one bed is NotAvailable and whereas + // the other is Available to make sure data is added to the correct + // roomConfig + roomConfigurations.sort(sortRoomConfigs) + + const roomConfigs = new Map() + for (const roomConfig of roomConfigurations) { + if (roomConfigs.has(roomConfig.roomType)) { + const currentRoomConf = roomConfigs.get(roomConfig.roomType) + if (currentRoomConf) { + currentRoomConf.features = roomConfig.features.reduce( + (feats, feature) => { + const currentFeatureIndex = feats.findIndex( + (f) => f.code === feature.code + ) + if (currentFeatureIndex !== -1) { + feats[currentFeatureIndex].inventory = + feats[currentFeatureIndex].inventory + feature.inventory + } else { + feats.push(feature) + } + return feats + }, + currentRoomConf.features + ) + currentRoomConf.roomsLeft = + currentRoomConf.roomsLeft + roomConfig.roomsLeft + roomConfigs.set(currentRoomConf.roomType, currentRoomConf) + } + } else { + roomConfigs.set(roomConfig.roomType, roomConfig) + } + } + return Array.from(roomConfigs.values()) +} diff --git a/apps/scandic-web/stores/select-rate/helpers.ts b/apps/scandic-web/stores/select-rate/helpers.ts index 1adf5ff19..9c597d28d 100644 --- a/apps/scandic-web/stores/select-rate/helpers.ts +++ b/apps/scandic-web/stores/select-rate/helpers.ts @@ -107,15 +107,13 @@ export function isRoomPackageCode( ) } -export function filterRoomsBySelectedPackages( - selectedPackages: RoomPackageCodeEnum[], - rooms: RoomConfiguration[] +export function clearRoomSelectionFromUrl( + roomIdx: number, + searchParams: URLSearchParams ) { - if (!selectedPackages.length) { - return rooms - } - - return rooms.filter((r) => - selectedPackages.every((pkg) => r.features.find((f) => f.code === pkg)) - ) + searchParams.delete(`room[${roomIdx}].bookingCode`) + searchParams.delete(`room[${roomIdx}].counterratecode`) + searchParams.delete(`room[${roomIdx}].ratecode`) + searchParams.delete(`room[${roomIdx}].roomtype`) + return searchParams } diff --git a/apps/scandic-web/stores/select-rate/index.ts b/apps/scandic-web/stores/select-rate/index.ts index 841989d02..92471664a 100644 --- a/apps/scandic-web/stores/select-rate/index.ts +++ b/apps/scandic-web/stores/select-rate/index.ts @@ -1,12 +1,13 @@ import { produce } from "immer" -import { ReadonlyURLSearchParams } from "next/navigation" import { useContext } from "react" import { create, useStore } from "zustand" +import { REDEMPTION } from "@/constants/booking" + import { RatesContext } from "@/contexts/Rates" import { - filterRoomsBySelectedPackages, + clearRoomSelectionFromUrl, findProductInRoom, findSelectedRate, } from "./helpers" @@ -14,18 +15,15 @@ import { import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter" import { RateTypeEnum } from "@/types/enums/rateType" +import type { Package, Packages } from "@/types/requests/packages" import type { InitialState, RatesState } from "@/types/stores/rates" -import type { - PriceProduct, - RoomConfiguration, -} from "@/types/trpc/routers/hotel/roomAvailability" +import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability" export function createRatesStore({ booking, hotelType, isUserLoggedIn, labels, - packages, pathname, roomCategories, roomsAvailability, @@ -36,34 +34,28 @@ export function createRatesStore({ { code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM, description: labels.accessibilityRoom, - itemCode: packages.find( - (pkg) => pkg.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM - )?.itemCode, }, { code: RoomPackageCodeEnum.ALLERGY_ROOM, description: labels.allergyRoom, - itemCode: packages.find( - (pkg) => pkg.code === RoomPackageCodeEnum.ALLERGY_ROOM - )?.itemCode, }, { code: RoomPackageCodeEnum.PET_ROOM, description: labels.petRoom, - itemCode: packages.find( - (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM - )?.itemCode, }, ] - let roomConfigurations: RatesState["roomConfigurations"] = [] + const roomsPackages: NonNullable[] = [] + const roomConfigurations: RatesState["roomConfigurations"] = [] if (roomsAvailability) { for (const availability of roomsAvailability) { if ("error" in availability) { // Availability request failed, default to empty array roomConfigurations.push([]) + roomsPackages.push([]) } else { roomConfigurations.push(availability.roomConfigurations) + roomsPackages.push(availability.packages) } } } @@ -72,21 +64,22 @@ export function createRatesStore({ for (const [idx, room] of booking.rooms.entries()) { if (room.rateCode && room.roomTypeCode) { const roomConfiguration = roomConfigurations?.[idx] - const selectedPackages = room.packages ?? [] - - let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages( - selectedPackages, - roomConfiguration - ) const selectedRoom = findSelectedRate( room.rateCode, room.counterRateCode, room.roomTypeCode, - rooms + roomConfiguration ) if (!selectedRoom) { + const updatedSearchParams = clearRoomSelectionFromUrl(idx, searchParams) + searchParams = updatedSearchParams + window.history.replaceState( + {}, + "", + `${pathname}?${updatedSearchParams}` + ) continue } @@ -99,7 +92,9 @@ export function createRatesStore({ rateSummary[idx] = { features: selectedRoom.features, product, - packages: room.packages ?? [], + packages: roomsPackages[idx].filter((pkg) => + room.packages?.includes(pkg.code) + ), rate: product.rate, roomType: selectedRoom.roomType, roomTypeCode: selectedRoom.roomTypeCode, @@ -126,22 +121,20 @@ export function createRatesStore({ booking, packageOptions, hotelType, + isRedemptionBooking: searchParams.has("searchType") + ? searchParams.get("searchType") === REDEMPTION + : false, isUserLoggedIn, - packages, pathname, - petRoomPackage: packages.find( - (pkg) => pkg.code === RoomPackageCodeEnum.PET_ROOM - ), rateSummary, roomConfigurations, rooms: booking.rooms.map((room, idx) => { const roomConfiguration = roomConfigurations[idx] - const selectedPackages = room.packages ?? [] - - let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages( - selectedPackages, - roomConfiguration - ) + const roomPackages = roomsPackages[idx] + const selectedPackages = + room.packages + ?.map((code) => roomPackages.find((pkg) => pkg.code === code)) + .filter((pkg): pkg is Package => Boolean(pkg)) ?? [] const selectedRate = findSelectedRate( @@ -159,91 +152,63 @@ export function createRatesStore({ room.counterRateCode ) } + let selectedFilter + const bookingCode = room.rateCode + ? room.bookingCode + : booking.bookingCode + if (bookingCode) { + selectedFilter = BookingCodeFilterEnum.Discounted + } else { + selectedFilter = BookingCodeFilterEnum.Regular + } return { actions: { appendRegularRates(roomConfigurations) { return set( produce((state: RatesState) => { - const rooms = state.rooms[idx].rooms - const updatedRooms = rooms.map((currentRoom) => { - const incomingRoom = roomConfigurations.find( - (room) => - room.roomType === currentRoom.roomType && - room.roomTypeCode === currentRoom.roomTypeCode - ) + state.rooms[idx].isFetchingAdditionalRate = false + if (roomConfigurations) { + const rooms = state.rooms[idx].rooms + const updatedRooms = rooms.map((currentRoom) => { + const incomingRoom = roomConfigurations.find( + (room) => + room.roomType === currentRoom.roomType && + room.roomTypeCode === currentRoom.roomTypeCode + ) - if (incomingRoom) { - let campaign = currentRoom.campaign - if (incomingRoom.campaign.length) { - const newCampaign = [ - ...campaign, - ...incomingRoom.campaign, - ].reduce((cpns, cpn) => { - if (cpns.has(cpn.rateDefinition.rateCode)) { + if (incomingRoom) { + let campaign = currentRoom.campaign + if (incomingRoom.campaign.length) { + const newCampaign = [ + ...campaign, + ...incomingRoom.campaign, + ].reduce((cpns, cpn) => { + if (cpns.has(cpn.rateDefinition.rateCode)) { + return cpns + } + cpns.set(cpn.rateDefinition.rateCode, cpn) return cpns - } - cpns.set(cpn.rateDefinition.rateCode, cpn) - return cpns - }, new Map()) - campaign = Array.from(newCampaign.values()) + }, new Map()) + campaign = Array.from(newCampaign.values()) + } + return { + ...currentRoom, + campaign, + products: [ + ...currentRoom.products, + ...incomingRoom.products, + ], + regular: incomingRoom.regular, + } } - return { - ...currentRoom, - campaign, - products: [ - ...currentRoom.products, - ...incomingRoom.products, - ], - regular: incomingRoom.regular, - } - } - return currentRoom - }) + return currentRoom + }) - state.rooms[idx].rooms = updatedRooms - }) - ) - }, - addRoomFeatures(roomFeatures) { - return set( - produce((state: RatesState) => { - const selectedPackages = state.rooms[idx].selectedPackages - const rateSummaryItem = state.rateSummary[idx] - - state.roomConfigurations[idx].forEach((room) => { - const features = roomFeatures.find( - (feat) => feat.roomTypeCode === room.roomTypeCode - )?.features - - if (features) { - room.features = features - - if (rateSummaryItem) { - rateSummaryItem.packages = selectedPackages - rateSummaryItem.features = features - } - } - }) - - state.rateSummary[idx] = rateSummaryItem - - state.rooms[idx].rooms = filterRoomsBySelectedPackages( - selectedPackages, - state.roomConfigurations[idx] - ) - - const selectedRate = findSelectedRate( - room.rateCode, - room.counterRateCode, - room.roomTypeCode, - state.rooms[idx].rooms - ) - - if (!selectedRate) { - state.rooms[idx].selectedRate = null - state.rateSummary[idx] = null + state.rooms[idx].rooms = updatedRooms + } else { + state.rooms[idx].rooms = [] } }) ) @@ -266,70 +231,75 @@ export function createRatesStore({ }) ) }, + removeSelectedPackage(code) { + return set( + produce((state: RatesState) => { + state.rooms[idx].isFetchingPackages = true + const filteredSelectedPackages = state.rooms[ + idx + ].selectedPackages.filter((c) => c.code !== code) + state.rooms[idx].selectedPackages = filteredSelectedPackages + + if ( + state.rooms[idx].selectedRate?.product.bookingCode || + state.booking.bookingCode + ) { + state.rooms[idx].selectedFilter = + BookingCodeFilterEnum.Discounted + } + + const searchParams = state.searchParams + if (filteredSelectedPackages.length) { + searchParams.set( + `room[${idx}].packages`, + filteredSelectedPackages.map((pkg) => pkg.code).join(",") + ) + } else { + searchParams.delete(`room[${idx}].packages`) + } + + state.searchParams = searchParams + + window.history.replaceState( + {}, + "", + `${state.pathname}?${searchParams}` + ) + }) + ) + }, + removeSelectedPackages() { + return set( + produce((state: RatesState) => { + state.rooms[idx].isFetchingPackages = true + state.rooms[idx].selectedPackages = [] + + if ( + state.rooms[idx].selectedRate?.product.bookingCode || + state.booking.bookingCode + ) { + state.rooms[idx].selectedFilter = + BookingCodeFilterEnum.Discounted + } + + const searchParams = state.searchParams + searchParams.delete(`room[${idx}].packages`) + + state.searchParams = searchParams + + window.history.replaceState( + {}, + "", + `${state.pathname}?${searchParams}` + ) + }) + ) + }, selectFilter(filter) { return set( produce((state: RatesState) => { state.rooms[idx].selectedFilter = filter - }) - ) - }, - togglePackages(selectedPackages) { - return set( - produce((state: RatesState) => { - state.rooms[idx].selectedPackages = selectedPackages - const rateSummaryItem = state.rateSummary[idx] - - const roomConfiguration = state.roomConfigurations[idx] - if (roomConfiguration) { - const searchParams = new URLSearchParams(state.searchParams) - if (selectedPackages.length) { - searchParams.set( - `room[${idx}].packages`, - selectedPackages.join(",") - ) - - if (rateSummaryItem) { - rateSummaryItem.packages = selectedPackages - } - } else { - state.rooms[idx].rooms = roomConfiguration - if (rateSummaryItem) { - rateSummaryItem.packages = [] - } - searchParams.delete(`room[${idx}].packages`) - } - - // If we already have the features data 'addRoomFeatures' wont run - // so we need to do additional filtering here if thats the case - const filteredRooms = filterRoomsBySelectedPackages( - selectedPackages, - state.roomConfigurations[idx] - ) - - if (filteredRooms.length) { - const selectedRate = findSelectedRate( - room.rateCode, - room.counterRateCode, - room.roomTypeCode, - state.rooms[idx].rooms - ) - - if (!selectedRate) { - state.rooms[idx].selectedRate = null - state.rateSummary[idx] = null - } - } - - state.searchParams = new ReadonlyURLSearchParams( - searchParams - ) - - window.history.pushState( - {}, - "", - `${state.pathname}?${searchParams}` - ) - } + state.rooms[idx].isFetchingAdditionalRate = true }) ) }, @@ -393,6 +363,19 @@ export function createRatesStore({ isMainRoom && hasMemberRate && isRegularRate + + state.rooms[idx].bookingRoom.rateCode = isMemberRate + ? memberRateCode + : productRateCode + if (!isMemberRate && hasMemberRate) { + state.rooms[idx].bookingRoom.counterRateCode = + memberRateCode + } + state.rooms[idx].bookingRoom.roomTypeCode = + selectedRate.roomTypeCode + state.rooms[idx].bookingRoom.bookingCode = + selectedRate.product.bookingCode + const searchParams = new URLSearchParams(state.searchParams) const counterratecode = isMemberRate ? productRateCode @@ -411,6 +394,17 @@ export function createRatesStore({ searchParams.set(`room[${idx}].ratecode`, rateCode) } + if (selectedRate.product.bookingCode) { + searchParams.set( + `room[${idx}].bookingCode`, + selectedRate.product.bookingCode + ) + } else { + if (searchParams.has(`room[${idx}].bookingCode`)) { + searchParams.delete(`room[${idx}].bookingCode`) + } + } + searchParams.set( `room[${idx}].roomtype`, selectedRate.roomTypeCode @@ -422,8 +416,9 @@ export function createRatesStore({ state.activeRoom = idx + 1 } - state.searchParams = new ReadonlyURLSearchParams(searchParams) - window.history.pushState( + state.searchParams = searchParams + + window.history.replaceState( {}, "", `${state.pathname}?${searchParams}` @@ -431,13 +426,105 @@ export function createRatesStore({ }) ) }, + selectPackages(selectedPackages) { + return set( + produce((state: RatesState) => { + state.rooms[idx].isFetchingPackages = true + const pkgs = state.roomsPackages[idx].filter((pkg) => + selectedPackages.includes(pkg.code) + ) + state.rooms[idx].selectedPackages = pkgs + + if ( + state.rooms[idx].selectedRate?.product.bookingCode || + state.booking.bookingCode + ) { + state.rooms[idx].selectedFilter = + BookingCodeFilterEnum.Discounted + } + + const searchParams = state.searchParams + if (selectedPackages.length) { + searchParams.set( + `room[${idx}].packages`, + selectedPackages.join(",") + ) + } else { + searchParams.delete(`room[${idx}].packages`) + } + + state.searchParams = searchParams + + window.history.replaceState( + {}, + "", + `${state.pathname}?${searchParams}` + ) + }) + ) + }, + updateRooms(rooms) { + return set( + produce((state: RatesState) => { + state.rooms[idx].isFetchingPackages = false + if (rooms) { + state.rooms[idx].rooms = rooms + const rateSummaryRoom = state.rateSummary[idx] + if (rateSummaryRoom) { + const room = state.rooms[idx].bookingRoom + const selectedRoom = findSelectedRate( + room.rateCode, + room.counterRateCode, + room.roomTypeCode, + rooms + ) + + if (selectedRoom) { + rateSummaryRoom.packages = + state.rooms[idx].selectedPackages + } else { + const searchParams = clearRoomSelectionFromUrl( + idx, + state.searchParams + ) + state.searchParams = searchParams + state.rateSummary[idx] = null + state.rooms[idx].selectedRate = null + + window.history.replaceState( + {}, + "", + `${pathname}?${searchParams}` + ) + } + } + } else { + state.rooms[idx].rooms = [] + if (state.rateSummary[idx]) { + const searchParams = clearRoomSelectionFromUrl( + idx, + state.searchParams + ) + state.searchParams = searchParams + state.rateSummary[idx] = null + state.rooms[idx].selectedRate = null + window.history.replaceState( + {}, + "", + `${pathname}?${searchParams}` + ) + } + } + }) + ) + }, }, bookingRoom: room, - rooms, - selectedFilter: booking.bookingCode - ? BookingCodeFilterEnum.Discounted - : BookingCodeFilterEnum.All, + isFetchingAdditionalRate: false, + isFetchingPackages: false, + rooms: roomConfiguration, + selectedFilter, selectedPackages, selectedRate: selectedRate && product @@ -452,6 +539,7 @@ export function createRatesStore({ } }), roomCategories, + roomsPackages, roomsAvailability, searchParams, vat, diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/flexibilityOption.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/flexibilityOption.ts deleted file mode 100644 index 18517ea26..000000000 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/flexibilityOption.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { z } from "zod" - -import type { - Product, - RoomConfiguration, -} from "@/types/trpc/routers/hotel/roomAvailability" -import type { - priceSchema, - productTypePriceSchema, -} from "@/server/routers/hotels/schemas/productTypePrice" -import type { RoomPackage } from "./roomFilter" - -export type ProductPrice = z.output -export type RoomPriceSchema = z.output - -export type FlexibilityOptionProps = { - features: RoomConfiguration["features"] - paymentTerm: string - petRoomPackage: RoomPackage | undefined - priceInformation?: Array - product: Product | undefined - roomType: RoomConfiguration["roomType"] - roomTypeCode: RoomConfiguration["roomTypeCode"] - title: string - rateName?: string // Obtained in case of booking code and redemption rates -} - -export interface FlexibilityOptionVoucherProps - extends Omit { - product: Product -} -export type FlexibilityOptionChequeProps = FlexibilityOptionVoucherProps - -export interface PriceListProps { - publicPrice: ProductPrice - memberPrice: ProductPrice - petRoomPackage?: RoomPackage - rateName?: string // Obtained in case of booking code and redemption rates -} diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/rates.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/rates.ts index aa7b200b4..63ebfe78d 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/rates.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/rates.ts @@ -1,4 +1,4 @@ -import type { Packages } from "@/types/requests/packages" +import type { Package } from "@/types/requests/packages" import type { Product, RoomConfiguration, @@ -12,5 +12,5 @@ export interface SharedRateCardProps extends Pick { handleSelectRate: (product: Product) => void nights: number - petRoomPackage: NonNullable[number] | undefined + petRoomPackage: Package | undefined } diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/roomFilter.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/roomFilter.ts index 9d82e138e..02c687391 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/roomFilter.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/roomFilter.ts @@ -11,7 +11,6 @@ export enum RoomPackageCodeEnum { export interface DefaultFilterOptions { code: RoomPackageCodeEnum description: string - itemCode: string | undefined } export type FilterValues = { diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/roomListItem.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/roomListItem.ts index 5ec8ff612..6db8d337f 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/roomListItem.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/roomListItem.ts @@ -1,8 +1,5 @@ -import type { z } from "zod" - +import type { Package } from "@/types/requests/packages" import type { RoomConfiguration } from "@/types/trpc/routers/hotel/roomAvailability" -import type { packagePriceSchema } from "@/server/routers/hotels/schemas/packages" -import type { RoomPriceSchema } from "./flexibilityOption" export type RoomListItemProps = { roomConfiguration: RoomConfiguration @@ -10,19 +7,9 @@ export type RoomListItemProps = { export type RoomListItemImageProps = Pick< RoomConfiguration, - "features" | "roomType" | "roomTypeCode" | "roomsLeft" -> - -type RoomPackagePriceSchema = z.output - -export type CalculatePricesPerNightProps = { - publicLocalPrice: RoomPriceSchema - memberLocalPrice: RoomPriceSchema - publicRequestedPrice: RoomPriceSchema | null - memberRequestedPrice: RoomPriceSchema | null - petRoomLocalPrice?: RoomPackagePriceSchema - petRoomRequestedPrice?: RoomPackagePriceSchema - nights: number + "roomType" | "roomTypeCode" | "roomsLeft" +> & { + roomPackages: Package[] } export interface RoomSizeProps { diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/roomsContainer.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/roomsContainer.ts index 4c005ecda..0e48f376f 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/roomsContainer.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/roomsContainer.ts @@ -1,14 +1,9 @@ import type { HotelData } from "@/types/hotel" -import type { ChildrenInRoom } from "@/utils/hotelSearchDetails" import type { SelectRateSearchParams } from "./selectRate" -export interface RoomsContainerProps { - adultArray: number[] +export interface RoomsContainerProps + extends Pick, + Pick { booking: SelectRateSearchParams - bookingCode?: string - childArray: ChildrenInRoom - fromDate: Date - hotelData: HotelData isUserLoggedIn: boolean - toDate: Date } diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/selectRate.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/selectRate.ts index 7b88e9fa8..4866a354f 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/selectRate.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/selectRate.ts @@ -1,10 +1,10 @@ import type { RateEnum } from "@/types/enums/rate" +import type { PackageEnum, Packages } from "@/types/requests/packages" import type { Product, RoomConfiguration, } from "@/types/trpc/routers/hotel/roomAvailability" import type { ChildBedMapEnum } from "../../bookingWidget/enums" -import type { RoomPackageCodeEnum } from "./roomFilter" export interface Child { bed: ChildBedMapEnum @@ -13,9 +13,10 @@ export interface Child { export interface Room { adults: number + bookingCode?: string childrenInRoom?: Child[] counterRateCode: string - packages?: RoomPackageCodeEnum[] + packages?: PackageEnum[] rateCode: string roomTypeCode: string } @@ -32,7 +33,7 @@ export interface SelectRateSearchParams { export type Rate = { features: RoomConfiguration["features"] - packages: RoomPackageCodeEnum[] + packages: NonNullable priceName?: string priceTerm?: string product: Product diff --git a/apps/scandic-web/types/contexts/select-rate/room.ts b/apps/scandic-web/types/contexts/select-rate/room.ts index 9967b5dc3..c3a0b4fa9 100644 --- a/apps/scandic-web/types/contexts/select-rate/room.ts +++ b/apps/scandic-web/types/contexts/select-rate/room.ts @@ -1,17 +1,16 @@ +import type { Package } from "@/types/requests/packages" import type { RatesState, SelectedRoom } from "@/types/stores/rates" export interface RoomContextValue extends Omit { - actions: Omit< - SelectedRoom["actions"], - "appendRegularRates" | "addRoomFeatures" - > + actions: SelectedRoom["actions"] isActiveRoom: boolean isFetchingAdditionalRate: boolean - isFetchingRoomFeatures: boolean isMainRoom: boolean + petRoomPackage: Package | undefined roomAvailability: | NonNullable[number] | undefined + roomPackages: Package[] roomNr: number totalRooms: number } diff --git a/apps/scandic-web/types/providers/rates.ts b/apps/scandic-web/types/providers/rates.ts index 93bd79214..00643398e 100644 --- a/apps/scandic-web/types/providers/rates.ts +++ b/apps/scandic-web/types/providers/rates.ts @@ -1,5 +1,4 @@ import type { Room } from "@/types/hotel" -import type { Packages } from "@/types/requests/packages" import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability" import type { SelectRateSearchParams } from "../components/hotelReservation/selectRate/selectRate" import type { AvailabilityError } from "../stores/rates" @@ -8,7 +7,6 @@ export interface RatesProviderProps extends React.PropsWithChildren { booking: SelectRateSearchParams hotelType: string | undefined isUserLoggedIn: boolean - packages: Packages | null roomCategories: Room[] roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined vat: number diff --git a/apps/scandic-web/types/requests/packages.ts b/apps/scandic-web/types/requests/packages.ts index ee663f034..1f5730d6b 100644 --- a/apps/scandic-web/types/requests/packages.ts +++ b/apps/scandic-web/types/requests/packages.ts @@ -6,6 +6,8 @@ import type { roomPackagesInputSchema, } from "@/server/routers/hotels/input" import type { packagesSchema } from "@/server/routers/hotels/output" +import type { RoomPackageCodeEnum } from "../components/hotelReservation/selectRate/roomFilter" +import type { BreakfastPackageEnum } from "../enums/breakfast" export interface BreackfastPackagesInput extends z.input {} @@ -17,3 +19,6 @@ export interface PackagesInput extends z.input {} export type Packages = z.output +export type Package = NonNullable[number] + +export type PackageEnum = BreakfastPackageEnum | RoomPackageCodeEnum diff --git a/apps/scandic-web/types/stores/rates.ts b/apps/scandic-web/types/stores/rates.ts index 87448ad31..44a4e43c3 100644 --- a/apps/scandic-web/types/stores/rates.ts +++ b/apps/scandic-web/types/stores/rates.ts @@ -1,16 +1,11 @@ -import type { ReadonlyURLSearchParams } from "next/navigation" - -import type { - DefaultFilterOptions, - RoomPackageCodeEnum, -} from "@/types/components/hotelReservation/selectRate/roomFilter" +import type { DefaultFilterOptions } from "@/types/components/hotelReservation/selectRate/roomFilter" import type { Rate, Room as RoomBooking, SelectRateSearchParams, } from "@/types/components/hotelReservation/selectRate/selectRate" import type { Room } from "@/types/hotel" -import type { Packages } from "@/types/requests/packages" +import type { Package, PackageEnum } from "@/types/requests/packages" import type { Product, RoomConfiguration, @@ -24,18 +19,17 @@ export interface AvailabilityError { } interface Actions { - appendRegularRates: (roomConfigurations: RoomConfiguration[]) => void - addRoomFeatures: ( - roomFeatures: { - roomTypeCode: RoomConfiguration["roomTypeCode"] - features: RoomConfiguration["features"] - }[] + appendRegularRates: ( + roomConfigurations: RoomConfiguration[] | undefined ) => void - closeSection: () => void - modifyRate: () => void + closeSection: VoidFunction + modifyRate: VoidFunction + removeSelectedPackage: (code: PackageEnum) => void + removeSelectedPackages: VoidFunction selectFilter: (filter: BookingCodeFilterEnum) => void - togglePackages: (codes: RoomPackageCodeEnum[]) => void + selectPackages: (codes: PackageEnum[]) => void selectRate: (rate: SelectedRate) => void + updateRooms: (rooms: RoomConfiguration[] | undefined) => void } export interface SelectedRate { @@ -48,27 +42,29 @@ export interface SelectedRate { export interface SelectedRoom { actions: Actions bookingRoom: RoomBooking + isFetchingAdditionalRate: boolean + isFetchingPackages: boolean rooms: RoomConfiguration[] selectedFilter: BookingCodeFilterEnum | undefined - selectedPackages: RoomPackageCodeEnum[] + selectedPackages: Package[] selectedRate: SelectedRate | null } export interface RatesState { activeRoom: number booking: SelectRateSearchParams - packageOptions: DefaultFilterOptions[] hotelType: string | undefined + isRedemptionBooking: boolean isUserLoggedIn: boolean - packages: NonNullable + packageOptions: DefaultFilterOptions[] pathname: string - petRoomPackage: NonNullable[number] | undefined rateSummary: Array rooms: SelectedRoom[] roomCategories: Room[] roomConfigurations: RoomConfiguration[][] + roomsPackages: Package[][] roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined - searchParams: ReadonlyURLSearchParams + searchParams: URLSearchParams vat: number } @@ -78,7 +74,6 @@ export interface InitialState | "booking" | "hotelType" | "isUserLoggedIn" - | "packages" | "pathname" | "roomCategories" | "roomsAvailability" diff --git a/apps/scandic-web/types/trpc/routers/hotel/availability.ts b/apps/scandic-web/types/trpc/routers/hotel/availability.ts index 5c677f724..6b9f90d76 100644 --- a/apps/scandic-web/types/trpc/routers/hotel/availability.ts +++ b/apps/scandic-web/types/trpc/routers/hotel/availability.ts @@ -1,12 +1,11 @@ -import { - type getHotelsByHotelIdsAvailabilityInputSchema, - type hotelsAvailabilityInputSchema, - type roomsCombinedAvailabilityInputSchema, - type selectedRoomAvailabilityInputSchema, -} from "@/server/routers/hotels/input" - import type { z } from "zod" +import type { + enterDetailsRoomsAvailabilityInputSchema, + getHotelsByHotelIdsAvailabilityInputSchema, + hotelsAvailabilityInputSchema, + selectRateRoomsAvailabilityInputSchema, +} from "@/server/routers/hotels/input" import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output" import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType" import type { @@ -23,12 +22,15 @@ export type HotelsAvailabilityInputSchema = z.output< export type HotelsByHotelIdsAvailabilityInputSchema = z.output< typeof getHotelsByHotelIdsAvailabilityInputSchema > -export type RoomsCombinedAvailabilityInputSchema = z.output< - typeof roomsCombinedAvailabilityInputSchema +export type RoomsAvailabilityInputSchema = z.input< + typeof selectRateRoomsAvailabilityInputSchema > -export type SelectedRoomAvailabilitySchema = z.output< - typeof selectedRoomAvailabilityInputSchema +export type RoomsAvailabilityInputRoom = + RoomsAvailabilityInputSchema["booking"]["rooms"][number] +export type RoomsAvailabilityExtendedInputSchema = z.input< + typeof enterDetailsRoomsAvailabilityInputSchema > + export type ProductType = z.output export type ProductTypePrices = z.output export type ProductTypePoints = z.output diff --git a/apps/scandic-web/types/trpc/routers/hotel/rate.ts b/apps/scandic-web/types/trpc/routers/hotel/rate.ts deleted file mode 100644 index 2ebcb02af..000000000 --- a/apps/scandic-web/types/trpc/routers/hotel/rate.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { z } from "zod" - -import type { rateSchema } from "@/server/routers/hotels/schemas/rate" - -export type Rate = z.output diff --git a/apps/scandic-web/types/trpc/routers/hotel/roomAvailability.ts b/apps/scandic-web/types/trpc/routers/hotel/roomAvailability.ts index b0b325c53..0c0a9c0ce 100644 --- a/apps/scandic-web/types/trpc/routers/hotel/roomAvailability.ts +++ b/apps/scandic-web/types/trpc/routers/hotel/roomAvailability.ts @@ -1,6 +1,5 @@ import type { z } from "zod" -import type { RouterOutput } from "@/lib/trpc/client" import type { roomsAvailabilitySchema } from "@/server/routers/hotels/output" import type { roomConfigurationSchema } from "@/server/routers/hotels/schemas/roomAvailability/configuration" import type { @@ -13,10 +12,6 @@ import type { } from "@/server/routers/hotels/schemas/roomAvailability/product" import type { rateDefinitionSchema } from "@/server/routers/hotels/schemas/roomAvailability/rateDefinition" -export type RoomAvailability = NonNullable< - RouterOutput["hotel"]["availability"]["room"] -> - export type CorporateChequeProduct = z.output export type PriceProduct = z.output export type RedemptionProduct = z.output