"use client" import { usePathname, useSearchParams } from "next/navigation" import { parseAsInteger, useQueryState } from "nuqs" import { createContext, useCallback, useContext, useMemo, useState, } from "react" import { type IntlShape, useIntl } from "react-intl" import { CurrencyEnum } from "@scandic-hotels/common/constants/currency" import { logger } from "@scandic-hotels/common/logger" import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client" import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter" import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel" import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema" import { useGetPointsCurrency } from "../../../bookingFlowConfig/bookingFlowConfigContext" import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn" import useLang from "../../../hooks/useLang" import { BookingCodeFilterEnum } from "../../../stores/bookingCode-filter" import { parseSelectRateSearchParams, searchParamsToRecord, serializeBookingSearchParams, } from "../../../utils/url" import { clearRooms } from "../clearRooms" import { DebugButton } from "../DebugButton" import { findUnavailableSelectedRooms } from "../findUnavailableSelectedRooms" import { getSelectedPackages } from "../getSelectedPackages" import { getTotalPrice } from "../getTotalPrice" import { includeRoomInfo } from "../includeRoomInfo" import { isRateSelected as isRateSelected_Inner } from "../isRateSelected" import { calculateNumberOfNights } from "./calculateNumberOfNights" import { getLowestRoomPrice } from "./getLowestRoomPrice" import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast" import type { SelectRateBooking } from "../../../types/components/selectRate/selectRate" import type { Price } from "../../../types/price" import type { AvailabilityWithRoomInfo, DefaultRoomPackage, Rate, RoomPackage, SelectedRate, SelectRateContext, } from "../types" const SelectRateContext = createContext( {} as SelectRateContext ) SelectRateContext.displayName = "SelectRateContext" type SelectRateContextProps = { children: React.ReactNode hotelData: NonNullable } export function SelectRateProvider({ children, hotelData, }: SelectRateContextProps) { const lang = useLang() const searchParams = useSearchParams() const updateBooking = useUpdateBooking() const isUserLoggedIn = useIsLoggedIn() const intl = useIntl() const pointsCurrency = useGetPointsCurrency() const [activeRoomIndex, setInternalActiveRoomIndex] = useQueryState( "activeRoomIndex", parseAsInteger.withDefault(0) ) const [_bookingCodeFilter, setBookingCodeFilter] = useState(BookingCodeFilterEnum.Discounted) const selectRateBooking = parseSelectRateSearchParams( searchParamsToRecord(searchParams) ) const selectRateInput = selectRateRoomsAvailabilityInputSchema.safeParse({ booking: selectRateBooking, lang, }) const hotelId = selectRateInput.data?.booking.hotelId ?? hotelData.hotel.id const hotelQuery = trpc.hotel.get.useQuery( { hotelId: hotelId!, language: lang, isCardOnlyPayment: false }, { enabled: !!hotelId, initialData: hotelData, refetchOnWindowFocus: false } ) const availabilityQuery = trpc.hotel.availability.selectRate.rooms.useQuery( selectRateInput.data!, { retry(failureCount, error) { if (error.data?.code === "BAD_REQUEST") { return false } return failureCount <= 2 }, enabled: selectRateInput.success, refetchOnWindowFocus: false, } ) const availablePackages: (DefaultRoomPackage | RoomPackage)[][] | undefined = useMemo(() => { const defaults = getDefaultRoomPackages(intl) return availabilityQuery.data ?.filter((x) => "packages" in x) .map((x) => { const p = x.packages.filter((x) => isRoomPackage(x)) return [ ...p, ...defaults.filter( (def) => !p.some((pkg) => pkg.code === def.code) ), ].sort((a, b) => a.description.localeCompare(b.description)) }) }, [availabilityQuery.data, intl]) const roomAvailability: (AvailabilityWithRoomInfo | null)[][] = useMemo(() => { return ( availabilityQuery.data ?.map((x, ix) => { if ("roomConfigurations" in x === false) { return undefined } const { roomConfigurations } = x return includeRoomInfo({ roomConfigurations, roomCategories: hotelQuery.data?.roomCategories ?? [], selectedPackages: getSelectedPackages( availablePackages?.at(ix), selectRateInput.data?.booking.rooms[ix]?.packages ?? [] ), }) }) .filter((x) => !!x) ?? [] ) }, [ availabilityQuery.data, hotelQuery.data?.roomCategories, availablePackages, selectRateInput.data?.booking.rooms, ]) const isRateSelected = useCallback( ({ roomIndex, rate, roomTypeCode, }: { roomIndex: number rate: Rate roomTypeCode: string | null | undefined }) => { const selectedRate = selectRateBooking?.rooms?.[roomIndex].rateCode const selectedRoomTypeCode = selectRateBooking?.rooms?.[roomIndex].roomTypeCode const isSelected = isRateSelected_Inner({ selectedRateCode: selectedRate, selectedRoomTypeCode, rate, roomTypeCode, }) return isSelected }, [selectRateBooking?.rooms] ) const roomCount = selectRateInput.data?.booking?.rooms?.length ?? 1 const selectedRates: SelectedRate[] = useMemo(() => { return (selectRateBooking?.rooms ?? []).map((_, ix) => { const selectedRatesPerRoom = roomAvailability.at(ix)?.flatMap((room) => { if (!room) return undefined const allRates: Rate[] = [ ...room.regular.map((reg) => ({ ...reg, type: "regular" as const, })), ...room.campaign.map((camp) => ({ ...camp, type: "campaign" as const, })), ...room.redemptions.map((red) => ({ ...red, type: "redemption" as const, })), ...room.code.map((cod) => ({ ...cod, type: "code" as const, })), ] return allRates .map((rate) => ({ ...rate, roomInfo: room, isSelected: isRateSelected({ roomIndex: ix, rate: rate, roomTypeCode: room.roomTypeCode, }), })) .filter((x) => x.isSelected) }) if (selectedRatesPerRoom && selectedRatesPerRoom.length > 1) { console.error(`Multiple selected rates found for room index ${ix}:`) } const selectedRate = selectedRatesPerRoom?.at(0) return selectedRate }) }, [selectRateBooking?.rooms, isRateSelected, roomAvailability]) const totalPrice = getTotalPrice({ selectedRates: selectedRates.map((rate, ix) => ({ rate, roomConfiguration: roomAvailability[ix]?.[0], })), isMember: isUserLoggedIn, pointsCurrency, }) const getPriceForRoom = useCallback( (roomIndex: number): Price | null => { if (roomIndex < 0 || roomIndex >= selectedRates.length) { console.warn("Room index out of bounds:", roomIndex) return null } const rate = selectedRates[roomIndex] if (!rate) { return null } return getTotalPrice({ selectedRates: [ { rate, roomConfiguration: roomAvailability[roomIndex]?.[0] }, ], isMember: isUserLoggedIn && roomIndex === 0, addAdditionalCost: false, pointsCurrency, }) }, [selectedRates, roomAvailability, isUserLoggedIn, pointsCurrency] ) const setActiveRoomIndex = useCallback( (roomIndex: number | "deselect" | "next") => { if (roomIndex === "deselect" || roomIndex == "next") { if (roomCount === 1) { setInternalActiveRoomIndex(0) return } const isLastRoom = activeRoomIndex >= roomCount - 1 if (isLastRoom) { setInternalActiveRoomIndex(-1) return } const nextRoomWithoutRate = selectedRates.findIndex((rate, ix) => { return ix !== activeRoomIndex && (!rate || !rate.isSelected) }) setInternalActiveRoomIndex(nextRoomWithoutRate) return } if (roomIndex < 0 || roomIndex >= roomCount) { logger.warn("Room index out of bounds:", roomIndex) return } setInternalActiveRoomIndex(roomIndex) }, [roomCount, activeRoomIndex, setInternalActiveRoomIndex, selectedRates] ) const getPackagesForRoom: SelectRateContext["getPackagesForRoom"] = ( roomIndex ) => { const availableForRoom = availablePackages?.[roomIndex] ?? [] const selectedPackages = getSelectedPackages( availableForRoom, selectRateInput.data?.booking.rooms[roomIndex]?.packages ?? [] ) return { selectedPackages, availablePackages: availableForRoom, } } const bookingCodeFilter = _bookingCodeFilter === BookingCodeFilterEnum.Discounted && !selectRateInput.data?.booking.bookingCode ? BookingCodeFilterEnum.All : _bookingCodeFilter const roomAvailabilityWithAdjustedRoomCount: (AvailabilityWithRoomInfo | null)[][] = roomAvailability.map((availability, roomIndex) => { if (roomIndex === 0) { return availability } return availability.map((room) => { if (!room) { return room } const sameRoomTypeSelectedPreviouslyCount = selectRateBooking?.rooms .slice(0, roomIndex) .filter((x) => x.roomTypeCode === room.roomTypeCode).length ?? 0 const newRoomsLeft = Math.max( room.roomsLeft - sameRoomTypeSelectedPreviouslyCount, 0 ) return { ...room, roomsLeft: newRoomsLeft, status: newRoomsLeft === 0 ? AvailabilityEnum.NotAvailable : AvailabilityEnum.Available, } as typeof room }) }) const roomIndexesToDeselect = findUnavailableSelectedRooms({ selectedRates, roomAvailabilityWithAdjustedRoomCount, }) const cleared = clearRooms({ selectRateBooking, roomIndexesToClear: roomIndexesToDeselect, }) if (cleared.hasUpdated) { updateBooking(cleared.selectRateBooking) } return ( getAvailabilityForRoom( roomIndex, roomAvailabilityWithAdjustedRoomCount ), getLowestRoomPrice: () => getLowestRoomPrice(roomAvailabilityWithAdjustedRoomCount), isRateSelected, getPackagesForRoom, bookingCodeFilter, input: { data: selectRateInput.data, hasError: !selectRateInput.success, nights: calculateNumberOfNights( selectRateInput.data?.booking.fromDate, selectRateInput.data?.booking.toDate ), errorCode: selectRateInput.error?.errors[0].message, bookingCode: selectRateInput.data?.booking.bookingCode, roomCount: roomCount, isMultiRoom: roomCount > 1, }, selectedRates: { vat: hotelQuery.data?.hotel.vat ?? 0, rates: selectedRates, totalPrice, getPriceForRoom, rateSelectedForRoom: (roomIndex: number) => { return !!selectedRates[roomIndex] }, forRoom: (roomIndex: number) => { return selectedRates[roomIndex] }, state: selectedRates.length === 0 ? "NONE_SELECTED" : selectedRates.every((x) => !!x) ? "ALL_SELECTED" : "PARTIALLY_SELECTED", }, activeRoomIndex: activeRoomIndex, actions: { setActiveRoom: setActiveRoomIndex, selectPackages: ({ roomIndex, packages }) => { const updatedRoom = selectRateBooking?.rooms?.[roomIndex] if (!updatedRoom) { console.error("No room found at index", roomIndex) // TODO: What to do here? return } updatedRoom.packages = packages updateBooking(selectRateBooking) setActiveRoomIndex(roomIndex) }, selectBookingCodeFilter: (filter: BookingCodeFilterEnum) => { setBookingCodeFilter(filter) }, selectRate: ({ roomIndex, rateCode, counterRateCode, roomTypeCode, bookingCode, }) => { const updatedRoom = selectRateBooking?.rooms?.[roomIndex] if (!updatedRoom) { console.error("No room found at index", roomIndex) // TODO: What to do here? return } updatedRoom.rateCode = rateCode updatedRoom.roomTypeCode = roomTypeCode updatedRoom.counterRateCode = counterRateCode || null updatedRoom.bookingCode = bookingCode || null updateBooking(selectRateBooking) setActiveRoomIndex("next") }, removeBookingCode: () => { if (!selectRateInput.data) { return } const clearedBooking: SelectRateBooking = { hotelId: selectRateInput.data.booking.hotelId, fromDate: selectRateInput.data.booking.fromDate, toDate: selectRateInput.data.booking.toDate, rooms: selectRateInput.data.booking.rooms.map((room) => ({ ...room, bookingCode: null, })), } updateBooking(clearedBooking) setActiveRoomIndex(0) }, }, }} > {children} ) } export const useSelectRateContext = () => useContext(SelectRateContext) const getDefaultRoomPackages = (intl: IntlShape): DefaultRoomPackage[] => [ { code: RoomPackageCodeEnum.ACCESSIBILITY_ROOM, description: intl.formatMessage({ defaultMessage: "Accessible room", }), }, { code: RoomPackageCodeEnum.ALLERGY_ROOM, description: intl.formatMessage({ defaultMessage: "Allergy-friendly room", }), }, { code: RoomPackageCodeEnum.PET_ROOM, description: intl.formatMessage({ defaultMessage: "Pet-friendly room", }), }, ].map((pkg) => ({ ...pkg, type: "default", localPrice: { currency: CurrencyEnum.Unknown, price: 0, totalPrice: 0 }, requestedPrice: { currency: CurrencyEnum.Unknown, price: 0, totalPrice: 0 }, itemCode: "", inventories: [], })) function getAvailabilityForRoom( roomIndex: number, roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined ): AvailabilityWithRoomInfo[] | undefined { if ( !roomAvailability || roomIndex < 0 || roomIndex >= roomAvailability.length ) { return undefined } return roomAvailability[roomIndex] ?.filter((x) => !!x) .sort((a, b) => { if (!a || !b) { return 0 } if ( a.status === AvailabilityEnum.NotAvailable && b.status !== AvailabilityEnum.NotAvailable ) { return 1 } if ( a.status !== AvailabilityEnum.NotAvailable && b.status === AvailabilityEnum.NotAvailable ) { return -1 } return 0 }) } function useUpdateBooking() { const pathname = usePathname() return function updateBooking(booking: SelectRateBooking) { const newUrl = new URL(pathname, window.location.origin) // TODO: Handle existing search params newUrl.search = serializeBookingSearchParams(booking).toString() // router.replace(newUrl.toString(), { scroll: false }) window.history.replaceState({}, "", newUrl.toString()) } } function isRoomPackage(x: { code: BreakfastPackageEnum | RoomPackageCodeEnum }): x is { code: RoomPackageCodeEnum } { return Object.values(RoomPackageCodeEnum).includes( x.code as RoomPackageCodeEnum ) }