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 lowestRoomPrice = getLowestRoomPrice()
|
||||||
const booking = inputData?.booking
|
const booking = inputData?.booking
|
||||||
|
|
||||||
if (!booking) return
|
if (!booking || !lowestRoomPrice) return
|
||||||
|
|
||||||
trackLowestRoomPrice({
|
trackLowestRoomPrice({
|
||||||
hotelId: booking.hotelId,
|
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 { type IntlShape, useIntl } from "react-intl"
|
||||||
|
|
||||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||||
import { dt } from "@scandic-hotels/common/dt"
|
|
||||||
import { logger } from "@scandic-hotels/common/logger"
|
import { logger } from "@scandic-hotels/common/logger"
|
||||||
import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client"
|
import { type RouterOutput, trpc } from "@scandic-hotels/trpc/client"
|
||||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||||
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema"
|
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/availability/selectRate/rooms/schema"
|
||||||
|
|
||||||
import { useIsLoggedIn } from "../../hooks/useIsLoggedIn"
|
import { useIsLoggedIn } from "../../../hooks/useIsLoggedIn"
|
||||||
import useLang from "../../hooks/useLang"
|
import useLang from "../../../hooks/useLang"
|
||||||
import { BookingCodeFilterEnum } from "../../stores/bookingCode-filter"
|
import { BookingCodeFilterEnum } from "../../../stores/bookingCode-filter"
|
||||||
import {
|
import {
|
||||||
parseSelectRateSearchParams,
|
parseSelectRateSearchParams,
|
||||||
searchParamsToRecord,
|
searchParamsToRecord,
|
||||||
serializeBookingSearchParams,
|
serializeBookingSearchParams,
|
||||||
} from "../../utils/url"
|
} from "../../../utils/url"
|
||||||
import { clearRooms } from "./clearRooms"
|
import { clearRooms } from "../clearRooms"
|
||||||
import { DebugButton } from "./DebugButton"
|
import { DebugButton } from "../DebugButton"
|
||||||
import { findUnavailableSelectedRooms } from "./findUnavailableSelectedRooms"
|
import { findUnavailableSelectedRooms } from "../findUnavailableSelectedRooms"
|
||||||
import { getSelectedPackages } from "./getSelectedPackages"
|
import { getSelectedPackages } from "../getSelectedPackages"
|
||||||
import { getTotalPrice } from "./getTotalPrice"
|
import { getTotalPrice } from "../getTotalPrice"
|
||||||
import { includeRoomInfo } from "./includeRoomInfo"
|
import { includeRoomInfo } from "../includeRoomInfo"
|
||||||
import { isRateSelected as isRateSelected_Inner } from "./isRateSelected"
|
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 { 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 { SelectRateBooking } from "../../../types/components/selectRate/selectRate"
|
||||||
import type { Price } from "../../types/price"
|
import type { Price } from "../../../types/price"
|
||||||
import type {
|
import type {
|
||||||
AvailabilityWithRoomInfo,
|
AvailabilityWithRoomInfo,
|
||||||
DefaultRoomPackage,
|
DefaultRoomPackage,
|
||||||
@@ -47,7 +47,7 @@ import type {
|
|||||||
RoomPackage,
|
RoomPackage,
|
||||||
SelectedRate,
|
SelectedRate,
|
||||||
SelectRateContext,
|
SelectRateContext,
|
||||||
} from "./types"
|
} from "../types"
|
||||||
|
|
||||||
const SelectRateContext = createContext<SelectRateContext>(
|
const SelectRateContext = createContext<SelectRateContext>(
|
||||||
{} as SelectRateContext
|
{} as SelectRateContext
|
||||||
@@ -514,37 +514,6 @@ const getDefaultRoomPackages = (intl: IntlShape): DefaultRoomPackage[] =>
|
|||||||
inventories: [],
|
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(
|
function getAvailabilityForRoom(
|
||||||
roomIndex: number,
|
roomIndex: number,
|
||||||
roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined
|
roomAvailability: (AvailabilityWithRoomInfo | null)[][] | undefined
|
||||||
@@ -35,7 +35,7 @@ export type SelectRateContext = {
|
|||||||
getLowestRoomPrice: () => {
|
getLowestRoomPrice: () => {
|
||||||
price: number
|
price: number
|
||||||
currency: CurrencyEnum
|
currency: CurrencyEnum
|
||||||
}
|
} | null
|
||||||
|
|
||||||
getPackagesForRoom: (roomIndex: number) => {
|
getPackagesForRoom: (roomIndex: number) => {
|
||||||
selectedPackages: RoomPackage[]
|
selectedPackages: RoomPackage[]
|
||||||
|
|||||||
Reference in New Issue
Block a user