Chore/refactor hotel trpc routes * chore(SW-3519): refactor trpc hotel routers * chore(SW-3519): refactor trpc hotel routers * refactor * merge * Merge branch 'master' of bitbucket.org:scandic-swap/web into chore/refactor-hotel-trpc-routes Approved-by: Linus Flood
580 lines
17 KiB
TypeScript
580 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 { 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/availability/selectRate/rooms/schema"
|
|
|
|
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 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 [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,
|
|
})
|
|
|
|
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,
|
|
})
|
|
},
|
|
[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)
|
|
|
|
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
|
|
)
|
|
}
|