diff --git a/packages/booking-flow/.env.test b/packages/booking-flow/.env.test index e69de29bb..ee2c923f8 100644 --- a/packages/booking-flow/.env.test +++ b/packages/booking-flow/.env.test @@ -0,0 +1 @@ +TZ=UTC \ No newline at end of file diff --git a/packages/booking-flow/lib/components/SelectRate/RoomsContainer/index.tsx b/packages/booking-flow/lib/components/SelectRate/RoomsContainer/index.tsx index 4c4a0a894..9d0dedfa5 100644 --- a/packages/booking-flow/lib/components/SelectRate/RoomsContainer/index.tsx +++ b/packages/booking-flow/lib/components/SelectRate/RoomsContainer/index.tsx @@ -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, diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.test.ts b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.test.ts new file mode 100644 index 000000000..6e0c5c314 --- /dev/null +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.test.ts @@ -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) + }) +}) diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.ts b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.ts new file mode 100644 index 000000000..02770a33e --- /dev/null +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/calculateNumberOfNights.ts @@ -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") +} diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.test.ts b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.test.ts new file mode 100644 index 000000000..cf93a409b --- /dev/null +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.test.ts @@ -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 }) + }) +}) diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.ts b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.ts new file mode 100644 index 000000000..da11dac18 --- /dev/null +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/getLowestRoomPrice.ts @@ -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 + | Pick + | Pick + | Pick + +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 +} diff --git a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/index.tsx similarity index 90% rename from packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx rename to packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/index.tsx index 80dc7c4ac..8846e6e3a 100644 --- a/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext.tsx +++ b/packages/booking-flow/lib/contexts/SelectRate/SelectRateContext/index.tsx @@ -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( {} 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 diff --git a/packages/booking-flow/lib/contexts/SelectRate/types.ts b/packages/booking-flow/lib/contexts/SelectRate/types.ts index 8a2d2f0b5..f5d752227 100644 --- a/packages/booking-flow/lib/contexts/SelectRate/types.ts +++ b/packages/booking-flow/lib/contexts/SelectRate/types.ts @@ -35,7 +35,7 @@ export type SelectRateContext = { getLowestRoomPrice: () => { price: number currency: CurrencyEnum - } + } | null getPackagesForRoom: (roomIndex: number) => { selectedPackages: RoomPackage[]