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

View File

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

View File

@@ -9,7 +9,7 @@ import { formId } from "@/components/HotelReservation/EnterDetails/Payment/Payme
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting" import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
import styles from "./bottomSheet.module.css" import styles from "./bottomSheet.module.css"
@@ -57,10 +57,12 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
> >
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption> <Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
<Subtitle> <Subtitle>
{formatPrice( {formatPriceWithAdditionalPrice(
intl, intl,
totalPrice.local.price, totalPrice.local.price,
totalPrice.local.currency totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)} )}
</Subtitle> </Subtitle>
<Caption color="baseTextHighContrast" type="underline"> <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 Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting" import {
formatPrice,
formatPriceWithAdditionalPrice,
} from "@/utils/numberFormatting"
import PriceDetailsTable from "./PriceDetailsTable" import PriceDetailsTable from "./PriceDetailsTable"
@@ -170,10 +173,12 @@ export default function SummaryUI({
memberPrice.amount, memberPrice.amount,
memberPrice.currency memberPrice.currency
) )
: formatPrice( : formatPriceWithAdditionalPrice(
intl, intl,
room.roomPrice.perStay.local.price, 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> </Body>
</div> </div>
@@ -383,10 +388,12 @@ export default function SummaryUI({
</div> </div>
<div> <div>
<Body textTransform="bold" data-testid="total-price"> <Body textTransform="bold" data-testid="total-price">
{formatPrice( {formatPriceWithAdditionalPrice(
intl, intl,
totalPrice.local.price, totalPrice.local.price,
totalPrice.local.currency totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)} )}
</Body> </Body>
{totalPrice.local.regularPrice ? ( {totalPrice.local.regularPrice ? (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,9 @@ function getPricePerNight(hotel: HotelResponse): number {
return ( return (
hotel.availability.productType?.member?.localPrice?.pricePerNight ?? hotel.availability.productType?.member?.localPrice?.pricePerNight ??
hotel.availability.productType?.public?.localPrice?.pricePerNight ?? hotel.availability.productType?.public?.localPrice?.pricePerNight ??
hotel.availability.productType?.redemptions?.find(
(r) => r?.localPrice.pointsPerStay
)?.localPrice?.pointsPerStay ??
Infinity Infinity
) )
} }

View File

@@ -77,6 +77,12 @@ async function fetchAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCity(input) return await serverClient().hotel.availability.hotelsByCity(input)
} }
async function fetchAvailableHotelsWithRedemption(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCityWithRedemption(
input
)
}
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) { async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
return await serverClient().hotel.availability.hotelsByCityWithBookingCode( return await serverClient().hotel.availability.hotelsByCityWithBookingCode(
input 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 { } else {
availableHotelsResponse = await Promise.allSettled( availableHotelsResponse = await Promise.allSettled(
booking.rooms.map( booking.rooms.map(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,6 +53,16 @@ input[type="radio"]:checked + .card .checkIcon {
right: -10px; 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 { .header {
display: flex; display: flex;
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);

View File

@@ -27,7 +27,7 @@ export default function FlexibilityOption({
roomType, roomType,
roomTypeCode, roomTypeCode,
title, title,
rateTitle, rateName,
}: FlexibilityOptionProps) { }: FlexibilityOptionProps) {
const intl = useIntl() const intl = useIntl()
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn) const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
@@ -104,9 +104,9 @@ export default function FlexibilityOption({
value={rate.rateCode} value={rate.rateCode}
/> />
<div className={styles.card}> <div className={styles.card}>
{rateTitle ? ( {rateName ? (
<div className={styles.header}> <div className={styles.header}>
<Caption>{rateTitle}</Caption> <Caption>{rateName}</Caption>
</div> </div>
) : null} ) : null}
<div className={styles.header}> <div className={styles.header}>
@@ -120,8 +120,8 @@ export default function FlexibilityOption({
/> />
</Button> </Button>
} }
title={rateTitle ? rateTitle : title} title={rateName ? rateName : title}
subtitle={rateTitle ? `${title} (${paymentTerm})` : paymentTerm} subtitle={rateName ? `${title} (${paymentTerm})` : paymentTerm}
> >
<div className={styles.terms}> <div className={styles.terms}>
{priceInformation?.map((info) => ( {priceInformation?.map((info) => (
@@ -150,7 +150,7 @@ export default function FlexibilityOption({
memberPrice={product.member} memberPrice={product.member}
petRoomPackage={petRoomPackage} petRoomPackage={petRoomPackage}
publicPrice={product.public} publicPrice={product.public}
rateTitle={rateTitle} rateName={rateName}
/> />
<div className={styles.checkIcon}> <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 { createElement } from "react"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { REDEMPTION } from "@/constants/booking"
import { useRatesStore } from "@/stores/select-rate" import { useRatesStore } from "@/stores/select-rate"
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek" import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
@@ -18,6 +19,7 @@ import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { cardVariants } from "./cardVariants" import { cardVariants } from "./cardVariants"
import FlexibilityOption from "./FlexibilityOption" import FlexibilityOption from "./FlexibilityOption"
import FlexibilityOptionPoints from "./FlexibilityOptionPoints"
import RoomSize from "./RoomSize" import RoomSize from "./RoomSize"
import styles from "./roomCard.module.css" import styles from "./roomCard.module.css"
@@ -72,6 +74,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const bookingCode = searchParams.get("bookingCode") const bookingCode = searchParams.get("bookingCode")
const isRedemption = searchParams.get("searchtype") === REDEMPTION
const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } = const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } =
useRatesStore((state) => ({ 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 * 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 * 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 product - Either public or member product type
* @param rateDefinitions - List of rate definitions * @param rateDefinitions - List of rate definitions
* @returns RateDefinition | undefined * @returns RateDefinition | undefined
@@ -160,10 +166,18 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
product: Product, product: Product,
rateDefinitions: RateDefinition[] rateDefinitions: RateDefinition[]
) { ) {
return rateDefinitions.find((rateDefinition) => let rateCode = ""
isUserLoggedIn && product.member && isMainRoom if (isUserLoggedIn && product.member && isMainRoom) {
? rateDefinition.rateCode === product.member?.rateCode rateCode = product.member.rateCode
: rateDefinition.rateCode === product.public?.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> <span>
{isRedemption ? null : (
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption> <Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
)}
{bookingCode ? ( {bookingCode ? (
<span className={!isBookingCodeRate ? styles.strikedText : ""}> <span className={!isBookingCodeRate ? styles.strikedText : ""}>
<PriceTagIcon /> <PriceTagIcon />
@@ -275,29 +291,30 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const rateTitle = getRateTitle(product.rate) const rateTitle = getRateTitle(product.rate)
const isAvailable = const isAvailable =
product.public || product.public ||
(product.member && isUserLoggedIn && isMainRoom) (product.member && isUserLoggedIn && isMainRoom) ||
product.redemptions?.length
const rateDefinition = getRateDefinition( const rateDefinition = getRateDefinition(
product, product,
roomAvailability.rateDefinitions roomAvailability.rateDefinitions
) )
return ( const props = {
<FlexibilityOption features: roomConfiguration.features,
key={product.rate} paymentTerm: product.isFlex ? payLater : payNow,
features={roomConfiguration.features} petRoomPackage: petRoomPackageSelected,
paymentTerm={product.isFlex ? payLater : payNow} priceInformation: rateDefinition?.generalTerms,
petRoomPackage={petRoomPackageSelected} product: isAvailable ? product : undefined,
priceInformation={rateDefinition?.generalTerms} roomType: roomConfiguration.roomType,
product={isAvailable ? product : undefined} roomTypeCode: roomConfiguration.roomTypeCode,
roomType={roomConfiguration.roomType} title: rateTitle,
roomTypeCode={roomConfiguration.roomTypeCode} rateName:
title={rateTitle} isBookingCodeRate || isRedemption
rateTitle={
product.public &&
product.public?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title ? rateDefinition?.title
: undefined : undefined,
} }
/> return isRedemption ? (
<FlexibilityOptionPoints key={product.rate} {...props} />
) : (
<FlexibilityOption key={product.rate} {...props} />
) )
})} })}
</> </>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,7 +151,7 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
return statusLookup[a.status] - statusLookup[b.status] return statusLookup[a.status] - statusLookup[b.status]
} }
export const roomsCombinedAvailabilitySchema = z const baseRoomsCombinedAvailabilitySchema = z
.object({ .object({
data: z.object({ data: z.object({
attributes: z.object({ attributes: z.object({
@@ -204,7 +204,10 @@ export const roomsCombinedAvailabilitySchema = z
type: z.string().optional(), type: z.string().optional(),
}), }),
}) })
.transform(({ data: { attributes } }) => {
function transformRoomConfigs({
data: { attributes },
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
const rateDefinitions = attributes.rateDefinitions const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => { const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS // @ts-expect-error - index of cancellationRule TS
@@ -280,7 +283,7 @@ export const roomsCombinedAvailabilitySchema = z
...attributes, ...attributes,
roomConfigurations, roomConfigurations,
} }
}) }
export const roomsAvailabilitySchema = z export const roomsAvailabilitySchema = z
.object({ .object({
@@ -298,74 +301,22 @@ export const roomsAvailabilitySchema = z
type: z.string().optional(), type: z.string().optional(),
}), }),
}) })
.transform(({ data: { attributes } }) => { .transform(transformRoomConfigs)
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 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) => { .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) => { room.products = room.products.map((product) => {
const publicRate = product.public product.rate = "flex"
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 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]
)
}
return room return room
}) })
.sort( .sort(
@@ -373,10 +324,7 @@ export const roomsAvailabilitySchema = z
(a, b) => statusLookup[a.status] - statusLookup[b.status] (a, b) => statusLookup[a.status] - statusLookup[b.status]
) )
return { return transformRoomConfigs(data)
...attributes,
roomConfigurations,
}
}) })
export const ratesSchema = z.array(rateSchema) export const ratesSchema = z.array(rateSchema)

View File

@@ -6,6 +6,7 @@ import { badRequestError } from "@/server/errors/trpc"
import { import {
contentStackBaseWithServiceProcedure, contentStackBaseWithServiceProcedure,
protectedProcedure, protectedProcedure,
protectedServcieProcedure,
publicProcedure, publicProcedure,
router, router,
safeProtectedServiceProcedure, safeProtectedServiceProcedure,
@@ -50,6 +51,7 @@ import {
hotelSchema, hotelSchema,
packagesSchema, packagesSchema,
ratesSchema, ratesSchema,
redemptionRoomsCombinedAvailabilitySchema,
roomsAvailabilitySchema, roomsAvailabilitySchema,
roomsCombinedAvailabilitySchema, roomsCombinedAvailabilitySchema,
} from "./output" } from "./output"
@@ -72,9 +74,12 @@ import type { HotelDataWithUrl } from "@/types/hotel"
import type { import type {
HotelsAvailabilityInputSchema, HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema, HotelsByHotelIdsAvailabilityInputSchema,
RoomsCombinedAvailabilityInputSchema,
SelectedRoomAvailabilitySchema,
} from "@/types/trpc/routers/hotel/availability" } from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel" import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations" import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/routes/hotelReservation"
export const getHotel = cache( export const getHotel = cache(
async (input: HotelInput, serviceToken: string) => { async (input: HotelInput, serviceToken: string) => {
@@ -467,51 +472,22 @@ export const getHotelsAvailabilityByHotelIds = async (
) )
} }
export const hotelQueryRouter = router({ async function getRoomsCombinedAvailability(
availability: router({ input: RoomsCombinedAvailabilityInputSchema,
hotelsByCity: serviceProcedure token: string // Either service token or user access token in case of redemption search
.input(hotelsAvailabilityInputSchema) ) {
.query(async ({ input, ctx }) => { const { lang } = input
const { lang } = ctx
const apiLang = toApiLang(lang) const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken) const {
}),
hotelsByCityWithRedemption: protectedProcedure
.input(hotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(
input,
apiLang,
ctx.session.token.access_token
)
}),
hotelsByHotelIds: serviceProcedure
.input(getHotelsByHotelIdsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
}),
roomsCombinedAvailability: serviceProcedure
.input(roomsCombinedAvailabilityInputSchema)
.query(
async ({
ctx,
input: {
adultsCount, adultsCount,
bookingCode, bookingCode,
childArray, childArray,
hotelId, hotelId,
lang,
rateCode, rateCode,
roomStayEndDate, roomStayEndDate,
roomStayStartDate, roomStayStartDate,
}, redemption,
}) => { } = input
const apiLang = toApiLang(lang)
const metricsData = { const metricsData = {
hotelId, hotelId,
@@ -540,6 +516,7 @@ export const hotelQueryRouter = router({
children: generateChildrenString(kids), children: generateChildrenString(kids),
}), }),
...(bookingCode && { bookingCode }), ...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: apiLang, language: apiLang,
} }
@@ -547,7 +524,7 @@ export const hotelQueryRouter = router({
api.endpoints.v1.Availability.hotel(hotelId.toString()), api.endpoints.v1.Availability.hotel(hotelId.toString()),
{ {
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${token}`,
}, },
}, },
params params
@@ -561,8 +538,9 @@ export const hotelQueryRouter = router({
} }
const apiJson = await apiResponse.json() const apiJson = await apiResponse.json()
const validateAvailabilityData = const validateAvailabilityData = redemption
roomsCombinedAvailabilitySchema.safeParse(apiJson) ? redemptionRoomsCombinedAvailabilitySchema.safeParse(apiJson)
: roomsCombinedAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) { if (!validateAvailabilityData.success) {
console.error("Validation error", { console.error("Validation error", {
@@ -587,8 +565,7 @@ export const hotelQueryRouter = router({
}) })
) )
metrics.roomsCombinedAvailability.success.add(1, metricsData) metrics.roomsCombinedAvailability.success.add(1, metricsData)
return availabilityResponses.map((availability) => {
const data = availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") { if (availability.status === "fulfilled") {
return availability.value return availability.value
} }
@@ -597,15 +574,14 @@ export const hotelQueryRouter = router({
error: "request_failure", error: "request_failure",
} }
}) })
return data
} }
),
room: serviceProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = input
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 { const {
hotelId, hotelId,
roomStayStartDate, roomStayStartDate,
@@ -616,6 +592,7 @@ export const hotelQueryRouter = router({
rateCode, rateCode,
counterRateCode, counterRateCode,
roomTypeCode, roomTypeCode,
redemption,
} = input } = input
const params: Record<string, string | number | undefined> = { const params: Record<string, string | number | undefined> = {
@@ -624,7 +601,8 @@ export const hotelQueryRouter = router({
adults, adults,
...(children && { children }), ...(children && { children }),
...(bookingCode && { bookingCode }), ...(bookingCode && { bookingCode }),
language: lang ?? toApiLang(ctx.lang), ...(redemption && { isRedemption: "true" }),
language: toApiLang(lang),
} }
metrics.selectedRoomAvailability.counter.add(1, { metrics.selectedRoomAvailability.counter.add(1, {
@@ -643,7 +621,7 @@ export const hotelQueryRouter = router({
api.endpoints.v1.Availability.hotel(hotelId.toString()), api.endpoints.v1.Availability.hotel(hotelId.toString()),
{ {
headers: { headers: {
Authorization: `Bearer ${ctx.serviceToken}`, Authorization: `Bearer ${token}`,
}, },
}, },
params params
@@ -707,15 +685,13 @@ export const hotelQueryRouter = router({
{ {
hotelId, hotelId,
isCardOnlyPayment: false, isCardOnlyPayment: false,
language: lang ?? ctx.lang, language: lang,
}, },
ctx.serviceToken serviceToken ?? token
) )
const rooms = validateAvailabilityData.data.roomConfigurations const rooms = validateAvailabilityData.data.roomConfigurations
const selectedRoom = rooms.find( const selectedRoom = rooms.find((room) => room.roomTypeCode === roomTypeCode)
(room) => room.roomTypeCode === roomTypeCode
)
if (!selectedRoom) { if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, { metrics.selectedRoomAvailability.fail.add(1, {
@@ -740,7 +716,8 @@ export const hotelQueryRouter = router({
const rateTypes = selectedRoom.products.find( const rateTypes = selectedRoom.products.find(
(rate) => (rate) =>
rate.public?.rateCode === rateCode || rate.public?.rateCode === rateCode ||
rate.member?.rateCode === rateCode rate.member?.rateCode === rateCode ||
rate.redemptions?.find((r) => r?.rateCode === rateCode)
) )
if (!rateTypes) { if (!rateTypes) {
@@ -759,8 +736,7 @@ export const hotelQueryRouter = router({
} }
const rates = rateTypes const rates = rateTypes
const rateDefinition = const rateDefinition = validateAvailabilityData.data.rateDefinitions.find(
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode (rate) => rate.rateCode === rateCode
) )
const memberRateDefinition = const memberRateDefinition =
@@ -776,9 +752,7 @@ export const hotelQueryRouter = router({
.map((roomType) => roomType.code) .map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode) .includes(availRoom.roomTypeCode)
) )
?.roomTypes.find( ?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
(roomType) => roomType.code === availRoom.roomTypeCode
)
if (matchingRoom) { if (matchingRoom) {
return { return {
@@ -824,6 +798,7 @@ export const hotelQueryRouter = router({
memberRate: rates?.member, memberRate: rates?.member,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed, mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
publicRate: rates?.public, publicRate: rates?.public,
redemptionRate: rates?.redemptions?.find((r) => r?.rateCode === rateCode),
rate: selectedRoom.products[0].rate, rate: selectedRoom.products[0].rate,
rateDefinitionTitle: rateDefinition?.title ?? "", rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms, rateDetails: rateDefinition?.generalTerms,
@@ -835,6 +810,63 @@ export const hotelQueryRouter = router({
rateType: rateDefinition?.rateType ?? "", rateType: rateDefinition?.rateType ?? "",
selectedRoom, selectedRoom,
} }
}
export const hotelQueryRouter = router({
availability: router({
hotelsByCity: serviceProcedure
.input(hotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
}),
hotelsByCityWithRedemption: protectedProcedure
.input(hotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByCity(
input,
apiLang,
ctx.session.token.access_token
)
}),
hotelsByHotelIds: serviceProcedure
.input(getHotelsByHotelIdsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
}),
roomsCombinedAvailability: serviceProcedure
.input(roomsCombinedAvailabilityInputSchema)
.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 }) => {
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
)
}), }),
hotelsByCityWithBookingCode: serviceProcedure hotelsByCityWithBookingCode: serviceProcedure
.input(hotelsAvailabilityInputSchema) .input(hotelsAvailabilityInputSchema)

View File

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

View File

@@ -10,22 +10,31 @@ export const priceSchema = z.object({
regularPricePerStay: z.coerce.number().optional(), regularPricePerStay: z.coerce.number().optional(),
}) })
export const pointsSchema = z.object({ export const pointsSchema = z
.object({
currency: z.nativeEnum(CurrencyEnum).optional(), currency: z.nativeEnum(CurrencyEnum).optional(),
pricePerNight: z.coerce.number().optional(),
pricePerStay: z.coerce.number().optional(), pricePerStay: z.coerce.number().optional(),
pointsPerNight: z.number(), pointsPerStay: z.coerce.number(),
pointsPerStay: z.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({ const partialPriceSchema = z.object({
rateCode: z.string(), rateCode: z.string(),
rateType: z.string().optional(), rateType: z.string().optional(),
requestedPrice: priceSchema.optional(),
}) })
export const productTypePriceSchema = partialPriceSchema.extend({ export const productTypePriceSchema = partialPriceSchema.extend({
localPrice: priceSchema, localPrice: priceSchema,
requestedPrice: priceSchema.optional(),
}) })
export const productTypePointsSchema = partialPriceSchema.extend({ export const productTypePointsSchema = partialPriceSchema.extend({

View File

@@ -29,6 +29,10 @@ export const roomConfigurationSchema = z
}) })
.transform((data) => { .transform((data) => {
if (data.products.length) { if (data.products.length) {
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 * Just guaranteeing that if all products all miss
* both public and member rateCode that status is * both public and member rateCode that status is
@@ -44,6 +48,6 @@ export const roomConfigurationSchema = z
} }
} }
} }
}
return data return data
}) })

View File

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

View File

@@ -206,3 +206,6 @@ export const contentStackBaseWithProtectedProcedure =
export const safeProtectedServiceProcedure = export const safeProtectedServiceProcedure =
safeProtectedProcedure.unstable_concat(serviceProcedure) 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 { 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 { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step" import { StepEnum } from "@/types/enums/step"
import type { import type {
DetailsState, 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( throw new Error(
`Unable to calculate RoomPrice since user is neither a member or memberRate is missing, or publicRate is missing` `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) { export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
return roomRates.reduce<TotalPrice>( return roomRates.reduce<Price>(
(total, roomRate, idx) => { (total, roomRate, idx) => {
const isFirstRoom = idx === 0 const isFirstRoom = idx === 0
const rate = const rate =

View File

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

View File

@@ -91,10 +91,15 @@ export function createRatesStore({
rc.products.find( rc.products.find(
(product) => (product) =>
product.public?.rateCode === room.rateCode || 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( const product = selectedRoom?.products.find(
(p) => (p) =>
p.public?.rateCode === room.rateCode || p.public?.rateCode === room.rateCode ||
@@ -105,10 +110,19 @@ export function createRatesStore({
features: selectedRoom.features, features: selectedRoom.features,
member: product.member, member: product.member,
public: product.public, public: product.public,
redemption: undefined,
rate: product.rate, rate: product.rate,
roomType: selectedRoom.roomType, roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode, 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, package: state.rooms[idx].selectedPackage,
rate: selectedRate.product.rate, rate: selectedRate.product.rate,
public: selectedRate.product.public, public: selectedRate.product.public,
redemption: undefined,
roomType: selectedRate.roomType, roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode, roomTypeCode: selectedRate.roomTypeCode,
} }
@@ -240,6 +255,51 @@ export function createRatesStore({
state.activeRoom = idx + 1 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) state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState( window.history.pushState(
{}, {},

View File

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

View File

@@ -1,10 +1,31 @@
import { z } from "zod"
import { CurrencyEnum } from "@/types/enums/currency"
interface TPrice { interface TPrice {
currency: string currency: string
price: number price: number
regularPrice?: number regularPrice?: number
additionalPrice?: number
additionalPriceCurrency?: string
} }
export interface Price { export interface Price {
requested: TPrice | undefined requested?: TPrice
local: 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 { import type { ProductTypePrices } from "@/types/trpc/routers/hotel/availability"
ProductTypePoints,
ProductTypePrices,
} from "@/types/trpc/routers/hotel/availability"
export type PriceCardProps = { export type PriceCardProps = {
productTypePrices: ProductTypePrices productTypePrices: ProductTypePrices
isMemberPrice?: boolean isMemberPrice?: boolean
} }
export type PointsCardProps = { export type PointsRowProps = {
productTypePoints?: ProductTypePoints pointsPerStay: number
redemptionPrice?: number additionalPricePerStay?: number
additionalPriceCurrency?: string
} }

View File

@@ -22,12 +22,12 @@ export type FlexibilityOptionProps = {
roomType: RoomConfiguration["roomType"] roomType: RoomConfiguration["roomType"]
roomTypeCode: RoomConfiguration["roomTypeCode"] roomTypeCode: RoomConfiguration["roomTypeCode"]
title: string 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 { export interface PriceListProps {
publicPrice?: ProductPrice | Record<string, never> publicPrice?: ProductPrice | Record<string, never>
memberPrice?: ProductPrice | Record<string, never> memberPrice?: ProductPrice | Record<string, never>
petRoomPackage?: RoomPackage 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, RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability" } from "@/types/trpc/routers/hotel/roomAvailability"
import type { ChildBedMapEnum } from "../../bookingWidget/enums" import type { ChildBedMapEnum } from "../../bookingWidget/enums"
import type { ProductTypePointsSchema } from "../enterDetails/details"
import type { RoomPackageCodeEnum } from "./roomFilter" import type { RoomPackageCodeEnum } from "./roomFilter"
export interface Child { export interface Child {
@@ -43,20 +44,14 @@ export type Rate = {
roomTypeCode: RoomConfiguration["roomTypeCode"] roomTypeCode: RoomConfiguration["roomTypeCode"]
} & ( } & (
| { | {
member?: undefined member?: NonNullable<Product["member"]>
public?: undefined public?: NonNullable<Product["public"]>
redemption?: never
} }
| { | {
member?: never member?: never
public: NonNullable<Product["public"]>
}
| {
member: NonNullable<Product["member"]>
public?: never public?: never
} redemption: NonNullable<ProductTypePointsSchema>
| {
member: NonNullable<Product["member"]>
public: NonNullable<Product["public"]>
} }
) )

View File

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

View File

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

View File

@@ -27,6 +27,9 @@ interface Actions {
modifyRate: (idx: number) => () => void modifyRate: (idx: number) => () => void
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void
selectRate: (idx: number) => (rate: SelectedRate) => void selectRate: (idx: number) => (rate: SelectedRate) => void
selectRateRedemption: (
idx: number
) => (rate: SelectedRate, selectedRateCode?: string) => void
} }
export interface SelectedRate { 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 { z } from "zod"
import type {
getHotelsByHotelIdsAvailabilityInputSchema,
hotelsAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output" import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType" import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type { import type {
@@ -18,6 +21,12 @@ export type HotelsAvailabilityInputSchema = z.output<
export type HotelsByHotelIdsAvailabilityInputSchema = z.output< export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema 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 ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema> export type ProductTypePrices = z.output<typeof productTypePriceSchema>
export type ProductTypePoints = z.output<typeof productTypePointsSchema> 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}` 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}`
}