Merged in chore/SW-3321-move-selectratecontext-to- (pull request #2729)
chore(SW-3321): Moved Select rate context to booking-flow package * chore(SW-3321): Moved Select rate context to booking-flow package * chore(SW-3321): Optimised code Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
"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 { dt } from "@scandic-hotels/common/dt"
|
||||
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/input"
|
||||
|
||||
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, type Price } from "./getTotalPrice"
|
||||
import { includeRoomInfo } from "./includeRoomInfo"
|
||||
import { isRateSelected as isRateSelected_Inner } from "./isRateSelected"
|
||||
|
||||
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
|
||||
import type { SelectRateBooking } from "../../types/components/selectRate/selectRate"
|
||||
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 [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],
|
||||
})),
|
||||
useMemberPrices: isUserLoggedIn,
|
||||
})
|
||||
|
||||
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] },
|
||||
],
|
||||
useMemberPrices: isUserLoggedIn,
|
||||
})
|
||||
},
|
||||
[selectedRates, roomAvailability, isUserLoggedIn]
|
||||
)
|
||||
|
||||
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
|
||||
),
|
||||
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)
|
||||
export const SelectRateConsumer = SelectRateContext.Consumer
|
||||
|
||||
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 calculateNumberOfNights(
|
||||
fromDate: string | Date | undefined,
|
||||
toDate: string | Date | undefined
|
||||
): number {
|
||||
if (!fromDate || !toDate) return 0
|
||||
|
||||
return dt(toDate).diff(dt(fromDate), "day")
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user