Merged in feat/SW-1356-reward-night-booking-2- (pull request #1559)

feat: SW-1356 Reward night bookingflow

* feat: SW-1356 Reward night bookingflow

* feat: SW-1356 Removed extra param booking call

* feat: SW-1356 Optimized as review comments

* feat: SW-1356 Schema validation updates

* feat: SW-1356 Fix after rebase

* feat: SW-1356 Optimised price.redemptions check

* feat: SW-1356 Updated Props naming


Approved-by: Arvid Norlin
This commit is contained in:
Hrishikesh Vaipurkar
2025-03-24 08:54:02 +00:00
parent b972679c6e
commit c5e294c7ea
57 changed files with 1113 additions and 657 deletions

View File

@@ -1,6 +1,7 @@
import { notFound, redirect } from "next/navigation"
import { Suspense } from "react"
import { REDEMPTION } from "@/constants/booking"
import { selectRate } from "@/constants/routes/hotelReservation"
import {
getBreakfastPackages,
@@ -83,6 +84,7 @@ export default async function DetailsPage({
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
roomTypeCode: room.roomTypeCode,
redemption: booking.searchType === REDEMPTION,
})
if (!roomAvailability) {
@@ -107,6 +109,7 @@ export default async function DetailsPage({
roomRate: {
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
redemptionRate: roomAvailability.redemptionRate,
},
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,

View File

@@ -30,9 +30,7 @@ export default function Voucher() {
return (
<div className={styles.optionsContainer}>
<div className={styles.vouchers}>
<BookingCode />
</div>
<BookingCode />
<div className={styles.options}>
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
<>
@@ -81,7 +79,6 @@ export function VoucherSkeleton() {
const intl = useIntl()
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
const reward = intl.formatMessage({ id: "Book Reward Night" })
const form = useForm()
@@ -89,7 +86,7 @@ export function VoucherSkeleton() {
return (
<FormProvider {...form}>
<div className={styles.optionsContainer}>
<div className={styles.vouchers}>
<div>
<label>
<Caption type="bold" color="red" asChild>
<span>{vouchers}</span>
@@ -98,21 +95,11 @@ export function VoucherSkeleton() {
<SkeletonShimmer width={"100%"} />
</div>
<div className={styles.options}>
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? null : (
<div className={styles.option}>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
</div>
)}
<div className={styles.option}>
<Checkbox name="redemption">
<Caption color="uiTextMediumContrast" asChild>
<span>{reward}</span>
</Caption>
</Checkbox>
<SkeletonShimmer width="24px" height="24px" />
<Caption color="uiTextMediumContrast" asChild>
<span>{reward}</span>
</Caption>
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@ import { formId } from "@/components/HotelReservation/EnterDetails/Payment/Payme
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
import styles from "./bottomSheet.module.css"
@@ -57,10 +57,12 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
>
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
<Subtitle>
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">

View File

@@ -20,7 +20,10 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import {
formatPrice,
formatPriceWithAdditionalPrice,
} from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable"
@@ -170,10 +173,12 @@ export default function SummaryUI({
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
: formatPriceWithAdditionalPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</Body>
</div>
@@ -383,10 +388,12 @@ export default function SummaryUI({
</div>
<div>
<Body textTransform="bold" data-testid="total-price">
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Body>
{totalPrice.local.regularPrice ? (

View File

@@ -3,17 +3,16 @@ import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelPointsCard.module.css"
import styles from "./hotelPointsRow.module.css"
import type { PointsCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
import type { PointsRowProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
export default function HotelPointsCard({
productTypePoints,
redemptionPrice,
}: PointsCardProps) {
export default function HotelPointsRow({
pointsPerStay,
additionalPricePerStay,
additionalPriceCurrency,
}: PointsRowProps) {
const intl = useIntl()
const pointsPerStay =
productTypePoints?.localPrice.pointsPerStay ?? redemptionPrice
return (
<div className={styles.poinstRow}>
@@ -23,14 +22,14 @@ export default function HotelPointsCard({
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Points" })}
</Caption>
{productTypePoints?.localPrice.pricePerStay ? (
{additionalPricePerStay ? (
<>
+
<Subtitle type="two" color="uiTextHighContrast">
{productTypePoints.localPrice.pricePerStay}
{additionalPricePerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{productTypePoints.localPrice.currency}
{additionalPriceCurrency}
</Caption>
</>
) : null}

View File

@@ -22,7 +22,7 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelPointsCard from "./HotelPointsCard"
import HotelPointsRow from "./HotelPointsRow"
import HotelPriceCard from "./HotelPriceCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
@@ -154,9 +154,7 @@ function HotelCard({
) : (
<>
{bookingCode && (
<span
className={`${styles.bookingCode} ${fullPrice ? styles.strikedText : ""}`}
>
<span className={`${fullPrice ? styles.strikedText : ""}`}>
<PriceTagIcon height={20} width={20} />
{bookingCode}
</span>
@@ -173,21 +171,23 @@ function HotelCard({
isMemberPrice
/>
)}
{price?.redemption && (
{!!price?.redemptions?.length && (
<div className={styles.pointsCard}>
<Caption>
{intl.formatMessage({ id: "Available rates" })}
</Caption>
{/* Display rate with full points option */}
<HotelPointsCard productTypePoints={price.redemption} />
{/* Display rate with partial points option A */}
{price.redemptionA && (
<HotelPointsCard productTypePoints={price.redemptionA} />
)}
{/* Display rate with partial points option B */}
{price.redemptionB && (
<HotelPointsCard productTypePoints={price.redemptionB} />
)}
{price.redemptions.map((redemption) => (
<HotelPointsRow
key={redemption.rateCode}
pointsPerStay={redemption.localPrice.pointsPerStay}
additionalPricePerStay={
redemption.localPrice.additionalPricePerStay
}
additionalPriceCurrency={
redemption.localPrice.additionalPriceCurrency
}
/>
))}
</div>
)}
<Button

View File

@@ -11,7 +11,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { isValidClientSession } from "@/utils/clientSession"
import HotelPointsCard from "../../HotelCard/HotelPointsCard"
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
@@ -117,7 +117,7 @@ export default function ListingHotelCardDialog({
</Subtitle>
)}
{redemptionPrice && (
<HotelPointsCard redemptionPrice={redemptionPrice} />
<HotelPointsRow pointsPerStay={redemptionPrice} />
)}
</div>
</div>

View File

@@ -13,7 +13,7 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { isValidClientSession } from "@/utils/clientSession"
import HotelPointsCard from "../../HotelCard/HotelPointsCard"
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
import HotelCardDialogImage from "../HotelCardDialogImage"
@@ -132,7 +132,7 @@ export default function StandaloneHotelCardDialog({
</Subtitle>
)}
{redemptionPrice && (
<HotelPointsCard redemptionPrice={redemptionPrice} />
<HotelPointsRow pointsPerStay={redemptionPrice} />
)}
</div>
<Button

View File

@@ -20,7 +20,7 @@ export default function HotelCardDialogListing({
}: HotelCardDialogListingProps) {
const intl = useIntl()
const isRedemption = hotels?.find(
(hotel) => hotel.availability.productType?.redemption
(hotel) => hotel.availability.productType?.redemptions?.length
)
const currencyValue = isRedemption
? intl.formatMessage({ id: "Points" })

View File

@@ -11,6 +11,9 @@ export function getHotelPins(
return hotels.map(({ availability, hotel, additionalData }) => {
const productType = availability.productType
const redemptionRate = productType?.redemptions?.find(
(r) => r?.localPrice.pointsPerStay
)
return {
coordinates: {
lat: hotel.location.latitude,
@@ -19,8 +22,7 @@ export function getHotelPins(
name: hotel.name,
publicPrice: productType?.public?.localPrice.pricePerNight ?? null,
memberPrice: productType?.member?.localPrice.pricePerNight ?? null,
redemptionPrice:
productType?.redemption?.localPrice.pointsPerNight ?? null,
redemptionPrice: redemptionRate?.localPrice.pointsPerStay ?? null,
rateType:
productType?.public?.rateType ?? productType?.member?.rateType ?? null,
currency:

View File

@@ -5,6 +5,9 @@ function getPricePerNight(hotel: HotelResponse): number {
return (
hotel.availability.productType?.member?.localPrice?.pricePerNight ??
hotel.availability.productType?.public?.localPrice?.pricePerNight ??
hotel.availability.productType?.redemptions?.find(
(r) => r?.localPrice.pointsPerStay
)?.localPrice?.pointsPerStay ??
Infinity
)
}
@@ -49,7 +52,7 @@ export function getSortedHotels({
(hotel.availability.productType?.public?.rateType?.toLowerCase() !==
"regular" ||
hotel.availability.productType?.member?.rateType?.toLowerCase() !==
"regular") &&
"regular") &&
!!hotel.availability.productType
)
const regularHotels = hotels.filter(

View File

@@ -77,6 +77,12 @@ async function fetchAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCity(input)
}
async function fetchAvailableHotelsWithRedemption(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCityWithRedemption(
input
)
}
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCityWithBookingCode(
input
@@ -192,6 +198,22 @@ export async function getHotels(
})
})
)
} else if (redemption) {
availableHotelsResponse = await Promise.allSettled(
booking.rooms.map(
async (room) =>
await fetchAvailableHotelsWithRedemption({
adults: room.adults,
children: room.childrenInRoom
? generateChildrenString(room.childrenInRoom)
: undefined,
cityId: city.id,
redemption,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
})
)
)
} else {
availableHotelsResponse = await Promise.allSettled(
booking.rooms.map(

View File

@@ -18,7 +18,10 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import {
formatPrice,
formatPriceWithAdditionalPrice,
} from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable"
@@ -151,10 +154,12 @@ export default function Summary({
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showDiscounted ? "red" : "uiTextHighContrast"}>
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</Body>
</div>
@@ -269,10 +274,12 @@ export default function Summary({
textTransform="bold"
data-testid="total-price"
>
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Body>
{booking.bookingCode && totalPrice.local.regularPrice && (

View File

@@ -7,7 +7,7 @@ import { useRatesStore } from "@/stores/select-rate"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
import Summary from "./Summary"
@@ -144,11 +144,16 @@ export default function MobileSummary({
className={styles.priceDetailsButton}
>
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
{formatPrice(
<Subtitle
color={showDiscounted ? "red" : "uiTextHighContrast"}
className={styles.wrappedText}
>
{formatPriceWithAdditionalPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">

View File

@@ -67,6 +67,10 @@
z-index: 10;
}
.wrappedText {
white-space: normal;
}
@media screen and (min-width: 768px) {
.bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7);

View File

@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"
import { useState, useTransition } from "react"
import { useIntl } from "react-intl"
import { REDEMPTION } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate"
@@ -13,13 +14,20 @@ import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import {
formatPrice,
formatPriceWithAdditionalPrice,
} from "@/utils/numberFormatting"
import MobileSummary from "./MobileSummary"
import { calculateTotalPrice } from "./utils"
import styles from "./rateSummary.module.css"
import {
PointsPriceSchema,
type Price,
} from "@/types/components/hotelReservation/price"
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
@@ -27,6 +35,8 @@ import { RateTypeEnum } from "@/types/enums/rateType"
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const {
bookingCode,
isRedemption,
bookingRooms,
dates,
petRoomPackage,
@@ -34,6 +44,8 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
roomsAvailability,
searchParams,
} = useRatesStore((state) => ({
bookingCode: state.booking.bookingCode,
isRedemption: state.booking.searchType === REDEMPTION,
bookingRooms: state.booking.rooms,
dates: {
checkInDate: state.booking.fromDate,
@@ -58,7 +70,6 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const checkInDate = new Date(dates.checkInDate)
const checkOutDate = new Date(dates.checkOutDate)
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
const bookingCode = params.get("bookingCode")
const totalNights = intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
@@ -128,11 +139,11 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
)
const showDiscounted = isUserLoggedIn || isBookingCodeRate
const totalPriceToShow = calculateTotalPrice(
rateSummary,
isUserLoggedIn,
petRoomPackage
)
// In case of reward night (redemption) only single room booking is supported by business rules
const totalPriceToShow: Price =
isRedemption && rateSummary[0].redemption
? PointsPriceSchema.parse(rateSummary[0].redemption)
: calculateTotalPrice(rateSummary, isUserLoggedIn, petRoomPackage)
return (
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
@@ -231,10 +242,12 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
color={showDiscounted ? "red" : "uiTextHighContrast"}
textAlign="right"
>
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
{bookingCode && totalPriceToShow.local.regularPrice && (
@@ -270,10 +283,12 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
{intl.formatMessage({ id: "Total price" })}
</Caption>
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
{formatPrice(
{formatPriceWithAdditionalPrice(
intl,
totalPriceToShow.local.price,
totalPriceToShow.local.currency
totalPriceToShow.local.currency,
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
<Footnote

View File

@@ -19,7 +19,7 @@ export default function PriceList({
publicPrice = {},
memberPrice = {},
petRoomPackage,
rateTitle,
rateName,
}: PriceListProps) {
const intl = useIntl()
const { isMainRoom } = useRoomContext()
@@ -69,14 +69,14 @@ export default function PriceList({
)
const priceLabelColor =
rateTitle && !memberLocalPrice ? "red" : "uiTextHighContrast"
rateName && !memberLocalPrice ? "red" : "uiTextHighContrast"
return (
<dl className={styles.priceList}>
{isUserLoggedIn && isMainRoom && memberLocalPrice ? null : (
<div className={styles.priceRow}>
<dt>
{rateTitle ? null : (
{rateName ? null : (
<Caption
type="bold"
color={

View File

@@ -8,6 +8,11 @@
align-items: baseline;
}
.pointsRow {
justify-content: flex-start;
gap: var(--Spacing-x-half);
}
.priceTable {
margin: 0;
}

View File

@@ -53,6 +53,16 @@ input[type="radio"]:checked + .card .checkIcon {
right: -10px;
}
.title {
display: flex;
height: 24px;
padding: var(--Spacing-x-half) var(--Spacing-x1);
justify-content: center;
align-items: center;
gap: var(--Spacing-x1);
align-self: stretch;
}
.header {
display: flex;
gap: var(--Spacing-x-half);

View File

@@ -27,7 +27,7 @@ export default function FlexibilityOption({
roomType,
roomTypeCode,
title,
rateTitle,
rateName,
}: FlexibilityOptionProps) {
const intl = useIntl()
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
@@ -104,9 +104,9 @@ export default function FlexibilityOption({
value={rate.rateCode}
/>
<div className={styles.card}>
{rateTitle ? (
{rateName ? (
<div className={styles.header}>
<Caption>{rateTitle}</Caption>
<Caption>{rateName}</Caption>
</div>
) : null}
<div className={styles.header}>
@@ -120,8 +120,8 @@ export default function FlexibilityOption({
/>
</Button>
}
title={rateTitle ? rateTitle : title}
subtitle={rateTitle ? `${title} (${paymentTerm})` : paymentTerm}
title={rateName ? rateName : title}
subtitle={rateName ? `${title} (${paymentTerm})` : paymentTerm}
>
<div className={styles.terms}>
{priceInformation?.map((info) => (
@@ -150,7 +150,7 @@ export default function FlexibilityOption({
memberPrice={product.member}
petRoomPackage={petRoomPackage}
publicPrice={product.public}
rateTitle={rateTitle}
rateName={rateName}
/>
<div className={styles.checkIcon}>

View File

@@ -0,0 +1,53 @@
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./pointsList.module.css"
import type { ProductTypePoints } from "@/types/trpc/routers/hotel/availability"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
export default function PointsList({
product,
handleSelect,
redemptions,
}: {
product: Product
handleSelect: (product: Product, selectedRateCode: string) => void
redemptions: ProductTypePoints[]
}) {
const intl = useIntl()
return (
<div className={styles.pointsList}>
{redemptions.map((redemption) => (
<label key={redemption.rateCode} className={styles.pointsRow}>
{/* ToDo Handle with appropriate Input or Radio component in UI implementation ticket */}
<input
name="redepmtionRate"
onChange={() => {
handleSelect(product, redemption.rateCode)
}}
type="radio"
value={redemption.rateCode}
/>
<Subtitle>{redemption.localPrice.pointsPerStay}</Subtitle>
<Caption>
{" "}
{intl.formatMessage({ id: "Points" })}
{redemption.localPrice.additionalPricePerStay &&
redemption.localPrice.additionalPriceCurrency
? ` + ${formatPrice(
intl,
redemption.localPrice.additionalPricePerStay,
redemption.localPrice.additionalPriceCurrency
)}`
: null}
</Caption>
</label>
))}
</div>
)
}

View File

@@ -0,0 +1,10 @@
.pointsList {
margin: 0;
}
.pointsRow {
display: flex;
align-items: baseline;
justify-content: flex-start;
gap: var(--Spacing-x-half);
}

View File

@@ -0,0 +1,125 @@
"use client"
import { useIntl } from "react-intl"
import { CheckIcon, InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Label from "@/components/TempDesignSystem/Form/Label"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import PointsList from "./PointsList"
import styles from "../FlexibilityOption/flexibilityOption.module.css"
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
export default function FlexibilityOptionPoints({
features,
paymentTerm,
priceInformation,
product,
roomType,
roomTypeCode,
title,
// Reward night rate tile obtianed from the ratedefinition
rateName,
}: FlexibilityOptionProps) {
const intl = useIntl()
const rewardNightTitle =
rateName ?? intl.formatMessage({ id: "Reward night" })
const {
actions: { selectRateRedemption },
} = useRoomContext()
if (!product?.redemptions?.length) {
return (
<div className={styles.noPricesCard}>
<div className={styles.header}>
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
<div className={styles.priceType}>
<Caption>{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
<Label size="regular" className={styles.noPricesLabel}>
<Caption color="uiTextHighContrast" type="bold">
{intl.formatMessage({ id: "No prices available" })}
</Caption>
</Label>
</div>
)
}
function handleSelect(product: Product, selectedRateCode?: string) {
selectRateRedemption(
{
features,
product,
roomType,
roomTypeCode,
},
selectedRateCode
)
}
return (
<div>
<div className={styles.title}>
<Caption>
{rewardNightTitle}
{" ∙ "}
{intl.formatMessage({ id: "Breakfast included" })}
</Caption>
</div>
<div className={styles.card}>
<div className={styles.header}>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
width={16}
height={16}
color="uiTextMediumContrast"
/>
</Button>
}
title={rewardNightTitle}
subtitle={`${title} ${paymentTerm}`}
>
{priceInformation?.length ? (
<div className={styles.terms}>
{priceInformation.map((info) => (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<CheckIcon
color="uiSemanticSuccess"
width={20}
height={20}
className={styles.termsIcon}
/>
{info}
</Body>
))}
</div>
) : null}
</Modal>
<div className={styles.priceType}>
<Caption color="uiTextHighContrast">{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
<PointsList
product={product}
handleSelect={handleSelect}
redemptions={product.redemptions}
/>
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation"
import { createElement } from "react"
import { useIntl } from "react-intl"
import { REDEMPTION } from "@/constants/booking"
import { useRatesStore } from "@/stores/select-rate"
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
@@ -18,6 +19,7 @@ import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { cardVariants } from "./cardVariants"
import FlexibilityOption from "./FlexibilityOption"
import FlexibilityOptionPoints from "./FlexibilityOptionPoints"
import RoomSize from "./RoomSize"
import styles from "./roomCard.module.css"
@@ -72,6 +74,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const searchParams = useSearchParams()
const bookingCode = searchParams.get("bookingCode")
const isRedemption = searchParams.get("searchtype") === REDEMPTION
const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } =
useRatesStore((state) => ({
@@ -152,6 +155,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
* Get terms and rate title from the rate definitions when booking code rate
* or public promotion is in play. Returns undefined when product is not available
*
* In case of redemption it will always return first redemption as terms
* and title are same for all various redemption rates
*
* @param product - Either public or member product type
* @param rateDefinitions - List of rate definitions
* @returns RateDefinition | undefined
@@ -160,10 +166,18 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
product: Product,
rateDefinitions: RateDefinition[]
) {
return rateDefinitions.find((rateDefinition) =>
isUserLoggedIn && product.member && isMainRoom
? rateDefinition.rateCode === product.member?.rateCode
: rateDefinition.rateCode === product.public?.rateCode
let rateCode = ""
if (isUserLoggedIn && product.member && isMainRoom) {
rateCode = product.member.rateCode
} else if (product.public?.rateCode) {
rateCode = product.public.rateCode
} else if (product.redemptions?.length) {
// In case of redemption there will be same rate terms and title
// irrespective of ratecodes
return rateDefinitions[0]
}
return rateDefinitions.find(
(rateDefinition) => rateDefinition.rateCode === rateCode
)
}
@@ -263,7 +277,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
) : (
<>
<span>
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
{isRedemption ? null : (
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
)}
{bookingCode ? (
<span className={!isBookingCodeRate ? styles.strikedText : ""}>
<PriceTagIcon />
@@ -275,29 +291,30 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const rateTitle = getRateTitle(product.rate)
const isAvailable =
product.public ||
(product.member && isUserLoggedIn && isMainRoom)
(product.member && isUserLoggedIn && isMainRoom) ||
product.redemptions?.length
const rateDefinition = getRateDefinition(
product,
roomAvailability.rateDefinitions
)
return (
<FlexibilityOption
key={product.rate}
features={roomConfiguration.features}
paymentTerm={product.isFlex ? payLater : payNow}
petRoomPackage={petRoomPackageSelected}
priceInformation={rateDefinition?.generalTerms}
product={isAvailable ? product : undefined}
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
title={rateTitle}
rateTitle={
product.public &&
product.public?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined
}
/>
const props = {
features: roomConfiguration.features,
paymentTerm: product.isFlex ? payLater : payNow,
petRoomPackage: petRoomPackageSelected,
priceInformation: rateDefinition?.generalTerms,
product: isAvailable ? product : undefined,
roomType: roomConfiguration.roomType,
roomTypeCode: roomConfiguration.roomTypeCode,
title: rateTitle,
rateName:
isBookingCodeRate || isRedemption
? rateDefinition?.title
: undefined,
}
return isRedemption ? (
<FlexibilityOptionPoints key={product.rate} {...props} />
) : (
<FlexibilityOption key={product.rate} {...props} />
)
})}
</>

View File

@@ -1,4 +1,5 @@
"use client"
import { REDEMPTION } from "@/constants/booking"
import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang"
@@ -24,6 +25,9 @@ export function RoomsContainer({
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).format("YYYY-MM-DD")
const redemption = booking.searchType
? booking.searchType === REDEMPTION
: undefined
const { data: roomsAvailability, isPending: isLoadingAvailability } =
useRoomsAvailability(
@@ -33,7 +37,8 @@ export function RoomsContainer({
toDateString,
lang,
childArray,
booking.bookingCode
booking.bookingCode,
redemption
)
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(

View File

@@ -11,9 +11,10 @@ export function useRoomsAvailability(
toDateString: string,
lang: Lang,
childArray: ChildrenInRoom,
bookingCode?: string
bookingCode?: string,
redemption?: boolean
) {
return trpc.hotel.availability.roomsCombinedAvailability.useQuery({
const params = {
adultsCount,
bookingCode,
childArray,
@@ -21,7 +22,17 @@ export function useRoomsAvailability(
lang,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
})
redemption,
}
const roomsAvailability = redemption
? trpc.hotel.availability.roomsCombinedAvailabilityWithRedemption.useQuery(
params
)
: trpc.hotel.availability.roomsCombinedAvailability.useQuery(params)
return roomsAvailability
}
export function useHotelPackages(

View File

@@ -648,6 +648,7 @@
"Restaurants": "Restauranter",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Gentag den nye adgangskode",
"Reward night": "Bonusnat",
"Room": "Værelse",
"Room & Terms": "Værelse & Vilkår",
"Room amenities": "Værelsesfaciliteter",

View File

@@ -647,6 +647,7 @@
"Restaurants": "Restaurants",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Neues Passwort erneut eingeben",
"Reward night": "Bonusnacht",
"Room": "Zimmer",
"Room & Terms": "Zimmer & Bedingungen",
"Room amenities": "Zimmerausstattung",

View File

@@ -646,6 +646,7 @@
"Restaurants": "Restaurants",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Retype new password",
"Reward night": "Reward night",
"Room": "Room",
"Room & Terms": "Room & Terms",
"Room amenities": "Room amenities",

View File

@@ -646,6 +646,7 @@
"Restaurants": "Ravintolat",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Kirjoita uusi salasana uudelleen",
"Reward night": "Palkintoyö",
"Room": "Huone",
"Room & Terms": "Huone & Ehdot",
"Room amenities": "Huoneen mukavuudet",

View File

@@ -645,6 +645,7 @@
"Restaurants": "Restauranter",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Skriv inn nytt passord på nytt",
"Reward night": "Bonusnatt",
"Room": "Rom",
"Room & Terms": "Rom & Vilkår",
"Room amenities": "Romfasiliteter",

View File

@@ -645,6 +645,7 @@
"Restaurants": "Restauranger",
"Restaurants & Bars": "Restaurants & Bars",
"Retype new password": "Upprepa nytt lösenord",
"Reward night": "Bonusnatt",
"Room": "Rum",
"Room & Terms": "Rum & Villkor",
"Room amenities": "Bekvämligheter på rummet",

View File

@@ -88,7 +88,11 @@ export const getSelectedRoomAvailability = cache(
function getMemoizedSelectedRoomAvailability(
input: GetSelectedRoomAvailabilityInput
) {
return serverClient().hotel.availability.room(input)
if (input.redemption) {
return serverClient().hotel.availability.roomWithRedemption(input)
} else {
return serverClient().hotel.availability.room(input)
}
}
)

View File

@@ -19,6 +19,9 @@ export default function RoomProvider({
)
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
const selectRateRedemption = useRatesStore((state) =>
state.actions.selectRateRedemption(idx)
)
const roomNr = idx + 1
return (
<RoomContext.Provider
@@ -29,6 +32,7 @@ export default function RoomProvider({
modifyRate,
selectFilter,
selectRate,
selectRateRedemption,
},
isActiveRoom: activeRoom === idx,
isMainRoom: roomNr === 1,

View File

@@ -17,6 +17,7 @@ const roomsSchema = z
)
.default([]),
rateCode: z.string(),
redemptionCode: z.string().optional(),
roomTypeCode: z.coerce.string(),
guest: z.object({
becomeMember: z.boolean(),

View File

@@ -45,6 +45,7 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
rateCode: z.string().optional(),
roomStayEndDate: z.string(),
roomStayStartDate: z.string(),
redemption: z.boolean().optional(),
})
export const selectedRoomAvailabilityInputSchema = z.object({
@@ -59,6 +60,7 @@ export const selectedRoomAvailabilityInputSchema = z.object({
counterRateCode: z.string().optional(),
packageCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
lang: z.nativeEnum(Lang).optional(),
redemption: z.boolean().optional(),
})
export type GetSelectedRoomAvailabilityInput = z.input<

View File

@@ -151,7 +151,7 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
return statusLookup[a.status] - statusLookup[b.status]
}
export const roomsCombinedAvailabilitySchema = z
const baseRoomsCombinedAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
@@ -204,83 +204,86 @@ export const roomsCombinedAvailabilitySchema = z
type: z.string().optional(),
}),
})
.transform(({ data: { attributes } }) => {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
)
function transformRoomConfigs({
data: { attributes },
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
room.products = room.products.map((product) => {
const publicRate = product.public
if (publicRate?.rateCode) {
const publicRateDefinition = rateDefinitions.find(
(rateDefinition) =>
rateDefinition.rateCode === publicRate.rateCode
)
if (publicRateDefinition) {
const rate = getRate(publicRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
)
room.products = room.products.map((product) => {
const publicRate = product.public
if (publicRate?.rateCode) {
const publicRateDefinition = rateDefinitions.find(
(rateDefinition) =>
rateDefinition.rateCode === publicRate.rateCode
)
if (publicRateDefinition) {
const rate = getRate(publicRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
const memberRate = product.member
if (memberRate?.rateCode) {
const memberRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === memberRate.rateCode
)
if (memberRateDefinition) {
const rate = getRate(memberRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
const memberRate = product.member
if (memberRate?.rateCode) {
const memberRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === memberRate.rateCode
)
if (memberRateDefinition) {
const rate = getRate(memberRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
return product
})
return product
})
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.products = room.products.sort(
(a, b) =>
// @ts-expect-error - index
cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode]
)
}
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.products = room.products.sort(
(a, b) =>
// @ts-expect-error - index
cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode]
)
}
return room
})
.sort(sortRoomConfigs)
return room
})
.sort(sortRoomConfigs)
return {
...attributes,
roomConfigurations,
}
})
return {
...attributes,
roomConfigurations,
}
}
export const roomsAvailabilitySchema = z
.object({
@@ -298,85 +301,30 @@ export const roomsAvailabilitySchema = z
type: z.string().optional(),
}),
})
.transform(({ data: { attributes } }) => {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
.transform(transformRoomConfigs)
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
)
export const roomsCombinedAvailabilitySchema =
baseRoomsCombinedAvailabilitySchema.transform(transformRoomConfigs)
export const redemptionRoomsCombinedAvailabilitySchema =
baseRoomsCombinedAvailabilitySchema.transform((data) => {
// In Redemption, rates are always Flex terms
data.data.attributes.roomConfigurations =
data.data.attributes.roomConfigurations
.map((room) => {
room.products = room.products.map((product) => {
const publicRate = product.public
if (publicRate?.rateCode) {
const publicRateDefinition = rateDefinitions.find(
(rateDefinition) =>
rateDefinition.rateCode === publicRate.rateCode
)
if (publicRateDefinition) {
const rate = getRate(publicRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
const memberRate = product.member
if (memberRate?.rateCode) {
const memberRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === memberRate.rateCode
)
if (memberRateDefinition) {
const rate = getRate(memberRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
product.rate = "flex"
product.isFlex = true
return product
})
return room
})
.sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.products = room.products.sort(
(a, b) =>
// @ts-expect-error - index
cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode]
)
}
return room
})
.sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
return {
...attributes,
roomConfigurations,
}
return transformRoomConfigs(data)
})
export const ratesSchema = z.array(rateSchema)

View File

@@ -6,6 +6,7 @@ import { badRequestError } from "@/server/errors/trpc"
import {
contentStackBaseWithServiceProcedure,
protectedProcedure,
protectedServcieProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -50,6 +51,7 @@ import {
hotelSchema,
packagesSchema,
ratesSchema,
redemptionRoomsCombinedAvailabilitySchema,
roomsAvailabilitySchema,
roomsCombinedAvailabilitySchema,
} from "./output"
@@ -72,9 +74,12 @@ import type { HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
RoomsCombinedAvailabilityInputSchema,
SelectedRoomAvailabilitySchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/routes/hotelReservation"
export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => {
@@ -467,6 +472,346 @@ export const getHotelsAvailabilityByHotelIds = async (
)
}
async function getRoomsCombinedAvailability(
input: RoomsCombinedAvailabilityInputSchema,
token: string // Either service token or user access token in case of redemption search
) {
const { lang } = input
const apiLang = toApiLang(lang)
const {
adultsCount,
bookingCode,
childArray,
hotelId,
rateCode,
roomStayEndDate,
roomStayStartDate,
redemption,
} = input
const metricsData = {
hotelId,
roomStayStartDate,
roomStayEndDate,
adultsCount,
childArray: childArray ? JSON.stringify(childArray) : undefined,
bookingCode,
}
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
console.info(
"api.hotels.roomsCombinedAvailability start",
JSON.stringify({ query: { hotelId, params: metricsData } })
)
const availabilityResponses = await Promise.allSettled(
adultsCount.map(async (adultCount: number, idx: number) => {
const kids = childArray?.[idx]
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults: adultCount,
...(kids?.length && {
children: generateChildrenString(kids),
}),
...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: apiLang,
}
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
console.error("Failed API call", { params, text })
return { error: "http_error", details: text }
}
const apiJson = await apiResponse.json()
const validateAvailabilityData = redemption
? redemptionRoomsCombinedAvailabilitySchema.safeParse(apiJson)
: roomsCombinedAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
console.error("Validation error", {
params,
error: validateAvailabilityData.error,
})
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
return {
error: "validation_error",
details: validateAvailabilityData.error,
}
}
if (rateCode) {
validateAvailabilityData.data.mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.mustBeGuaranteed
}
return validateAvailabilityData.data
})
)
metrics.roomsCombinedAvailability.success.add(1, metricsData)
return availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
}
export const getRoomAvailability = async (
input: SelectedRoomAvailabilitySchema,
lang: Lang,
token: string, // Either service token or user access token in case of redemption search
serviceToken?: string // In Redemption we need serviceToken for hotel api call
) => {
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
rateCode,
counterRateCode,
roomTypeCode,
redemption,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: toApiLang(lang),
}
metrics.selectedRoomAvailability.counter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponseAvailability.ok) {
const text = await apiResponseAvailability.text()
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
}),
})
console.error(
"api.hotels.selectedRoomAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
},
})
)
throw new Error("Failed to fetch selected room availability")
}
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData =
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
if (!validateAvailabilityData.success) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.selectedRoomAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
const hotelData = await getHotel(
{
hotelId,
isCardOnlyPayment: false,
language: lang,
},
serviceToken ?? token
)
const rooms = validateAvailabilityData.data.roomConfigurations
const selectedRoom = rooms.find((room) => room.roomTypeCode === roomTypeCode)
if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found")
return null
}
const availableRoomsInCategory = rooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
const rateTypes = selectedRoom.products.find(
(rate) =>
rate.public?.rateCode === rateCode ||
rate.member?.rateCode === rateCode ||
rate.redemptions?.find((r) => r?.rateCode === rateCode)
)
if (!rateTypes) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "not_found",
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
})
console.error("No matching rate found")
return null
}
const rates = rateTypes
const rateDefinition = validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)
const memberRateDefinition =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === counterRateCode
)
const bedTypes = availableRoomsInCategory
.map((availRoom) => {
const matchingRoom = hotelData?.roomCategories
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
if (matchingRoom) {
return {
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
})
.filter((bed): bed is BedTypeSelection => Boolean(bed))
metrics.selectedRoomAvailability.success.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return {
bedTypes,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
memberRate: rates?.member,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
publicRate: rates?.public,
redemptionRate: rates?.redemptions?.find((r) => r?.rateCode === rateCode),
rate: selectedRoom.products[0].rate,
rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms,
// Send rate Title when it is a booking code rate
rateTitle:
rateDefinition?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
}
}
export const hotelQueryRouter = router({
availability: router({
hotelsByCity: serviceProcedure
@@ -497,344 +842,31 @@ export const hotelQueryRouter = router({
roomsCombinedAvailability: serviceProcedure
.input(roomsCombinedAvailabilityInputSchema)
.query(
async ({
ctx,
input: {
adultsCount,
bookingCode,
childArray,
hotelId,
lang,
rateCode,
roomStayEndDate,
roomStayStartDate,
},
}) => {
const apiLang = toApiLang(lang)
const metricsData = {
hotelId,
roomStayStartDate,
roomStayEndDate,
adultsCount,
childArray: childArray ? JSON.stringify(childArray) : undefined,
bookingCode,
}
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
console.info(
"api.hotels.roomsCombinedAvailability start",
JSON.stringify({ query: { hotelId, params: metricsData } })
)
const availabilityResponses = await Promise.allSettled(
adultsCount.map(async (adultCount: number, idx: number) => {
const kids = childArray?.[idx]
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults: adultCount,
...(kids?.length && {
children: generateChildrenString(kids),
}),
...(bookingCode && { bookingCode }),
language: apiLang,
}
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
console.error("Failed API call", { params, text })
return { error: "http_error", details: text }
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
roomsCombinedAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
console.error("Validation error", {
params,
error: validateAvailabilityData.error,
})
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
return {
error: "validation_error",
details: validateAvailabilityData.error,
}
}
if (rateCode) {
validateAvailabilityData.data.mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.mustBeGuaranteed
}
return validateAvailabilityData.data
})
)
metrics.roomsCombinedAvailability.success.add(1, metricsData)
const data = availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
return data
}
),
.query(async ({ input, ctx }) => {
return getRoomsCombinedAvailability(input, ctx.serviceToken)
}),
roomsCombinedAvailabilityWithRedemption: protectedProcedure
.input(roomsCombinedAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomsCombinedAvailability(
input,
ctx.session.token.access_token
)
}),
room: serviceProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = input
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
rateCode,
counterRateCode,
roomTypeCode,
} = input
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: lang ?? toApiLang(ctx.lang),
}
metrics.selectedRoomAvailability.counter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponseAvailability.ok) {
const text = await apiResponseAvailability.text()
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
}),
})
console.error(
"api.hotels.selectedRoomAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
},
})
)
throw new Error("Failed to fetch selected room availability")
}
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData =
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
if (!validateAvailabilityData.success) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.selectedRoomAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
const hotelData = await getHotel(
{
hotelId,
isCardOnlyPayment: false,
language: lang ?? ctx.lang,
},
return getRoomAvailability(input, ctx.lang, ctx.serviceToken)
}),
roomWithRedemption: protectedServcieProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomAvailability(
input,
ctx.lang,
ctx.session.token.access_token,
ctx.serviceToken
)
const rooms = validateAvailabilityData.data.roomConfigurations
const selectedRoom = rooms.find(
(room) => room.roomTypeCode === roomTypeCode
)
if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found")
return null
}
const availableRoomsInCategory = rooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
const rateTypes = selectedRoom.products.find(
(rate) =>
rate.public?.rateCode === rateCode ||
rate.member?.rateCode === rateCode
)
if (!rateTypes) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "not_found",
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
})
console.error("No matching rate found")
return null
}
const rates = rateTypes
const rateDefinition =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)
const memberRateDefinition =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === counterRateCode
)
const bedTypes = availableRoomsInCategory
.map((availRoom) => {
const matchingRoom = hotelData?.roomCategories
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find(
(roomType) => roomType.code === availRoom.roomTypeCode
)
if (matchingRoom) {
return {
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
})
.filter((bed): bed is BedTypeSelection => Boolean(bed))
metrics.selectedRoomAvailability.success.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return {
bedTypes,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
memberRate: rates?.member,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
publicRate: rates?.public,
rate: selectedRoom.products[0].rate,
rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms,
// Send rate Title when it is a booking code rate
rateTitle:
rateDefinition?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
}
}),
hotelsByCityWithBookingCode: serviceProcedure
.input(hotelsAvailabilityInputSchema)

View File

@@ -9,8 +9,6 @@ export const productTypeSchema = z
.object({
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
redemption: productTypePointsSchema.optional(),
redemptionA: productTypePointsSchema.optional(),
redemptionB: productTypePointsSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
})
.optional()

View File

@@ -10,22 +10,31 @@ export const priceSchema = z.object({
regularPricePerStay: z.coerce.number().optional(),
})
export const pointsSchema = z.object({
currency: z.nativeEnum(CurrencyEnum).optional(),
pricePerNight: z.coerce.number().optional(),
pricePerStay: z.coerce.number().optional(),
pointsPerNight: z.number(),
pointsPerStay: z.number(),
})
export const pointsSchema = z
.object({
currency: z.nativeEnum(CurrencyEnum).optional(),
pricePerStay: z.coerce.number().optional(),
pointsPerStay: z.coerce.number(),
additionalPricePerStay: z.coerce.number().optional(),
additionalPriceCurrency: z.nativeEnum(CurrencyEnum).optional(),
})
.transform((data) => ({
...data,
additionalPriceCurrency: data.currency,
currency: CurrencyEnum.POINTS,
pricePerStay: data.pointsPerStay,
price: data.pointsPerStay,
additionalPrice: data.additionalPricePerStay,
}))
const partialPriceSchema = z.object({
rateCode: z.string(),
rateType: z.string().optional(),
requestedPrice: priceSchema.optional(),
})
export const productTypePriceSchema = partialPriceSchema.extend({
localPrice: priceSchema,
requestedPrice: priceSchema.optional(),
})
export const productTypePointsSchema = partialPriceSchema.extend({

View File

@@ -29,21 +29,25 @@ export const roomConfigurationSchema = z
})
.transform((data) => {
if (data.products.length) {
/**
* Just guaranteeing that if all products all miss
* both public and member rateCode that status is
* set to `NotAvailable`
*/
const allProductsMissBothRateCodes = data.products.every(
(product) => !product.public?.rateCode && !product.member?.rateCode
)
if (allProductsMissBothRateCodes) {
return {
...data,
status: AvailabilityEnum.NotAvailable,
if (data.products[0].redemptions) {
// No need of rate check in reward night scenario
return { ...data }
} else {
/**
* Just guaranteeing that if all products all miss
* both public and member rateCode that status is
* set to `NotAvailable`
*/
const allProductsMissBothRateCodes = data.products.every(
(product) => !product.public?.rateCode && !product.member?.rateCode
)
if (allProductsMissBothRateCodes) {
return {
...data,
status: AvailabilityEnum.NotAvailable,
}
}
}
}
return data
})

View File

@@ -1,6 +1,9 @@
import { z } from "zod"
import { productTypePriceSchema } from "../productTypePrice"
import {
productTypePointsSchema,
productTypePriceSchema,
} from "../productTypePrice"
export const productSchema = z
.object({
@@ -9,6 +12,7 @@ export const productSchema = z
productType: z.object({
member: productTypePriceSchema.optional(),
public: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
}),
// Used to set the rate that we use to chose titles etc.
rate: z.enum(["change", "flex", "save"]).default("save"),

View File

@@ -206,3 +206,6 @@ export const contentStackBaseWithProtectedProcedure =
export const safeProtectedServiceProcedure =
safeProtectedProcedure.unstable_concat(serviceProcedure)
export const protectedServcieProcedure =
protectedProcedure.unstable_concat(serviceProcedure)

View File

@@ -2,9 +2,10 @@ import isEqual from "fast-deep-equal"
import { detailsStorageName } from "."
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
@@ -131,18 +132,43 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
}
}
if (roomRate.redemptionRate) {
return {
// ToDo Handle perNight as undefined
perNight: {
requested: undefined,
local: {
currency:
roomRate.redemptionRate.localPrice.currency ?? CurrencyEnum.POINTS,
price: roomRate.redemptionRate.localPrice.pointsPerStay,
additionalPrice:
roomRate.redemptionRate.localPrice.additionalPricePerStay,
additionalPriceCurrency:
roomRate.redemptionRate.localPrice.additionalPriceCurrency,
},
},
perStay: {
requested: undefined,
local: {
currency:
roomRate.redemptionRate.localPrice.currency ?? CurrencyEnum.POINTS,
price: roomRate.redemptionRate.localPrice.pointsPerStay,
additionalPrice:
roomRate.redemptionRate.localPrice.additionalPricePerStay,
additionalPriceCurrency:
roomRate.redemptionRate.localPrice.additionalPriceCurrency,
},
},
}
}
throw new Error(
`Unable to calculate RoomPrice since user is neither a member or memberRate is missing, or publicRate is missing`
)
}
type TotalPrice = {
requested: { currency: string; price: number } | undefined
local: { currency: string; price: number; regularPrice?: number }
}
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
return roomRates.reduce<TotalPrice>(
return roomRates.reduce<Price>(
(total, roomRate, idx) => {
const isFirstRoom = idx === 0
const rate =

View File

@@ -3,6 +3,7 @@ import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { REDEMPTION } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { DetailsContext } from "@/contexts/Details"
@@ -20,6 +21,9 @@ import {
} from "./helpers"
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
import {
PointsPriceSchema,
type Price} from "@/types/components/hotelReservation/price";
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
@@ -49,11 +53,20 @@ export function createDetailsStore(
breakfastPackages: BreakfastPackages | null
) {
const isMember = !!user
const isRedemption =
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION
const initialTotalPrice = getTotalPrice(
initialState.rooms.map((r) => r.roomRate),
isMember
)
let initialTotalPrice: Price
if (isRedemption && initialState.rooms[0].roomRate.redemptionRate) {
initialTotalPrice = PointsPriceSchema.parse(
initialState.rooms[0].roomRate.redemptionRate
)
} else {
initialTotalPrice = getTotalPrice(
initialState.rooms.map((r) => r.roomRate),
isMember
)
}
initialState.rooms.forEach((room) => {
if (room.roomFeatures) {

View File

@@ -91,10 +91,15 @@ export function createRatesStore({
rc.products.find(
(product) =>
product.public?.rateCode === room.rateCode ||
product.member?.rateCode === room.rateCode
product.member?.rateCode === room.rateCode ||
product.redemptions?.find(
(redemption) => redemption?.rateCode === room.rateCode
)
)
)
const redemptionProduct = selectedRoom?.products[0].redemptions?.find(
(r) => r?.rateCode === room.rateCode
)
const product = selectedRoom?.products.find(
(p) =>
p.public?.rateCode === room.rateCode ||
@@ -105,10 +110,19 @@ export function createRatesStore({
features: selectedRoom.features,
member: product.member,
public: product.public,
redemption: undefined,
rate: product.rate,
roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode,
}
} else if (selectedRoom && redemptionProduct) {
rateSummary[idx] = {
features: selectedRoom.features,
redemption: redemptionProduct,
rate: selectedRoom?.products[0].rate,
roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode,
}
}
}
})
@@ -200,6 +214,7 @@ export function createRatesStore({
package: state.rooms[idx].selectedPackage,
rate: selectedRate.product.rate,
public: selectedRate.product.public,
redemption: undefined,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
@@ -240,6 +255,51 @@ export function createRatesStore({
state.activeRoom = idx + 1
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
},
selectRateRedemption(idx) {
return function (selectedRate, selectedRateCode?: string) {
return set(
produce((state: RatesState) => {
const redemptionRate = selectedRate.product.redemptions?.find(
(r) => r?.rateCode === selectedRateCode
)
if (!redemptionRate) {
return
}
state.rooms[idx].selectedRate = selectedRate
state.rateSummary[idx] = {
features: selectedRate.features,
package: state.rooms[idx].selectedPackage,
rate: selectedRate.product.rate,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
redemption: redemptionRate,
}
const searchParams = new URLSearchParams(state.searchParams)
if (redemptionRate.rateCode) {
searchParams.set(
`room[${idx}].ratecode`,
redemptionRate.rateCode
)
}
searchParams.set(
`room[${idx}].roomtype`,
selectedRate.roomTypeCode
)
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
@@ -291,11 +351,11 @@ export function createRatesStore({
selectedRate:
selectedRate && product
? {
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
features: selectedRate.features,
product,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
: null,
}
}),

View File

@@ -7,11 +7,13 @@ import type {
guestDetailsSchema,
signedInDetailsSchema,
} from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
import type { productTypePointsSchema } from "@/server/routers/hotels/schemas/productTypePrice"
import type { Price } from "../price"
export type DetailsSchema = z.output<typeof guestDetailsSchema>
export type MultiroomDetailsSchema = z.output<typeof multiroomDetailsSchema>
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
export type ProductTypePointsSchema = z.output<typeof productTypePointsSchema>
export interface RoomPrice {
perNight: Price
@@ -29,4 +31,5 @@ export type JoinScandicFriendsCardProps = {
export type RoomRate = {
memberRate?: Product["member"]
publicRate?: Product["public"]
redemptionRate?: ProductTypePointsSchema
}

View File

@@ -1,10 +1,31 @@
import { z } from "zod"
import { CurrencyEnum } from "@/types/enums/currency"
interface TPrice {
currency: string
price: number
regularPrice?: number
additionalPrice?: number
additionalPriceCurrency?: string
}
export interface Price {
requested: TPrice | undefined
requested?: TPrice
local: TPrice
}
export const PointsPriceSchema = z
.object({
localPrice: z.object({
currency: z.nativeEnum(CurrencyEnum),
price: z.number(),
additionalPrice: z.number().optional(),
additionalPriceCurrency: z.nativeEnum(CurrencyEnum).optional(),
}),
})
.transform((data) => ({
local: {
...data.localPrice,
},
}))

View File

@@ -1,14 +1,12 @@
import type {
ProductTypePoints,
ProductTypePrices,
} from "@/types/trpc/routers/hotel/availability"
import type { ProductTypePrices } from "@/types/trpc/routers/hotel/availability"
export type PriceCardProps = {
productTypePrices: ProductTypePrices
isMemberPrice?: boolean
}
export type PointsCardProps = {
productTypePoints?: ProductTypePoints
redemptionPrice?: number
export type PointsRowProps = {
pointsPerStay: number
additionalPricePerStay?: number
additionalPriceCurrency?: string
}

View File

@@ -22,12 +22,12 @@ export type FlexibilityOptionProps = {
roomType: RoomConfiguration["roomType"]
roomTypeCode: RoomConfiguration["roomTypeCode"]
title: string
rateTitle?: string // This is for the rates via booking codes
rateName?: string // Obtained in case of booking code and redemption rates
}
export interface PriceListProps {
publicPrice?: ProductPrice | Record<string, never>
memberPrice?: ProductPrice | Record<string, never>
petRoomPackage?: RoomPackage
rateTitle?: string // This is for the rates via booking codes
rateName?: string // Obtained in case of booking code and redemption rates
}

View File

@@ -3,6 +3,7 @@ import type {
RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability"
import type { ChildBedMapEnum } from "../../bookingWidget/enums"
import type { ProductTypePointsSchema } from "../enterDetails/details"
import type { RoomPackageCodeEnum } from "./roomFilter"
export interface Child {
@@ -43,20 +44,14 @@ export type Rate = {
roomTypeCode: RoomConfiguration["roomTypeCode"]
} & (
| {
member?: undefined
public?: undefined
member?: NonNullable<Product["member"]>
public?: NonNullable<Product["public"]>
redemption?: never
}
| {
member?: never
public: NonNullable<Product["public"]>
}
| {
member: NonNullable<Product["member"]>
public?: never
}
| {
member: NonNullable<Product["member"]>
public: NonNullable<Product["public"]>
redemption: NonNullable<ProductTypePointsSchema>
}
)

View File

@@ -11,6 +11,10 @@ export interface RoomContextValue extends SelectedRoom {
modifyRate: () => void
selectFilter: (code: RoomPackageCodeEnum | undefined) => void
selectRate: (rate: SelectedRate) => void
selectRateRedemption: (
rate: SelectedRate,
selectedRateCode?: string
) => void
}
isActiveRoom: boolean
isMainRoom: boolean

View File

@@ -4,5 +4,6 @@ export enum CurrencyEnum {
NOK = "NOK",
PLN = "PLN",
SEK = "SEK",
POINTS = "POINTS",
Unknown = "Unknown",
}

View File

@@ -27,6 +27,9 @@ interface Actions {
modifyRate: (idx: number) => () => void
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void
selectRate: (idx: number) => (rate: SelectedRate) => void
selectRateRedemption: (
idx: number
) => (rate: SelectedRate, selectedRateCode?: string) => void
}
export interface SelectedRate {

View File

@@ -1,9 +1,12 @@
import {
type getHotelsByHotelIdsAvailabilityInputSchema,
type hotelsAvailabilityInputSchema,
type roomsCombinedAvailabilityInputSchema,
type selectedRoomAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { z } from "zod"
import type {
getHotelsByHotelIdsAvailabilityInputSchema,
hotelsAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type {
@@ -18,6 +21,12 @@ export type HotelsAvailabilityInputSchema = z.output<
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema
>
export type RoomsCombinedAvailabilityInputSchema = z.output<
typeof roomsCombinedAvailabilityInputSchema
>
export type SelectedRoomAvailabilitySchema = z.output<
typeof selectedRoomAvailabilityInputSchema
>
export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema>
export type ProductTypePoints = z.output<typeof productTypePointsSchema>

View File

@@ -22,3 +22,19 @@ export function formatPrice(intl: IntlShape, price: number, currency: string) {
})
return `${localizedPrice} ${currency}`
}
// This will handle redemption and bonus cheque (corporate cheque) scneario with partial payments
export function formatPriceWithAdditionalPrice(
intl: IntlShape,
points: number,
pointsCurrency: string,
additionalPrice?: number,
additionalPriceCurrency?: string
) {
const formattedAdditionalPrice =
additionalPrice && additionalPriceCurrency
? `+ ${formatPrice(intl, additionalPrice, additionalPriceCurrency)}`
: ""
return `${formatPrice(intl, points, pointsCurrency)} ${formattedAdditionalPrice}`
}