Merged in feature/select-rate-vertical-data-flow (pull request #2535)
Feature/select rate vertical data flow * add fix from SW-2666 * use translations for room packages * move types to it's own file * Merge branch 'master' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow * merge * feature/select-rate: double rate for campaing rates * revert NODE_ENV check in Cookiebot script * revert testing values * fix(SW-3171): fix all filter selected in price details * fix(SW-3166): multiroom anchoring when changing filter * fix(SW-3172): check hotelType, show correct breakfast message * Merge branch 'feature/select-rate-vertical-data-flow' of bitbucket.org:scandic-swap/web into feature/select-rate-vertical-data-flow * fix: show special needs icons for subsequent roomTypes SW-3167 * fix: Display strike through text when logged in SW-3168 * fix: Reinstate the scrollToView behaviour when selecting a rate SW-3169 * merge * . * PR fixes * fix: don't return notFound() * . * always include defaults for room packages * merge * merge * merge * Remove floating h1 for new select-rate Approved-by: Anton Gunnarsson
This commit is contained in:
68
apps/scandic-web/contexts/SelectRate/DebugButton.tsx
Normal file
68
apps/scandic-web/contexts/SelectRate/DebugButton.tsx
Normal file
@@ -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
|
||||
}
|
||||
565
apps/scandic-web/contexts/SelectRate/SelectRateContext.tsx
Normal file
565
apps/scandic-web/contexts/SelectRate/SelectRateContext.tsx
Normal file
@@ -0,0 +1,565 @@
|
||||
"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 { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import {
|
||||
parseSelectRateSearchParams,
|
||||
searchParamsToRecord,
|
||||
serializeBookingSearchParams,
|
||||
} from "@scandic-hotels/booking-flow/utils/url"
|
||||
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 { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
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/hotelReservation/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 = useIsUserLoggedIn()
|
||||
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
|
||||
if (hotelId !== hotelData.hotel.id) {
|
||||
throw new Error("Mismatched hotel ID in SelectRateProvider")
|
||||
}
|
||||
|
||||
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,
|
||||
intl,
|
||||
})
|
||||
|
||||
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,
|
||||
intl,
|
||||
})
|
||||
},
|
||||
[selectedRates, roomAvailability, isUserLoggedIn, intl]
|
||||
)
|
||||
|
||||
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
|
||||
),
|
||||
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")
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{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
apps/scandic-web/contexts/SelectRate/clearRooms.ts
Normal file
31
apps/scandic-web/contexts/SelectRate/clearRooms.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SelectRateBooking } from "@/types/components/hotelReservation/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" }])
|
||||
})
|
||||
})
|
||||
23
apps/scandic-web/contexts/SelectRate/getSelectedPackages.ts
Normal file
23
apps/scandic-web/contexts/SelectRate/getSelectedPackages.ts
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
30
apps/scandic-web/contexts/SelectRate/getTotalPrice.test.ts
Normal file
30
apps/scandic-web/contexts/SelectRate/getTotalPrice.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getTotalPrice } from "./getTotalPrice"
|
||||
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
const mockIntl = {
|
||||
formatMessage: ({ defaultMessage }: { defaultMessage: string }) => {
|
||||
return defaultMessage
|
||||
},
|
||||
} as IntlShape
|
||||
|
||||
describe("getTotalPrice", () => {
|
||||
it("should return null when no rates are selected", () => {
|
||||
const result = getTotalPrice({
|
||||
selectedRates: [],
|
||||
useMemberPrices: false,
|
||||
intl: mockIntl,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
local: {
|
||||
currency: "Unknown",
|
||||
price: 0,
|
||||
regularPrice: undefined,
|
||||
},
|
||||
requested: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
280
apps/scandic-web/contexts/SelectRate/getTotalPrice.ts
Normal file
280
apps/scandic-web/contexts/SelectRate/getTotalPrice.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import { sumPackages } from "@/components/HotelReservation/utils"
|
||||
|
||||
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Rate,
|
||||
RoomPackage,
|
||||
} from "@/contexts/SelectRate/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,
|
||||
intl,
|
||||
}: {
|
||||
selectedRates: Array<SelectedRate | null>
|
||||
useMemberPrices: boolean
|
||||
intl: IntlShape
|
||||
}): 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)
|
||||
// TODO: This is a workaround, should be handled where we print the price.
|
||||
voucherPrice.local.currency = intl.formatMessage({
|
||||
defaultMessage: "Voucher",
|
||||
}) as CurrencyEnum
|
||||
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,
|
||||
}
|
||||
)
|
||||
}
|
||||
106
apps/scandic-web/contexts/SelectRate/includeRoomInfo.test.ts
Normal file
106
apps/scandic-web/contexts/SelectRate/includeRoomInfo.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
39
apps/scandic-web/contexts/SelectRate/includeRoomInfo.ts
Normal file
39
apps/scandic-web/contexts/SelectRate/includeRoomInfo.ts
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
117
apps/scandic-web/contexts/SelectRate/isRateSelected.test.ts
Normal file
117
apps/scandic-web/contexts/SelectRate/isRateSelected.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/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)
|
||||
})
|
||||
})
|
||||
41
apps/scandic-web/contexts/SelectRate/isRateSelected.ts
Normal file
41
apps/scandic-web/contexts/SelectRate/isRateSelected.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { RateEnum } from "@scandic-hotels/trpc/enums/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()
|
||||
)
|
||||
}
|
||||
132
apps/scandic-web/contexts/SelectRate/types.ts
Normal file
132
apps/scandic-web/contexts/SelectRate/types.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { type RouterOutput } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import { type Price } from "./getTotalPrice"
|
||||
|
||||
import type { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
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"
|
||||
|
||||
export type SelectRateContext = {
|
||||
hotel: QueryData<RouterOutput["hotel"]["get"]>
|
||||
availability: QueryData<
|
||||
RouterOutput["hotel"]["availability"]["selectRate"]["rooms"]
|
||||
>
|
||||
input: {
|
||||
data: RoomsAvailabilityOutputSchema | undefined
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
Reference in New Issue
Block a user