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:
Hrishikesh Vaipurkar
2025-09-02 07:40:01 +00:00
parent 1804f7b7cd
commit 0a4bf40a15
77 changed files with 127 additions and 148 deletions

View File

@@ -1,68 +0,0 @@
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
}

View File

@@ -1,13 +0,0 @@
import { createContext, useContext } from "react"
import type { RoomContextValue } from "@/types/contexts/select-rate/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
}

View File

@@ -1,579 +0,0 @@
"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 ?? 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
)
}

View File

@@ -1,31 +0,0 @@
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 }
}

View File

@@ -1,26 +0,0 @@
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
}

View File

@@ -1,72 +0,0 @@
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" }])
})
})

View File

@@ -1,23 +0,0 @@
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
})
}

View File

@@ -1,21 +0,0 @@
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,
})
})
})

View File

@@ -1,273 +0,0 @@
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 {
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,
}: {
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,
}
)
}

View File

@@ -1,106 +0,0 @@
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()
})
})

View File

@@ -1,39 +0,0 @@
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
})
}

View File

@@ -1,117 +0,0 @@
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)
})
})

View File

@@ -1,41 +0,0 @@
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()
)
}

View File

@@ -1,134 +0,0 @@
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
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 }