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

@@ -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(