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,68 @@
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
|
||||
import { useSelectRateContext } from "./SelectRateContext"
|
||||
import { type SelectRateContext } from "./types"
|
||||
|
||||
export function DebugButton() {
|
||||
const context = useSelectRateContext()
|
||||
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
const allRoomAvailability = getAllRoomAvailability(context)
|
||||
const allRoomPackages = getAllRoomPackages(context)
|
||||
console.log("%c SelectRateContext: ", "background: #AD0015; color: #FFF", {
|
||||
...context,
|
||||
...allRoomAvailability,
|
||||
...allRoomPackages,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "4rem",
|
||||
background: "green",
|
||||
border: "none",
|
||||
padding: "0.5rem 1rem",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* // eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
DEBUG
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function getAllRoomAvailability(context: SelectRateContext) {
|
||||
const obj: Record<
|
||||
string,
|
||||
ReturnType<SelectRateContext["getAvailabilityForRoom"]> | null
|
||||
> = {}
|
||||
for (let i = 0; i < context.input.roomCount; i++) {
|
||||
const key = `getAvailabilityForRoom(${i})`
|
||||
const availability = context.getAvailabilityForRoom(i) ?? null
|
||||
obj[key] = availability
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
function getAllRoomPackages(context: SelectRateContext) {
|
||||
const obj: Record<
|
||||
string,
|
||||
ReturnType<SelectRateContext["getPackagesForRoom"]> | null
|
||||
> = {}
|
||||
for (let i = 0; i < context.input.roomCount; i++) {
|
||||
const key = `getPackagesForRoom(${i})`
|
||||
const availability = context.getPackagesForRoom(i) ?? null
|
||||
obj[key] = availability
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
13
packages/booking-flow/lib/contexts/SelectRate/Room.ts
Normal file
13
packages/booking-flow/lib/contexts/SelectRate/Room.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from "react"
|
||||
|
||||
import type { RoomContextValue } from "../../types/contexts/selectRate/room"
|
||||
|
||||
export const RoomContext = createContext<RoomContextValue | null>(null)
|
||||
|
||||
export function useRoomContext() {
|
||||
const ctx = useContext(RoomContext)
|
||||
if (!ctx) {
|
||||
throw new Error("Missing context value [RoomContext]")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
31
packages/booking-flow/lib/contexts/SelectRate/clearRooms.ts
Normal file
31
packages/booking-flow/lib/contexts/SelectRate/clearRooms.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SelectRateBooking } from "../../types/components/selectRate/selectRate"
|
||||
|
||||
/**
|
||||
* Removes room data
|
||||
*/
|
||||
export function clearRooms({
|
||||
selectRateBooking,
|
||||
roomIndexesToClear,
|
||||
}: {
|
||||
selectRateBooking: SelectRateBooking | null
|
||||
roomIndexesToClear: number[]
|
||||
}):
|
||||
| { selectRateBooking: SelectRateBooking; hasUpdated: true }
|
||||
| { hasUpdated: false } {
|
||||
if (!selectRateBooking || roomIndexesToClear.length === 0) {
|
||||
return { hasUpdated: false }
|
||||
}
|
||||
|
||||
roomIndexesToClear.forEach((roomIndex) => {
|
||||
if (!selectRateBooking?.rooms?.[roomIndex]) {
|
||||
return
|
||||
}
|
||||
|
||||
selectRateBooking.rooms[roomIndex].roomTypeCode = null
|
||||
selectRateBooking.rooms[roomIndex].rateCode = null
|
||||
selectRateBooking.rooms[roomIndex].counterRateCode = null
|
||||
selectRateBooking.rooms[roomIndex].bookingCode = null
|
||||
})
|
||||
|
||||
return { hasUpdated: true, selectRateBooking: selectRateBooking }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import type { AvailabilityWithRoomInfo, SelectedRate } from "./types"
|
||||
|
||||
export function findUnavailableSelectedRooms({
|
||||
selectedRates,
|
||||
roomAvailabilityWithAdjustedRoomCount,
|
||||
}: {
|
||||
selectedRates: SelectedRate[]
|
||||
roomAvailabilityWithAdjustedRoomCount: (AvailabilityWithRoomInfo | null)[][]
|
||||
}): number[] {
|
||||
const roomIndexesToDeselect: number[] = []
|
||||
for (let roomIndex = selectedRates.length - 1; roomIndex >= 0; roomIndex--) {
|
||||
const rate = selectedRates[roomIndex]
|
||||
if (!rate) continue
|
||||
|
||||
const room = roomAvailabilityWithAdjustedRoomCount[roomIndex].find(
|
||||
(x) => x?.roomTypeCode === rate.roomInfo.roomTypeCode
|
||||
)
|
||||
|
||||
if (!room || room.status === AvailabilityEnum.NotAvailable) {
|
||||
roomIndexesToDeselect.push(roomIndex)
|
||||
}
|
||||
}
|
||||
return roomIndexesToDeselect
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import { getSelectedPackages } from "./getSelectedPackages"
|
||||
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { Package } from "./types"
|
||||
|
||||
type FirstParameter = Parameters<typeof getSelectedPackages>[0]
|
||||
|
||||
const localPrice = { currency: CurrencyEnum.Unknown, price: 0, totalPrice: 0 }
|
||||
describe("getSelectedPackages", () => {
|
||||
const availablePackages: Partial<Package>[] = [
|
||||
{
|
||||
code: "PKG1" as PackageEnum,
|
||||
localPrice,
|
||||
},
|
||||
{ code: "PKG2" as PackageEnum, localPrice },
|
||||
{ code: "PKG3" as PackageEnum, localPrice },
|
||||
]
|
||||
|
||||
it("returns empty array if availablePackages is undefined", () => {
|
||||
const result = getSelectedPackages(undefined, ["PKG1" as PackageEnum])
|
||||
expect(result).toMatchObject([])
|
||||
})
|
||||
|
||||
it("returns empty array if selectedPackages is undefined", () => {
|
||||
// @ts-expect-error testing undefined
|
||||
const result = getSelectedPackages(availablePackages, undefined)
|
||||
expect(result).toMatchObject([])
|
||||
})
|
||||
|
||||
it("returns empty array if selectedPackages is empty", () => {
|
||||
const result = getSelectedPackages(availablePackages as FirstParameter, [])
|
||||
expect(result).toMatchObject([])
|
||||
})
|
||||
|
||||
it("returns only the selected packages", () => {
|
||||
const result = getSelectedPackages(availablePackages as FirstParameter, [
|
||||
"PKG1" as PackageEnum,
|
||||
"PKG3" as PackageEnum,
|
||||
])
|
||||
expect(result).toMatchObject([{ code: "PKG1" }, { code: "PKG3" }])
|
||||
})
|
||||
|
||||
it("returns empty array if no selectedPackages match", () => {
|
||||
const result = getSelectedPackages(availablePackages as FirstParameter, [
|
||||
"PKG4" as PackageEnum,
|
||||
])
|
||||
expect(result).toMatchObject([])
|
||||
})
|
||||
|
||||
it("returns all packages if all are selected", () => {
|
||||
const result = getSelectedPackages(availablePackages as FirstParameter, [
|
||||
"PKG1" as PackageEnum,
|
||||
"PKG2" as PackageEnum,
|
||||
"PKG3" as PackageEnum,
|
||||
])
|
||||
expect(result).toMatchObject(availablePackages)
|
||||
})
|
||||
|
||||
it("handles duplicate selectedPackages gracefully", () => {
|
||||
const result = getSelectedPackages(availablePackages as FirstParameter, [
|
||||
"PKG1" as PackageEnum,
|
||||
"PKG1" as PackageEnum,
|
||||
"PKG2" as PackageEnum,
|
||||
])
|
||||
expect(result).toMatchObject([{ code: "PKG1" }, { code: "PKG2" }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { DefaultRoomPackage, RoomPackage } from "./types"
|
||||
|
||||
export function getSelectedPackages(
|
||||
availablePackages: (DefaultRoomPackage | RoomPackage)[] | undefined,
|
||||
selectedPackages: PackageEnum[]
|
||||
): RoomPackage[] {
|
||||
if (
|
||||
!availablePackages ||
|
||||
!selectedPackages ||
|
||||
selectedPackages.length === 0
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
return availablePackages.filter((pack) => {
|
||||
const isSelected = selectedPackages.some(
|
||||
(selected) => selected === pack.code
|
||||
)
|
||||
return isSelected
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getTotalPrice } from "./getTotalPrice"
|
||||
|
||||
describe("getTotalPrice", () => {
|
||||
it("should return null when no rates are selected", () => {
|
||||
const result = getTotalPrice({
|
||||
selectedRates: [],
|
||||
useMemberPrices: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
local: {
|
||||
currency: "Unknown",
|
||||
price: 0,
|
||||
regularPrice: undefined,
|
||||
},
|
||||
requested: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
269
packages/booking-flow/lib/contexts/SelectRate/getTotalPrice.ts
Normal file
269
packages/booking-flow/lib/contexts/SelectRate/getTotalPrice.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import { sumPackages } from "../../utils/SelectRate"
|
||||
|
||||
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { AvailabilityWithRoomInfo, Rate, RoomPackage } from "./types"
|
||||
|
||||
type TPrice = {
|
||||
additionalPrice?: number
|
||||
additionalPriceCurrency?: CurrencyEnum
|
||||
currency: CurrencyEnum
|
||||
price: number
|
||||
regularPrice?: number
|
||||
}
|
||||
|
||||
export type Price = {
|
||||
requested?: TPrice
|
||||
local: TPrice
|
||||
}
|
||||
|
||||
type SelectedRate = {
|
||||
roomConfiguration: AvailabilityWithRoomInfo | null
|
||||
rate: Rate | undefined
|
||||
}
|
||||
|
||||
export function getTotalPrice({
|
||||
selectedRates,
|
||||
useMemberPrices,
|
||||
}: {
|
||||
selectedRates: Array<SelectedRate | null>
|
||||
useMemberPrices: boolean
|
||||
}): Price | null {
|
||||
const mainRoom = selectedRates[0]
|
||||
const mainRoomRate = mainRoom?.rate
|
||||
const summaryArray = selectedRates.filter(
|
||||
(x): x is OneLevelNonNullable<SelectedRate> => !!x
|
||||
)
|
||||
|
||||
if (summaryArray.some((rate) => "corporateCheque" in rate)) {
|
||||
return calculateCorporateChequePrice(summaryArray)
|
||||
}
|
||||
|
||||
if (!mainRoomRate) {
|
||||
return calculateTotalPrice(summaryArray, useMemberPrices)
|
||||
}
|
||||
|
||||
// In case of reward night (redemption) or voucher only single room booking is supported by business rules
|
||||
if ("redemption" in mainRoomRate) {
|
||||
return calculateRedemptionTotalPrice(
|
||||
mainRoomRate.redemption,
|
||||
mainRoom.roomConfiguration?.selectedPackages.filter(
|
||||
(pkg) => "localPrice" in pkg
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
if ("voucher" in mainRoomRate) {
|
||||
const voucherPrice = calculateVoucherPrice(summaryArray)
|
||||
return voucherPrice
|
||||
}
|
||||
|
||||
return calculateTotalPrice(summaryArray, useMemberPrices)
|
||||
}
|
||||
|
||||
function calculateTotalPrice(
|
||||
selectedRateSummary: OneLevelNonNullable<SelectedRate>[],
|
||||
useMemberPrices: boolean
|
||||
) {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room, idx) => {
|
||||
if (!room.rate || !("member" in room.rate) || !("public" in room.rate)) {
|
||||
return total
|
||||
}
|
||||
|
||||
const roomNr = idx + 1
|
||||
const isMainRoom = roomNr === 1
|
||||
|
||||
const useMemberRate = isMainRoom && useMemberPrices && room.rate.member
|
||||
const rate = useMemberRate ? room.rate.member : room.rate.public
|
||||
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const packagesPrice = room.roomConfiguration?.selectedPackages.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 }
|
||||
)
|
||||
|
||||
total.local.currency = rate.localPrice.currency
|
||||
total.local.price =
|
||||
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
|
||||
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
rate.localPrice.regularPricePerStay +
|
||||
packagesPrice.local
|
||||
}
|
||||
|
||||
if (rate.requestedPrice) {
|
||||
if (!total.requested) {
|
||||
total.requested = {
|
||||
currency: rate.requestedPrice.currency,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (!total.requested.currency) {
|
||||
total.requested.currency = rate.requestedPrice.currency
|
||||
}
|
||||
|
||||
total.requested.price =
|
||||
total.requested.price +
|
||||
rate.requestedPrice.pricePerStay +
|
||||
packagesPrice.requested
|
||||
|
||||
if (rate.requestedPrice.regularPricePerStay) {
|
||||
total.requested.regularPrice =
|
||||
(total.requested.regularPrice || 0) +
|
||||
rate.requestedPrice.regularPricePerStay +
|
||||
packagesPrice.requested
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Unknown,
|
||||
price: 0,
|
||||
regularPrice: undefined,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function calculateRedemptionTotalPrice(
|
||||
redemption: RedemptionProduct["redemption"],
|
||||
packages: RoomPackage[] | null
|
||||
) {
|
||||
const pkgsSum = sumPackages(packages)
|
||||
let additionalPrice
|
||||
if (redemption.localPrice.additionalPricePerStay) {
|
||||
additionalPrice =
|
||||
redemption.localPrice.additionalPricePerStay + pkgsSum.price
|
||||
} else if (pkgsSum.price) {
|
||||
additionalPrice = pkgsSum.price
|
||||
}
|
||||
|
||||
let additionalPriceCurrency
|
||||
if (redemption.localPrice.currency) {
|
||||
additionalPriceCurrency = redemption.localPrice.currency
|
||||
} else if (pkgsSum.currency) {
|
||||
additionalPriceCurrency = pkgsSum.currency
|
||||
}
|
||||
return {
|
||||
local: {
|
||||
additionalPrice,
|
||||
additionalPriceCurrency,
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: redemption.localPrice.pointsPerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function calculateVoucherPrice(
|
||||
selectedRateSummary: OneLevelNonNullable<SelectedRate>[]
|
||||
) {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room) => {
|
||||
if (!("voucher" in room.rate)) {
|
||||
return total
|
||||
}
|
||||
const rate = room.rate.voucher
|
||||
|
||||
total.local.price = total.local.price + rate.numberOfVouchers
|
||||
|
||||
const pkgsSum = sumPackages(room.roomConfiguration?.selectedPackages)
|
||||
if (pkgsSum.price && pkgsSum.currency) {
|
||||
total.local.additionalPrice =
|
||||
(total.local.additionalPrice || 0) + pkgsSum.price
|
||||
total.local.additionalPriceCurrency = pkgsSum.currency
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Voucher,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
type OneLevelNonNullable<T> = {
|
||||
[K in keyof T]-?: NonNullable<T[K]>
|
||||
}
|
||||
|
||||
export function calculateCorporateChequePrice(
|
||||
selectedRates: OneLevelNonNullable<SelectedRate>[]
|
||||
) {
|
||||
return selectedRates.reduce<Price>(
|
||||
(total, room) => {
|
||||
if (!("corporateCheque" in room.rate)) {
|
||||
return total
|
||||
}
|
||||
|
||||
const rate = room.rate.corporateCheque
|
||||
const pkgsSum = sumPackages(
|
||||
selectedRates.flatMap((x) => x.roomConfiguration?.selectedPackages)
|
||||
)
|
||||
|
||||
total.local.price = total.local.price + rate.localPrice.numberOfCheques
|
||||
if (rate.localPrice.additionalPricePerStay) {
|
||||
total.local.additionalPrice =
|
||||
(total.local.additionalPrice || 0) +
|
||||
rate.localPrice.additionalPricePerStay +
|
||||
pkgsSum.price
|
||||
} else if (pkgsSum.price) {
|
||||
total.local.additionalPrice =
|
||||
(total.local.additionalPrice || 0) + pkgsSum.price
|
||||
}
|
||||
if (rate.localPrice.currency) {
|
||||
total.local.additionalPriceCurrency = rate.localPrice.currency
|
||||
}
|
||||
|
||||
if (rate.requestedPrice) {
|
||||
if (!total.requested) {
|
||||
total.requested = {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
total.requested.price =
|
||||
total.requested.price + rate.requestedPrice.numberOfCheques
|
||||
|
||||
if (rate.requestedPrice.additionalPricePerStay) {
|
||||
total.requested.additionalPrice =
|
||||
(total.requested.additionalPrice || 0) +
|
||||
rate.requestedPrice.additionalPricePerStay
|
||||
}
|
||||
|
||||
if (rate.requestedPrice.currency) {
|
||||
total.requested.additionalPriceCurrency = rate.requestedPrice.currency
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { includeRoomInfo } from "./includeRoomInfo"
|
||||
|
||||
import type { RoomCategory } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { RoomConfiguration } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>
|
||||
}
|
||||
: T
|
||||
|
||||
const mockRoomCategories: DeepPartial<RoomCategory>[] = [
|
||||
{
|
||||
id: "cat1",
|
||||
name: "Standard",
|
||||
roomTypes: [{ code: "STD" }, { code: "STD2" }],
|
||||
},
|
||||
{
|
||||
id: "cat2",
|
||||
name: "Deluxe",
|
||||
roomTypes: [{ code: "DLX" }],
|
||||
},
|
||||
]
|
||||
|
||||
describe("includeRoomInfo", () => {
|
||||
it("returns roomConfiguration with roomInfo when roomTypeCode matches", () => {
|
||||
const roomConfigurations: DeepPartial<RoomConfiguration>[] = [
|
||||
{ roomTypeCode: "STD" },
|
||||
{ roomTypeCode: "DLX" },
|
||||
]
|
||||
|
||||
const result = includeRoomInfo({
|
||||
roomConfigurations: roomConfigurations as RoomConfiguration[],
|
||||
roomCategories: mockRoomCategories as RoomCategory[],
|
||||
selectedPackages: [],
|
||||
})
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
roomTypeCode: "STD",
|
||||
roomInfo: mockRoomCategories[0],
|
||||
})
|
||||
expect(result[1]).toMatchObject({
|
||||
roomTypeCode: "DLX",
|
||||
roomInfo: mockRoomCategories[1],
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null when no matching roomTypeCode is found", () => {
|
||||
const roomConfigurations: DeepPartial<RoomConfiguration>[] = [
|
||||
{ roomTypeCode: "NOT_FOUND" },
|
||||
]
|
||||
|
||||
const result = includeRoomInfo({
|
||||
roomConfigurations: roomConfigurations as RoomConfiguration[],
|
||||
roomCategories: mockRoomCategories as RoomCategory[],
|
||||
selectedPackages: [],
|
||||
})
|
||||
|
||||
expect(result).toEqual([null])
|
||||
})
|
||||
|
||||
it("handles empty roomConfigurations", () => {
|
||||
const result = includeRoomInfo({
|
||||
roomConfigurations: [],
|
||||
roomCategories: mockRoomCategories as RoomCategory[],
|
||||
selectedPackages: [],
|
||||
})
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("handles empty roomCategories", () => {
|
||||
const roomConfigurations: DeepPartial<RoomConfiguration>[] = [
|
||||
{ roomTypeCode: "STD" },
|
||||
]
|
||||
const result = includeRoomInfo({
|
||||
roomConfigurations: roomConfigurations as RoomConfiguration[],
|
||||
roomCategories: [],
|
||||
selectedPackages: [],
|
||||
})
|
||||
expect(result).toEqual([null])
|
||||
})
|
||||
|
||||
it("returns correct mapping for multiple configurations with mixed matches", () => {
|
||||
const roomConfigurations: DeepPartial<RoomConfiguration>[] = [
|
||||
{ roomTypeCode: "STD" },
|
||||
{ roomTypeCode: "DLX" },
|
||||
{ roomTypeCode: "UNKNOWN" },
|
||||
]
|
||||
const result = includeRoomInfo({
|
||||
roomConfigurations: roomConfigurations as RoomConfiguration[],
|
||||
roomCategories: mockRoomCategories as RoomCategory[],
|
||||
selectedPackages: [],
|
||||
})
|
||||
expect(result[0]).toMatchObject({
|
||||
roomTypeCode: "STD",
|
||||
roomInfo: mockRoomCategories[0],
|
||||
})
|
||||
expect(result[1]).toMatchObject({
|
||||
roomTypeCode: "DLX",
|
||||
roomInfo: mockRoomCategories[1],
|
||||
})
|
||||
expect(result[2]).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { RouterOutput } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import type { AvailabilityWithRoomInfo, RoomPackage } from "./types"
|
||||
|
||||
type RoomConfiguration = Extract<
|
||||
RouterOutput["hotel"]["availability"]["selectRate"]["rooms"][number],
|
||||
{ roomConfigurations: unknown }
|
||||
>["roomConfigurations"][number]
|
||||
type RoomCategory = NonNullable<
|
||||
RouterOutput["hotel"]["get"]
|
||||
>["roomCategories"][number]
|
||||
|
||||
export function includeRoomInfo({
|
||||
roomConfigurations,
|
||||
roomCategories,
|
||||
selectedPackages,
|
||||
}: {
|
||||
roomConfigurations: RoomConfiguration[]
|
||||
roomCategories: RoomCategory[]
|
||||
selectedPackages: RoomPackage[]
|
||||
}): (AvailabilityWithRoomInfo | null)[] {
|
||||
return roomConfigurations.map((roomConfiguration) => {
|
||||
const room = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...roomConfiguration,
|
||||
roomInfo: room,
|
||||
selectedPackages,
|
||||
} satisfies AvailabilityWithRoomInfo
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
|
||||
import { isRateSelected } from "./isRateSelected"
|
||||
|
||||
describe("isRateSelected", () => {
|
||||
it("should return false when selectedRateCode is undefined", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: undefined,
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when selectedRoomTypeCode is null", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: null,
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when rateCode is undefined", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: undefined } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when roomTypeCode is null", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: null,
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when rate codes don't match", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE2" } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when room type codes don't match", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: "ROOM2",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when both rate code and room type code match", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle case insensitivity in rate codes", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "rate1" } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle case insensitivity in room type codes", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: "RATE1",
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: "RATE1" } },
|
||||
roomTypeCode: "room1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should work with RateEnum values", () => {
|
||||
const result = isRateSelected({
|
||||
selectedRateCode: RateEnum.save,
|
||||
selectedRoomTypeCode: "ROOM1",
|
||||
rate: { public: { rateCode: RateEnum.save } },
|
||||
roomTypeCode: "ROOM1",
|
||||
} as any)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
|
||||
import type { Rate } from "./types"
|
||||
|
||||
export function isRateSelected({
|
||||
selectedRateCode,
|
||||
selectedRoomTypeCode,
|
||||
rate,
|
||||
roomTypeCode,
|
||||
}: {
|
||||
selectedRateCode: RateEnum | string | undefined | null
|
||||
selectedRoomTypeCode: RateEnum | string | undefined | null
|
||||
rate: Rate
|
||||
roomTypeCode: string | null | undefined
|
||||
}) {
|
||||
if (!selectedRateCode || !selectedRoomTypeCode || !rate || !roomTypeCode) {
|
||||
return false
|
||||
}
|
||||
|
||||
let rateCodes: string[] = []
|
||||
if ("public" in rate && rate.public) {
|
||||
rateCodes = [...rateCodes, rate.public.rateCode?.toLowerCase()]
|
||||
}
|
||||
if ("member" in rate && rate.member) {
|
||||
rateCodes = [...rateCodes, rate.member.rateCode?.toLowerCase()]
|
||||
}
|
||||
if ("redemption" in rate && rate.redemption) {
|
||||
rateCodes = [...rateCodes, rate.redemption.rateCode?.toLowerCase()]
|
||||
}
|
||||
if ("voucher" in rate && rate.voucher) {
|
||||
rateCodes = [...rateCodes, rate.voucher.rateCode?.toLowerCase()]
|
||||
}
|
||||
if ("corporateCheque" in rate && rate.corporateCheque) {
|
||||
rateCodes = [...rateCodes, rate.corporateCheque.rateCode?.toLowerCase()]
|
||||
}
|
||||
|
||||
return (
|
||||
rateCodes.includes(selectedRateCode.toLowerCase()) &&
|
||||
selectedRoomTypeCode.toLowerCase() === roomTypeCode.toLowerCase()
|
||||
)
|
||||
}
|
||||
135
packages/booking-flow/lib/contexts/SelectRate/types.ts
Normal file
135
packages/booking-flow/lib/contexts/SelectRate/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { type RouterOutput } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import { type Price } from "./getTotalPrice"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { RoomsAvailabilityOutputSchema } from "@scandic-hotels/trpc/types/availability"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { RoomConfiguration } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { BookingCodeFilterEnum } from "../../stores/bookingCode-filter"
|
||||
|
||||
export type SelectRateContext = {
|
||||
hotel: QueryData<RouterOutput["hotel"]["get"]>
|
||||
availability: QueryData<
|
||||
RouterOutput["hotel"]["availability"]["selectRate"]["rooms"]
|
||||
>
|
||||
input: {
|
||||
data: RoomsAvailabilityOutputSchema | undefined
|
||||
errorCode?: string
|
||||
hasError: boolean
|
||||
nights: number
|
||||
isMultiRoom: boolean
|
||||
roomCount: number
|
||||
bookingCode: string | undefined
|
||||
}
|
||||
|
||||
isFetching: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
|
||||
getAvailabilityForRoom: (
|
||||
roomIndex: number
|
||||
) => AvailabilityWithRoomInfo[] | undefined
|
||||
|
||||
getPackagesForRoom: (roomIndex: number) => {
|
||||
selectedPackages: RoomPackage[]
|
||||
availablePackages: (DefaultRoomPackage | RoomPackage)[]
|
||||
}
|
||||
|
||||
isRateSelected: (args: {
|
||||
roomIndex: number
|
||||
rate: Rate
|
||||
roomTypeCode: string
|
||||
}) => boolean
|
||||
|
||||
selectedRates: {
|
||||
vat: number
|
||||
rates: (
|
||||
| WithSelected<Rate & { roomInfo: AvailabilityWithRoomInfo }>
|
||||
| undefined
|
||||
)[]
|
||||
forRoom: (
|
||||
roomIndex: number
|
||||
) => WithSelected<Rate & { roomInfo: AvailabilityWithRoomInfo }> | undefined
|
||||
rateSelectedForRoom: (roomIndex: number) => boolean
|
||||
getPriceForRoom: (roomIndex: number) => Price | null
|
||||
totalPrice: Price | null
|
||||
state: "ALL_SELECTED" | "PARTIALLY_SELECTED" | "NONE_SELECTED"
|
||||
}
|
||||
|
||||
bookingCodeFilter: BookingCodeFilterEnum
|
||||
activeRoomIndex: number
|
||||
actions: {
|
||||
setActiveRoom: (roomIndex: number | "deselect" | "next") => void
|
||||
selectPackages: (args: {
|
||||
roomIndex: number
|
||||
packages: PackageEnum[]
|
||||
}) => void
|
||||
selectBookingCodeFilter: (filter: BookingCodeFilterEnum) => void
|
||||
selectRate: (args: {
|
||||
roomIndex: number
|
||||
rateCode: string
|
||||
counterRateCode?: string
|
||||
roomTypeCode: string
|
||||
bookingCode?: string
|
||||
}) => void
|
||||
removeBookingCode: () => void
|
||||
}
|
||||
}
|
||||
|
||||
type RegularRate = RoomConfiguration["regular"][number] & {
|
||||
type: "regular"
|
||||
}
|
||||
type CampaignRate = RoomConfiguration["campaign"][number] & {
|
||||
type: "campaign"
|
||||
}
|
||||
type RedemptionRate = RoomConfiguration["redemptions"][number] & {
|
||||
type: "redemption"
|
||||
}
|
||||
type CodeRate = RoomConfiguration["code"][number] & { type: "code" }
|
||||
|
||||
export type Rate = RegularRate | CampaignRate | RedemptionRate | CodeRate
|
||||
|
||||
type QueryData<T> = {
|
||||
data?: T
|
||||
isFetching: boolean
|
||||
isError: boolean
|
||||
isSuccess: boolean
|
||||
error: unknown
|
||||
}
|
||||
|
||||
type AvailabilityQueryData = QueryData<
|
||||
RouterOutput["hotel"]["availability"]["selectRate"]["rooms"]
|
||||
>["data"]
|
||||
|
||||
type HotelQueryData = QueryData<RouterOutput["hotel"]["get"]>["data"]
|
||||
|
||||
type WithSelected<T> = T & { isSelected: boolean }
|
||||
|
||||
export type RoomInfo = NonNullable<HotelQueryData>["roomCategories"][number]
|
||||
|
||||
export type AvailabilityWithRoomInfo = Extract<
|
||||
NonNullable<AvailabilityQueryData>[number],
|
||||
{ hotelId: number }
|
||||
>["roomConfigurations"][number] & {
|
||||
roomInfo: RoomInfo | undefined
|
||||
selectedPackages: RoomPackage[]
|
||||
}
|
||||
|
||||
export type SelectedRate =
|
||||
| WithSelected<Rate & { roomInfo: AvailabilityWithRoomInfo }>
|
||||
| undefined
|
||||
|
||||
export type Package = Extract<
|
||||
NonNullable<AvailabilityQueryData>[number],
|
||||
{ hotelId: number }
|
||||
>["packages"][number]
|
||||
|
||||
export type DefaultRoomPackage = RoomPackage & {
|
||||
type: "default"
|
||||
// code: RoomPackageCodeEnum
|
||||
// description: string
|
||||
}
|
||||
|
||||
export type RoomPackage = Package & { code: RoomPackageCodeEnum }
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { packageSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
|
||||
import type { z } from "zod"
|
||||
|
||||
type RoomPackage = z.output<typeof packageSchema>
|
||||
export type RoomPackageCodes = RoomPackage["code"]
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { RateEnum } from "@scandic-hotels/common/constants/rate"
|
||||
import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
import type { PackageEnum, Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type {
|
||||
Product,
|
||||
RoomConfiguration,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { BookingSearchType } from "../../../misc/searchType"
|
||||
|
||||
export interface Room {
|
||||
adults: number
|
||||
childrenInRoom?: Child[]
|
||||
bookingCode?: string | null
|
||||
counterRateCode?: string | null
|
||||
packages?: PackageEnum[] | null
|
||||
rateCode?: string | null
|
||||
roomTypeCode?: string | null
|
||||
}
|
||||
|
||||
export type SelectRateBooking = {
|
||||
bookingCode?: string
|
||||
city?: string
|
||||
fromDate: string
|
||||
hotelId: string
|
||||
rooms: Room[]
|
||||
searchType?: BookingSearchType
|
||||
toDate: string
|
||||
}
|
||||
|
||||
export type Rate = {
|
||||
features: RoomConfiguration["features"]
|
||||
packages: NonNullable<Packages>
|
||||
priceName?: string
|
||||
priceTerm?: string
|
||||
product: Product
|
||||
rate: RateEnum
|
||||
roomRates?: {
|
||||
rate: Rate
|
||||
roomIndex: number
|
||||
}[]
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
}
|
||||
|
||||
export type RateCode = {
|
||||
publicRateCode: string
|
||||
roomTypeCode: string
|
||||
name: string
|
||||
paymentTerm: string
|
||||
}
|
||||
16
packages/booking-flow/lib/types/contexts/selectRate/room.ts
Normal file
16
packages/booking-flow/lib/types/contexts/selectRate/room.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { RatesState, SelectedRoom } from "../../stores/rates"
|
||||
|
||||
export interface RoomContextValue extends SelectedRoom {
|
||||
isActiveRoom: boolean
|
||||
isFetchingAdditionalRate: boolean
|
||||
isMainRoom: boolean
|
||||
petRoomPackage: Package | undefined
|
||||
roomAvailability:
|
||||
| NonNullable<RatesState["roomsAvailability"]>[number]
|
||||
| undefined
|
||||
roomPackages: Package[]
|
||||
roomNr: number
|
||||
totalRooms: number
|
||||
}
|
||||
88
packages/booking-flow/lib/types/stores/rates.ts
Normal file
88
packages/booking-flow/lib/types/stores/rates.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { Room } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { Package, PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type {
|
||||
Product,
|
||||
RoomConfiguration,
|
||||
RoomsAvailability,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { BookingCodeFilterEnum } from "../../stores/bookingCode-filter"
|
||||
import type {
|
||||
Rate,
|
||||
Room as RoomBooking,
|
||||
SelectRateBooking,
|
||||
} from "../components/selectRate/selectRate"
|
||||
|
||||
export interface AvailabilityError {
|
||||
details: string
|
||||
error: string
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
appendRegularRates: (
|
||||
roomConfigurations: RoomConfiguration[] | undefined
|
||||
) => void
|
||||
closeSection: () => void
|
||||
modifyRate: () => void
|
||||
removeSelectedPackage: (code: PackageEnum) => void
|
||||
removeSelectedPackages: () => void
|
||||
selectFilter: (filter: BookingCodeFilterEnum) => void
|
||||
selectPackages: (codes: PackageEnum[]) => void
|
||||
selectRate: (rate: SelectedRate, isUserLoggedIn: boolean) => void
|
||||
updateRooms: (rooms: RoomConfiguration[] | undefined) => void
|
||||
}
|
||||
|
||||
export interface SelectedRate {
|
||||
features: RoomConfiguration["features"]
|
||||
product: Product
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
}
|
||||
|
||||
export interface SelectedRoom {
|
||||
actions: Actions
|
||||
bookingRoom: RoomBooking
|
||||
isFetchingAdditionalRate: boolean
|
||||
isFetchingPackages: boolean
|
||||
rooms: RoomConfiguration[]
|
||||
selectedFilter: BookingCodeFilterEnum | undefined
|
||||
selectedPackages: Package[]
|
||||
selectedRate: SelectedRate | null
|
||||
}
|
||||
|
||||
interface DefaultFilterOptions {
|
||||
code: RoomPackageCodeEnum
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface RatesState {
|
||||
activeRoom: number
|
||||
booking: SelectRateBooking
|
||||
hotelType: string | undefined
|
||||
isRedemptionBooking: boolean
|
||||
packageOptions: DefaultFilterOptions[]
|
||||
rateSummary: Array<Rate | null>
|
||||
rooms: SelectedRoom[]
|
||||
roomCategories: Room[]
|
||||
roomConfigurations: RoomConfiguration[][]
|
||||
roomsPackages: Package[][]
|
||||
roomsAvailability: (RoomsAvailability | AvailabilityError)[] | undefined
|
||||
vat: number
|
||||
defaultCurrency: CurrencyEnum
|
||||
}
|
||||
|
||||
export interface InitialState
|
||||
extends Pick<
|
||||
RatesState,
|
||||
"booking" | "hotelType" | "roomCategories" | "roomsAvailability" | "vat"
|
||||
> {
|
||||
initialActiveRoom?: number
|
||||
pathname: string
|
||||
labels: {
|
||||
accessibilityRoom: string
|
||||
allergyRoom: string
|
||||
petRoom: string
|
||||
}
|
||||
}
|
||||
73
packages/booking-flow/lib/utils/SelectRate/index.test.ts
Normal file
73
packages/booking-flow/lib/utils/SelectRate/index.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { AlertTypeEnum } from "@scandic-hotels/common/constants/alert"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
|
||||
import { filterOverlappingDates } from "./index"
|
||||
|
||||
import type { specialAlertsSchema } from "@scandic-hotels/trpc/routers/hotels/schemas/hotel/specialAlerts"
|
||||
import type { z } from "zod"
|
||||
|
||||
type Alert = z.infer<typeof specialAlertsSchema>[number]
|
||||
|
||||
function makeAlert(start: string, end: string): Alert {
|
||||
return {
|
||||
id: "test-id",
|
||||
name: "Test Alert",
|
||||
heading: "Test Heading",
|
||||
text: "Some text",
|
||||
type: AlertTypeEnum.Alarm,
|
||||
displayInBookingFlow: true,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
}
|
||||
}
|
||||
|
||||
describe("filterOverlappingDates", () => {
|
||||
const alert = makeAlert("2025-09-01", "2025-09-10")
|
||||
|
||||
it("shows alert if booking starts inside alert", () => {
|
||||
const result = filterOverlappingDates(
|
||||
[alert],
|
||||
dt("2025-09-05"),
|
||||
dt("2025-09-12")
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("shows alert if booking ends inside alert", () => {
|
||||
const result = filterOverlappingDates(
|
||||
[alert],
|
||||
dt("2025-08-28"),
|
||||
dt("2025-09-05")
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("shows alert if booking fully contains alert", () => {
|
||||
const result = filterOverlappingDates(
|
||||
[alert],
|
||||
dt("2025-08-28"),
|
||||
dt("2025-09-15")
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("shows alert if alert fully contains booking", () => {
|
||||
const result = filterOverlappingDates(
|
||||
[alert],
|
||||
dt("2025-09-03"),
|
||||
dt("2025-09-05")
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not show alert if no overlap", () => {
|
||||
const result = filterOverlappingDates(
|
||||
[alert],
|
||||
dt("2025-08-01"),
|
||||
dt("2025-08-05")
|
||||
)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
143
packages/booking-flow/lib/utils/SelectRate/index.tsx
Normal file
143
packages/booking-flow/lib/utils/SelectRate/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { type Dayjs, dt } from "@scandic-hotels/common/dt"
|
||||
import {
|
||||
MaterialIcon,
|
||||
type MaterialIconSetIconProps,
|
||||
} from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
import { ChildBedTypeEnum } from "@scandic-hotels/trpc/enums/childBedTypeEnum"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { Package, Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import type { RoomPackageCodes } from "../../types/components/selectRate/roomFilter"
|
||||
|
||||
interface IconForFeatureCodeProps {
|
||||
featureCode: RoomPackageCodes
|
||||
}
|
||||
export function IconForFeatureCode({
|
||||
featureCode,
|
||||
...props
|
||||
}: IconForFeatureCodeProps & MaterialIconSetIconProps): JSX.Element {
|
||||
switch (featureCode) {
|
||||
case RoomPackageCodeEnum.ACCESSIBILITY_ROOM:
|
||||
return <MaterialIcon icon="accessible" {...props} />
|
||||
case RoomPackageCodeEnum.ALLERGY_ROOM:
|
||||
return <MaterialIcon icon="mode_fan" {...props} />
|
||||
case RoomPackageCodeEnum.PET_ROOM:
|
||||
default:
|
||||
return <MaterialIcon icon="pets" {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
export const invertedBedTypeMap: Record<ChildBedTypeEnum, string> = {
|
||||
[ChildBedTypeEnum.ParentsBed]: ChildBedMapEnum[ChildBedMapEnum.IN_ADULTS_BED],
|
||||
[ChildBedTypeEnum.Crib]: ChildBedMapEnum[ChildBedMapEnum.IN_CRIB],
|
||||
[ChildBedTypeEnum.ExtraBed]: ChildBedMapEnum[ChildBedMapEnum.IN_EXTRA_BED],
|
||||
[ChildBedTypeEnum.Unknown]: ChildBedMapEnum[ChildBedMapEnum.UNKNOWN],
|
||||
}
|
||||
|
||||
export function sumPackages(
|
||||
packages: Pick<Package, "localPrice">[] | undefined | null
|
||||
) {
|
||||
if (!packages || !packages.length) {
|
||||
return {
|
||||
currency: undefined,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
return packages.reduce(
|
||||
(total, pkg) => {
|
||||
total.price = total.price + pkg.localPrice.totalPrice
|
||||
return total
|
||||
},
|
||||
{
|
||||
currency: packages[0].localPrice.currency,
|
||||
price: 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function sumPackagesRequestedPrice(packages: Packages | null) {
|
||||
if (!packages || !packages.length) {
|
||||
return {
|
||||
currency: undefined,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
return packages.reduce(
|
||||
(total, pkg) => {
|
||||
total.price = total.price + pkg.requestedPrice.totalPrice
|
||||
return total
|
||||
},
|
||||
{
|
||||
currency: packages[0].requestedPrice.currency,
|
||||
price: 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function calculateVat(priceInclVat: number, vat: number) {
|
||||
const vatPercentage = vat / 100
|
||||
const priceExclVat = priceInclVat / (1 + vatPercentage)
|
||||
const vatAmount = priceInclVat - priceExclVat
|
||||
return {
|
||||
priceExclVat,
|
||||
vatAmount,
|
||||
}
|
||||
}
|
||||
|
||||
export function filterOverlappingDates<
|
||||
T extends {
|
||||
startDate: Date | Dayjs | string | undefined | null
|
||||
endDate: Date | Dayjs | string | undefined | null
|
||||
},
|
||||
>(dateRangeItems: T[], fromDate: Date | Dayjs, toDate: Date | Dayjs) {
|
||||
const startDate = dt(fromDate)
|
||||
const endDate = dt(toDate)
|
||||
|
||||
return dateRangeItems.filter((item) =>
|
||||
hasOverlappingDates(item, startDate, endDate)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasOverlappingDates(
|
||||
dateRangeItem: {
|
||||
startDate: Date | Dayjs | string | undefined | null
|
||||
endDate: Date | Dayjs | string | undefined | null
|
||||
},
|
||||
fromDate: Date | Dayjs,
|
||||
toDate: Date | Dayjs
|
||||
) {
|
||||
const startDate = dt(fromDate)
|
||||
const endDate = dt(toDate)
|
||||
|
||||
if (dateRangeItem.endDate && dateRangeItem.startDate) {
|
||||
const itemStartDate = dt(dateRangeItem.startDate)
|
||||
const itemEndDate = dt(dateRangeItem.endDate)
|
||||
|
||||
const fromDateIsBetweenItemDates = startDate.isBetween(
|
||||
itemStartDate,
|
||||
itemEndDate,
|
||||
"date",
|
||||
"[]"
|
||||
)
|
||||
const toDateIsBetweenItemDates = endDate.isBetween(
|
||||
itemStartDate,
|
||||
itemEndDate,
|
||||
"date",
|
||||
"[]"
|
||||
)
|
||||
|
||||
const itemFullyContained =
|
||||
startDate.isSameOrBefore(itemStartDate, "date") &&
|
||||
endDate.isSameOrAfter(itemEndDate, "date")
|
||||
|
||||
return (
|
||||
fromDateIsBetweenItemDates ||
|
||||
toDateIsBetweenItemDates ||
|
||||
itemFullyContained
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import isEqual from "fast-deep-equal"
|
||||
|
||||
import {
|
||||
parseBookingWidgetSearchParams,
|
||||
searchParamsToRecord,
|
||||
type SelectRateBooking,
|
||||
} from "./url"
|
||||
import { parseBookingWidgetSearchParams, searchParamsToRecord } from "./url"
|
||||
|
||||
import type { BookingWidgetSearchData } from "../components/BookingWidget"
|
||||
import type { SelectRateBooking } from "../types/components/selectRate/selectRate"
|
||||
|
||||
/**
|
||||
* Parses and compares booking widget search parameters
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Child } from "@scandic-hotels/trpc/types/child"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { NextSearchParams } from "../types"
|
||||
import type { SelectRateBooking } from "../types/components/selectRate/selectRate"
|
||||
|
||||
type PartialRoom = { rooms?: Partial<Room>[] }
|
||||
|
||||
@@ -267,15 +268,6 @@ export type SelectHotelBooking = {
|
||||
bookingCode?: string
|
||||
searchType?: BookingSearchType
|
||||
}
|
||||
export type SelectRateBooking = {
|
||||
bookingCode?: string
|
||||
city?: string
|
||||
fromDate: string
|
||||
hotelId: string
|
||||
rooms: Room[]
|
||||
searchType?: BookingSearchType
|
||||
toDate: string
|
||||
}
|
||||
export interface Room {
|
||||
adults: number
|
||||
childrenInRoom?: Child[]
|
||||
|
||||
@@ -33,13 +33,20 @@
|
||||
"./components/SidePeekAccordions/CheckInCheckOutAccordionItem": "./lib/components/SidePeekAccordions/CheckInCheckOutAccordionItem.tsx",
|
||||
"./components/SidePeekAccordions/ParkingAccordionItem": "./lib/components/SidePeekAccordions/ParkingAccordionItem.tsx",
|
||||
"./components/TripAdvisorChip": "./lib/components/TripAdvisorChip/index.tsx",
|
||||
"./contexts/SelectRate/getTotalPrice": "./lib/contexts/SelectRate/getTotalPrice.ts",
|
||||
"./contexts/SelectRate/SelectRateContext": "./lib/contexts/SelectRate/SelectRateContext.tsx",
|
||||
"./contexts/SelectRate/Room": "./lib/contexts/SelectRate/Room.ts",
|
||||
"./contexts/SelectRate/types": "./lib/contexts/SelectRate/types.ts",
|
||||
"./hooks/useSearchHistory": "./lib/hooks/useSearchHistory.ts",
|
||||
"./pages/*": "./lib/pages/*.tsx",
|
||||
"./searchType": "./lib/misc/searchType.ts",
|
||||
"./stores/bookingCode-filter": "./lib/stores/bookingCode-filter.ts",
|
||||
"./stores/hotels-map": "./lib/stores/hotels-map.ts",
|
||||
"./types/components/selectRate/selectRate": "./lib/types/components/selectRate/selectRate.ts",
|
||||
"./types/stores/rates": "./lib/types/stores/rates.ts",
|
||||
"./utils/isSameBooking": "./lib/utils/isSameBooking.ts",
|
||||
"./utils/url": "./lib/utils/url.ts"
|
||||
"./utils/url": "./lib/utils/url.ts",
|
||||
"./utils/SelectRate": "./lib/utils/SelectRate/index.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
@@ -53,6 +60,7 @@
|
||||
"fast-deep-equal": "^3.1.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"motion": "^12.10.0",
|
||||
"nuqs": "^2.4.3",
|
||||
"react-aria-components": "^1.8.0",
|
||||
"react-day-picker": "^9.6.7",
|
||||
"react-hook-form": "^7.56.2",
|
||||
|
||||
Reference in New Issue
Block a user