Files
web/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/index.tsx
Hrishikesh Vaipurkar 78ede453a2 Merged in feat/SW-3526-show-sas-eb-points-rate-in- (pull request #2933)
feat(SW-3526): Show EB points rate and label in booking flow

* feat(SW-3526): Show EB points rate and label in booking flow

* feat(SW-3526) Optimized points currency code

* feat(SW-3526) Removed extra multiplication for token expiry after rebase

* feat(SW-3526): Updated to exhaustive check and thow if type error

Approved-by: Anton Gunnarsson
2025-10-15 06:54:44 +00:00

578 lines
17 KiB
TypeScript

"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<SelectRateContext>(
{} as SelectRateContext
)
SelectRateContext.displayName = "SelectRateContext"
type SelectRateContextProps = {
children: React.ReactNode
hotelData: NonNullable<RouterOutput["hotel"]["get"]>
}
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<number>(
"activeRoomIndex",
parseAsInteger.withDefault(0)
)
const [_bookingCodeFilter, setBookingCodeFilter] =
useState<BookingCodeFilterEnum>(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 (
<SelectRateContext.Provider
value={{
hotel: {
data: hotelQuery.data,
isFetching: hotelQuery.isFetching,
isError: hotelQuery.isError,
isSuccess: hotelQuery.isSuccess,
error: hotelQuery.error,
},
availability: {
data: availabilityQuery.data,
isFetching: availabilityQuery.isFetching,
isError: availabilityQuery.isError,
isSuccess: availabilityQuery.isSuccess,
error: availabilityQuery.error,
},
isFetching: availabilityQuery.isFetching || hotelQuery.isFetching,
isError: availabilityQuery.isError || hotelQuery.isError,
isSuccess: availabilityQuery.isSuccess && hotelQuery.isSuccess,
getAvailabilityForRoom: (roomIndex: number) =>
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}
<DebugButton />
</SelectRateContext.Provider>
)
}
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
)
}