fix(SW-3442) getLowestRoomPrice - cannot read property of undefined
* fix: getLowestRoomPrice throws when given unexpected data * dont track lowestRoomPrice if unavailable Approved-by: Hrishikesh Vaipurkar
This commit is contained in:
@@ -0,0 +1 @@
|
||||
TZ=UTC
|
||||
@@ -36,7 +36,7 @@ export function RoomsContainer({}: RoomsContainerProps) {
|
||||
const lowestRoomPrice = getLowestRoomPrice()
|
||||
const booking = inputData?.booking
|
||||
|
||||
if (!booking) return
|
||||
if (!booking || !lowestRoomPrice) return
|
||||
|
||||
trackLowestRoomPrice({
|
||||
hotelId: booking.hotelId,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { calculateNumberOfNights } from "./calculateNumberOfNights"
|
||||
|
||||
describe("calculateNumberOfNights", () => {
|
||||
it("returns 0 when fromDate is undefined", () => {
|
||||
expect(calculateNumberOfNights(undefined, "2023-01-02")).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 when toDate is undefined", () => {
|
||||
expect(calculateNumberOfNights("2023-01-01", undefined)).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 for the same date (string)", () => {
|
||||
expect(calculateNumberOfNights("2023-01-01", "2023-01-01")).toBe(0)
|
||||
})
|
||||
|
||||
it("calculates nights for string date inputs", () => {
|
||||
expect(calculateNumberOfNights("2023-01-01", "2023-01-05")).toBe(4)
|
||||
})
|
||||
|
||||
it("calculates nights for Date object inputs", () => {
|
||||
expect(
|
||||
calculateNumberOfNights(
|
||||
new Date("2023-02-10T00:00:00Z"),
|
||||
new Date("2023-02-12T00:00:00Z")
|
||||
)
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
it("handles mixed Date and string inputs", () => {
|
||||
expect(calculateNumberOfNights(new Date("2023-03-01"), "2023-03-04")).toBe(
|
||||
3
|
||||
)
|
||||
})
|
||||
|
||||
it("returns a negative number when fromDate is after toDate", () => {
|
||||
expect(calculateNumberOfNights("2023-01-05", "2023-01-01")).toBe(-4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
|
||||
export function calculateNumberOfNights(
|
||||
fromDate: string | Date | undefined,
|
||||
toDate: string | Date | undefined
|
||||
): number {
|
||||
if (!fromDate || !toDate) return 0
|
||||
|
||||
return dt(toDate).diff(dt(fromDate), "day")
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import { getLowestRoomPrice } from "./getLowestRoomPrice"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "../types"
|
||||
|
||||
describe("getLowestRoomPrice", () => {
|
||||
it("returns null when roomAvailability is empty", () => {
|
||||
const res = getLowestRoomPrice([])
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when first row has no rooms (only nulls)", () => {
|
||||
const roomAvailability = [[null, null], [{ products: [] }]]
|
||||
const res = getLowestRoomPrice(
|
||||
roomAvailability as (AvailabilityWithRoomInfo | null)[][]
|
||||
)
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
|
||||
it("returns undefined when first room has no price products", () => {
|
||||
const mixedProduct = {
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 50 },
|
||||
},
|
||||
}
|
||||
const firstRoom = { products: [mixedProduct] }
|
||||
const roomAvailability = [
|
||||
[
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ brokenData: 1 } as any,
|
||||
firstRoom,
|
||||
],
|
||||
]
|
||||
const res = getLowestRoomPrice(roomAvailability)
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 50 })
|
||||
})
|
||||
|
||||
it("returns the first price product converted to { currency, price }", () => {
|
||||
const publicProduct = {
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
|
||||
},
|
||||
}
|
||||
const memberProduct = {
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 900 },
|
||||
},
|
||||
}
|
||||
const firstRoom = { products: [publicProduct, memberProduct] }
|
||||
const roomAvailability = [[firstRoom], [{ products: [] }]]
|
||||
const res = getLowestRoomPrice(
|
||||
roomAvailability as unknown as (AvailabilityWithRoomInfo | null)[][]
|
||||
)
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 900 })
|
||||
})
|
||||
|
||||
it("prefers member/public values from the product object when present", () => {
|
||||
// product with both member and public: member values should be used for currency/price resolution
|
||||
const mixedProduct = {
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
|
||||
},
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
|
||||
},
|
||||
}
|
||||
const firstRoom = { products: [mixedProduct] }
|
||||
const roomAvailability = [[null, firstRoom]]
|
||||
const res = getLowestRoomPrice(
|
||||
roomAvailability as (AvailabilityWithRoomInfo | null)[][]
|
||||
)
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 800 })
|
||||
})
|
||||
|
||||
it("prefers member/public values from the product object when present", () => {
|
||||
// product with both member and public: member values should be used for currency/price resolution
|
||||
const mixedProduct = {
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1200 },
|
||||
},
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
|
||||
},
|
||||
}
|
||||
const firstRate = { products: [mixedProduct] }
|
||||
const firstRoom = [null, firstRate]
|
||||
const res = getLowestRoomPrice([
|
||||
firstRoom,
|
||||
] as (AvailabilityWithRoomInfo | null)[][])
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 800 })
|
||||
})
|
||||
|
||||
it("gets the lowest price even if the order is mixed", () => {
|
||||
const firstRate = {
|
||||
products: [
|
||||
{
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1200 },
|
||||
},
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const secondRate = {
|
||||
products: [
|
||||
{
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 1000 },
|
||||
},
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 700 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const firstRoom = [firstRate, secondRate]
|
||||
const secondRoom = [null, firstRate, secondRate]
|
||||
const roomAvailability = [
|
||||
null,
|
||||
firstRoom,
|
||||
secondRoom,
|
||||
] as (AvailabilityWithRoomInfo | null)[][]
|
||||
const res = getLowestRoomPrice(roomAvailability)
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 700 })
|
||||
})
|
||||
|
||||
it("gets the lowest price even if the public price is lower than member price", () => {
|
||||
const firstRate = {
|
||||
products: [
|
||||
{
|
||||
public: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 700 },
|
||||
},
|
||||
member: {
|
||||
localPrice: { currency: CurrencyEnum.SEK, pricePerNight: 800 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const firstRoom = [firstRate]
|
||||
const roomAvailability = [
|
||||
firstRoom,
|
||||
] as (AvailabilityWithRoomInfo | null)[][]
|
||||
const res = getLowestRoomPrice(roomAvailability)
|
||||
expect(res).toEqual({ currency: CurrencyEnum.SEK, price: 700 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
|
||||
import type {
|
||||
CorporateChequeProduct,
|
||||
PriceProduct,
|
||||
RedemptionProduct,
|
||||
VoucherProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "../types"
|
||||
|
||||
export function getLowestRoomPrice(
|
||||
roomAvailability: (AvailabilityWithRoomInfo | null)[][]
|
||||
): { currency: CurrencyEnum; price: number } | null {
|
||||
const products = roomAvailability
|
||||
?.flatMap((room) => room?.flatMap((rates) => rates?.products))
|
||||
.filter((x) => !!x)
|
||||
|
||||
if (!products || !products.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sorted = sortRates(products as PriceInfo[])
|
||||
|
||||
return sorted.at(0) ?? null
|
||||
}
|
||||
|
||||
type PriceInfo =
|
||||
| Pick<PriceProduct, "member" | "public">
|
||||
| Pick<RedemptionProduct, "redemption">
|
||||
| Pick<CorporateChequeProduct, "corporateCheque">
|
||||
| Pick<VoucherProduct, "voucher">
|
||||
|
||||
function sortRates(rates: PriceInfo[]) {
|
||||
const mapped = rates.map((rate) => getPriceFromProduct(rate))
|
||||
return mapped
|
||||
.filter((x) => !!x)
|
||||
.toSorted((a, b) => {
|
||||
return a.price - b.price
|
||||
})
|
||||
}
|
||||
|
||||
function getPriceFromProduct(product: PriceInfo): {
|
||||
currency: CurrencyEnum
|
||||
price: number
|
||||
} | null {
|
||||
if (
|
||||
("public" in product && product.public) ||
|
||||
("member" in product && product.member)
|
||||
) {
|
||||
if (!product.public && !product.member) {
|
||||
return null
|
||||
}
|
||||
const minPrice = Math.min(
|
||||
product.member?.localPrice.pricePerNight || Infinity,
|
||||
product.public?.localPrice.pricePerNight || Infinity
|
||||
)
|
||||
|
||||
return {
|
||||
currency:
|
||||
(product.member?.localPrice.currency as CurrencyEnum) ||
|
||||
(product.public?.localPrice.currency as CurrencyEnum) ||
|
||||
CurrencyEnum.Unknown,
|
||||
price: minPrice === Infinity ? 0 : minPrice,
|
||||
}
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
return {
|
||||
currency: CurrencyEnum.Voucher,
|
||||
price: product.voucher?.numberOfVouchers,
|
||||
}
|
||||
}
|
||||
|
||||
if ("corporateCheque" in product) {
|
||||
return {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: product.corporateCheque?.localPrice.numberOfCheques,
|
||||
}
|
||||
}
|
||||
|
||||
if ("redemption" in product) {
|
||||
return {
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: product.redemption.localPrice.pointsPerNight,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -12,34 +12,34 @@ import {
|
||||
import { type IntlShape, useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema"
|
||||
|
||||
import { useIsLoggedIn } from "../../hooks/useIsLoggedIn"
|
||||
import useLang from "../../hooks/useLang"
|
||||
import { BookingCodeFilterEnum } from "../../stores/bookingCode-filter"
|
||||
import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
|
||||
import useLang from "../../../hooks/useLang"
|
||||
import { BookingCodeFilterEnum } from "../../../stores/bookingCode-filter"
|
||||
import {
|
||||
parseSelectRateSearchParams,
|
||||
searchParamsToRecord,
|
||||
serializeBookingSearchParams,
|
||||
} from "../../utils/url"
|
||||
import { clearRooms } from "./clearRooms"
|
||||
import { DebugButton } from "./DebugButton"
|
||||
import { findUnavailableSelectedRooms } from "./findUnavailableSelectedRooms"
|
||||
import { getSelectedPackages } from "./getSelectedPackages"
|
||||
import { getTotalPrice } from "./getTotalPrice"
|
||||
import { includeRoomInfo } from "./includeRoomInfo"
|
||||
import { isRateSelected as isRateSelected_Inner } from "./isRateSelected"
|
||||
} from "../../../utils/url"
|
||||
import { clearRooms } from "../clearRooms"
|
||||
import { DebugButton } from "../DebugButton"
|
||||
import { findUnavailableSelectedRooms } from "../findUnavailableSelectedRooms"
|
||||
import { getSelectedPackages } from "../getSelectedPackages"
|
||||
import { getTotalPrice } from "../getTotalPrice"
|
||||
import { includeRoomInfo } from "../includeRoomInfo"
|
||||
import { isRateSelected as isRateSelected_Inner } from "../isRateSelected"
|
||||
import { calculateNumberOfNights } from "./calculateNumberOfNights"
|
||||
import { getLowestRoomPrice } from "./getLowestRoomPrice"
|
||||
|
||||
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { SelectRateBooking } from "../../types/components/selectRate/selectRate"
|
||||
import type { Price } from "../../types/price"
|
||||
import type { SelectRateBooking } from "../../../types/components/selectRate/selectRate"
|
||||
import type { Price } from "../../../types/price"
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
DefaultRoomPackage,
|
||||
@@ -47,7 +47,7 @@ import type {
|
||||
RoomPackage,
|
||||
SelectedRate,
|
||||
SelectRateContext,
|
||||
} from "./types"
|
||||
} from "../types"
|
||||
|
||||
const SelectRateContext = createContext<SelectRateContext>(
|
||||
{} as SelectRateContext
|
||||
@@ -514,37 +514,6 @@ const getDefaultRoomPackages = (intl: IntlShape): DefaultRoomPackage[] =>
|
||||
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 getLowestRoomPrice(
|
||||
roomAvailability: (AvailabilityWithRoomInfo | null)[][]
|
||||
) {
|
||||
// First room is always cheapest room because sort by price is default
|
||||
const firstRoomAvailability = roomAvailability[0]
|
||||
return firstRoomAvailability
|
||||
.filter((room) => !!room)[0]
|
||||
.products.filter(
|
||||
(product): product is PriceProduct =>
|
||||
!!(
|
||||
("public" in product && product.public) ||
|
||||
("member" in product && product.member)
|
||||
)
|
||||
)
|
||||
.map((product) => ({
|
||||
currency: (product.member?.localPrice.currency ||
|
||||
product.public?.localPrice.currency)!,
|
||||
price: (product.member?.localPrice.pricePerNight ||
|
||||
product.public?.localPrice.pricePerNight)!,
|
||||
}))[0]
|
||||
}
|
||||
|
||||
function getAvailabilityForRoom(
|
||||
roomIndex: number,
|
||||
roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined
|
||||
@@ -35,7 +35,7 @@ export type SelectRateContext = {
|
||||
getLowestRoomPrice: () => {
|
||||
price: number
|
||||
currency: CurrencyEnum
|
||||
}
|
||||
} | null
|
||||
|
||||
getPackagesForRoom: (roomIndex: number) => {
|
||||
selectedPackages: RoomPackage[]
|
||||
|
||||
Reference in New Issue
Block a user