Merged in feat/SW-1308-booking-codes-track-b (pull request #1607)

Feat/SW-1308 booking codes track b

* feat: SW-1308 Booking codes track b

* feat: SW-1308 Booking codes Track B implementation

* feat: SW-1308 Optimized after rebase


Approved-by: Arvid Norlin
This commit is contained in:
Hrishikesh Vaipurkar
2025-03-24 11:23:11 +00:00
parent 5643bcc62a
commit b0674d07f5
66 changed files with 1612 additions and 285 deletions

View File

@@ -21,7 +21,7 @@
.errorContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
gap: var(--Spacing-x1);
}
.error {
display: flex;

View File

@@ -25,6 +25,8 @@ import type {
} from "@/types/components/bookingWidget"
import type { ButtonProps } from "@/components/TempDesignSystem/Button/button"
const invalidBookingCodeMsg = "Invalid booking code"
export default function BookingCode() {
const intl = useIntl()
const checkIsTablet = useMediaQuery(
@@ -53,11 +55,14 @@ export default function BookingCode() {
function updateBookingCodeFormValue(value: string) {
// Set value and show error if validation fails
setValue("bookingCode.value", value, { shouldValidate: true })
setValue("bookingCode.value", value.toUpperCase(), { shouldValidate: true })
if (getValues(REDEMPTION)) {
// Remove the redemption as user types booking code and show notification for the same
setValue(REDEMPTION, false)
// Add delay to handle table mode rendering
setTimeout(function () {
setValue(REDEMPTION, false)
})
// Hide the above notification popup after 5 seconds by re-triggering validation
// This is kept consistent with location search field error notification timeout
setTimeout(function () {
@@ -247,12 +252,13 @@ function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
function BookingCodeError({ codeError }: { codeError: FieldError }) {
const intl = useIntl()
const isMultiroomErr = codeError.message?.indexOf("Multi-room") !== -1
const isInvalidErr = codeError.message === invalidBookingCodeMsg
return (
<div className={styles.errorContainer}>
<Caption color={isMultiroomErr ? "blue" : "red"} className={styles.error}>
<Caption color={isInvalidErr ? "red" : "blue"} className={styles.error}>
<ErrorCircleIcon
color={isMultiroomErr ? "blue" : "red"}
color={isInvalidErr ? "red" : "blue"}
className={styles.errorIcon}
/>
{intl.formatMessage({ id: codeError.message })}
@@ -282,7 +288,7 @@ export function RemoveExtraRooms({ ...props }: ButtonProps) {
type="button"
onClick={removeExtraRooms}
size="small"
intent="secondary"
intent="primary"
{...props}
>
{intl.formatMessage({ id: "Remove extra rooms" })}
@@ -354,10 +360,11 @@ function TabletBookingCode({
onChange: (e) => updateValue(e.target.value),
})}
autoComplete="off"
hideError
/>
<div className={styles.bookingCodeRememberVisible}>
{codeError?.message ? (
<RemoveExtraRooms />
<BookingCodeError codeError={codeError} />
) : (
<CodeRemember
bookingCodeValue={bookingCode?.value}

View File

@@ -6,7 +6,7 @@ import { useIntl } from "react-intl"
import { REDEMPTION } from "@/constants/booking"
import { ErrorCircleIcon } from "@/components/Icons"
import { InfoCircleIcon } from "@/components/Icons"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -27,14 +27,13 @@ export default function RewardNight() {
const ref = useRef<HTMLDivElement | null>(null)
const reward = intl.formatMessage({ id: "Book Reward Night" })
const redemptionErr = errors[REDEMPTION]
const bookingCode = getValues("bookingCode.value")
const isMultiRoomError = redemptionErr?.message?.indexOf("Multi-room") === 0
const errorInfoColor = isMultiRoomError ? "red" : "blue"
function validateRedemption(value: boolean) {
// Validate redemption as per the rules defined in the schema
trigger(REDEMPTION)
if (value && bookingCode) {
if (value && getValues("bookingCode.value")) {
setValue("bookingCode.flag", false)
setValue("bookingCode.value", "", { shouldValidate: true })
// Hide the notification popup after 5 seconds by re-triggering validation
// This is kept consistent with location search field error notification timeout
@@ -87,15 +86,8 @@ export default function RewardNight() {
</Checkbox>
{redemptionErr && (
<div className={styles.errorContainer}>
<Caption
className={styles.error}
type="regular"
color={errorInfoColor}
>
<ErrorCircleIcon
color={errorInfoColor}
className={styles.errorIcon}
/>
<Caption className={styles.error} type="regular" color="blue">
<InfoCircleIcon color="blue" className={styles.errorIcon} />
{intl.formatMessage({ id: redemptionErr.message })}
</Caption>
{isMultiRoomError ? <RemoveExtraRooms /> : null}

View File

@@ -12,7 +12,6 @@
.error {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
}
.errorIcon {

View File

@@ -104,10 +104,7 @@ export const bookingWidgetSchema = z
path: ["search"],
})
}
if (
value.rooms.length > 1 &&
value.bookingCode?.value.toLowerCase().startsWith("vo")
) {
if (value.rooms.length > 1 && value.bookingCode?.value.startsWith("VO")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Multi-room booking is not available with this booking code.",
@@ -119,18 +116,6 @@ export const bookingWidgetSchema = z
path: ["rooms"],
})
}
if (value.rooms.length > 1 && value.redemption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Multi-room booking is not available with reward night.",
path: ["bookingCode.value"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Multi-room booking is not available with reward night.",
path: ["rooms"],
})
}
if (value.rooms.length > 1 && value.redemption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,

View File

@@ -11,6 +11,7 @@ import { formatPrice } from "@/utils/numberFormatting"
import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
import { CurrencyEnum } from "@/types/enums/currency"
export default function JoinScandicFriendsCard({
name = "join",
@@ -35,8 +36,8 @@ export default function JoinScandicFriendsCard({
{
amount: formatPrice(
intl,
room.roomRate.memberRate.localPrice.pricePerStay,
room.roomRate.memberRate.localPrice.currency
room.roomRate.memberRate.localPrice.pricePerStay ?? 0,
room.roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown
),
roomNr,
}

View File

@@ -17,6 +17,7 @@ import { formatPrice } from "@/utils/numberFormatting"
import styles from "./joinScandicFriendsCard.module.css"
import type { JoinScandicFriendsCardProps } from "@/types/components/hotelReservation/enterDetails/details"
import { CurrencyEnum } from "@/types/enums/currency"
export default function JoinScandicFriendsCard({
name = "join",
@@ -42,8 +43,8 @@ export default function JoinScandicFriendsCard({
{
amount: formatPrice(
intl,
room.roomRate.memberRate.localPrice.pricePerStay,
room.roomRate.memberRate.localPrice.currency
room.roomRate.memberRate.localPrice.pricePerStay ?? 0,
room.roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown
),
}
)

View File

@@ -15,6 +15,8 @@ import styles from "./modal.module.css"
import type { Dispatch, SetStateAction } from "react"
import { CurrencyEnum } from "@/types/enums/currency"
export default function MemberPriceModal({
isOpen,
setIsOpen,
@@ -49,8 +51,8 @@ export default function MemberPriceModal({
<Subtitle type="two" color="red">
{formatPrice(
intl,
memberPrice.pricePerStay,
memberPrice.currency
memberPrice.pricePerStay ?? 0,
memberPrice.currency ?? CurrencyEnum.Unknown
)}
</Subtitle>
</span>

View File

@@ -14,6 +14,7 @@ import { formatPrice } from "@/utils/numberFormatting"
import styles from "./priceDetailsTable.module.css"
import type { Price } from "@/types/components/hotelReservation/price"
import { CurrencyEnum } from "@/types/enums/currency"
import type { RoomState } from "@/types/stores/enter-details"
function Row({
@@ -117,6 +118,8 @@ export default function PriceDetailsTable({
getMemberRate && room.roomRate.memberRate
? room.roomRate.memberRate
: room.roomRate.publicRate
const voucherPrice = room.roomRate.voucherRate
const chequePrice = room.roomRate.chequeRate
if (!price) {
return null
}
@@ -129,42 +132,72 @@ export default function PriceDetailsTable({
</Body>
)}
<TableSectionHeader title={room.roomType} subtitle={duration} />
<Row
label={intl.formatMessage({ id: "Average price per night" })}
value={formatPrice(
intl,
price.localPrice.pricePerNight,
price.localPrice.currency
)}
/>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
{price && (
<>
<Row
label={intl.formatMessage({
id: "Average price per night",
})}
value={formatPrice(
intl,
price.localPrice.pricePerNight,
price.localPrice.currency
)}
/>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<Row
key={feature.code}
label={feature.description}
value={formatPrice(
intl,
+feature.localPrice.totalPrice,
feature.localPrice.currency
)}
/>
))
: null}
{room.bedType ? (
<Row
key={feature.code}
label={feature.description}
value={formatPrice(
intl,
+feature.localPrice.totalPrice,
feature.localPrice.currency
)}
label={room.bedType.description}
value={formatPrice(intl, 0, price.localPrice.currency)}
/>
))
: null}
{room.bedType ? (
) : null}
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
price.localPrice.pricePerStay,
price.localPrice.currency
)}
/>
</>
)}
{voucherPrice && (
<Row
label={room.bedType.description}
value={formatPrice(intl, 0, price.localPrice.currency)}
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
voucherPrice.numberOfVouchers,
CurrencyEnum.Voucher
)}
/>
) : null}
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
price.localPrice.pricePerStay,
price.localPrice.currency
)}
/>
)}
{chequePrice && (
<Row
bold
label={intl.formatMessage({ id: "Room charge" })}
value={formatPrice(
intl,
chequePrice.localPrice.numberOfBonusCheques,
CurrencyEnum.CC,
chequePrice.localPrice.additionalPricePerStay,
chequePrice.localPrice.currency
)}
/>
)}
</TableSection>
{room.breakfast ? (
@@ -218,14 +251,19 @@ export default function PriceDetailsTable({
})}
<TableSection>
<TableSectionHeader title={intl.formatMessage({ id: "Total" })} />
<Row
label={intl.formatMessage({ id: "Price excluding VAT" })}
value={formatPrice(intl, priceExclVat, totalPrice.local.currency)}
/>
<Row
label={intl.formatMessage({ id: "VAT {vat}%" }, { vat })}
value={formatPrice(intl, vatAmount, totalPrice.local.currency)}
/>
{totalPrice.local.currency !== CurrencyEnum.Voucher &&
totalPrice.local.currency !== CurrencyEnum.CC ? (
<>
<Row
label={intl.formatMessage({ id: "Price excluding VAT" })}
value={formatPrice(intl, priceExclVat, totalPrice.local.currency)}
/>
<Row
label={intl.formatMessage({ id: "VAT {vat}%" }, { vat })}
value={formatPrice(intl, vatAmount, totalPrice.local.currency)}
/>
</>
) : null}
<tr className={styles.row}>
<td>
<Body textTransform="bold">
@@ -237,7 +275,9 @@ export default function PriceDetailsTable({
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Body>
</td>

View File

@@ -32,6 +32,7 @@ import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
import { CurrencyEnum } from "@/types/enums/currency"
export default function SummaryUI({
booking,
@@ -60,9 +61,10 @@ export default function SummaryUI({
function getMemberPrice(roomRate: RoomRate) {
return roomRate?.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
currency:
roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
amount: roomRate.memberRate.localPrice.pricePerStay ?? 0,
}
: null
}
@@ -249,7 +251,8 @@ export default function SummaryUI({
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
room.roomPrice.perStay.local.additionalPriceCurrency ??
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
@@ -413,7 +416,9 @@ export default function SummaryUI({
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
),
}
)}

View File

@@ -0,0 +1,21 @@
.chequeCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
display: grid;
gap: var(--Spacing-x1);
}
.chequeRow,
.cheque {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
align-items: baseline;
}
.cheque {
justify-content: end;
}

View File

@@ -0,0 +1,59 @@
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelChequeCard.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
import type { ProductTypeCheque } from "@/types/trpc/routers/hotel/availability"
export default function HotelChequeCard({
productTypeCheque,
}: {
productTypeCheque: ProductTypeCheque
}) {
const intl = useIntl()
return (
<div className={styles.chequeCard}>
<div className={styles.chequeRow}>
<Caption>{intl.formatMessage({ id: "From" })}</Caption>
<div className={styles.cheque}>
<Subtitle type="two" color="uiTextHighContrast">
{productTypeCheque.localPrice.numberOfBonusCheques}
</Subtitle>
<Caption color="uiTextHighContrast" className={styles.currency}>
{CurrencyEnum.CC}
</Caption>
{productTypeCheque.localPrice.additionalPricePerStay && (
<>
{"+"}
<Subtitle type="two" color="uiTextHighContrast">
{productTypeCheque.localPrice.additionalPricePerStay}
</Subtitle>
<Caption color="uiTextHighContrast">
{productTypeCheque.localPrice.currency}
</Caption>
</>
)}
</div>
</div>
{productTypeCheque.requestedPrice ? (
<div className={styles.chequeRow}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Approx." })}
</Caption>
<Caption color={"uiTextMediumContrast"}>
{productTypeCheque.requestedPrice.numberOfBonusCheques}{" "}
{CurrencyEnum.CC}
{productTypeCheque.requestedPrice.additionalPricePerStay
? " + "
: ""}
{productTypeCheque.requestedPrice.additionalPricePerStay}{" "}
{productTypeCheque.requestedPrice.currency}
</Caption>
</div>
) : null}
</div>
)
}

View File

@@ -21,6 +21,11 @@
gap: var(--Spacing-x-half);
}
.voucherChqRate {
justify-content: start;
align-items: baseline;
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);

View File

@@ -8,32 +8,37 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelPriceCard.module.css"
import type { PriceCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
import { RateTypeEnum } from "@/types/enums/rateType"
export default function HotelPriceCard({
productTypePrices,
isMemberPrice = false,
}: PriceCardProps) {
const intl = useIntl()
const isRegularOrPublicPromotionRate =
productTypePrices.rateType === RateTypeEnum.Regular ||
productTypePrices.rateType === RateTypeEnum.PublicPromotion
return (
<dl className={styles.priceCard}>
{isMemberPrice ? (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
</div>
) : (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
</div>
)}
{isRegularOrPublicPromotionRate &&
(isMemberPrice ? (
<div className={styles.priceRow}>
<dt>
<Caption color="red">
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
</div>
) : (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextHighContrast">
{intl.formatMessage({ id: "Standard price" })}
</Caption>
</dt>
</div>
))}
<div className={styles.priceRow}>
<dt>
<Caption
@@ -79,24 +84,26 @@ export default function HotelPriceCard({
</div>
)}
{productTypePrices.localPrice.pricePerStay !==
productTypePrices.localPrice.pricePerNight &&
// Handle undefined scenarios
productTypePrices.localPrice.pricePerNight && (
<>
<Divider color="subtle" className={styles.divider} />
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Total" })}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{productTypePrices.localPrice.pricePerStay}{" "}
{productTypePrices.localPrice.currency}
</Caption>
</dd>
</div>
</>
)}
<>
<Divider color="subtle" className={styles.divider} />
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Total" })}
</Caption>
</dt>
<dd>
<Caption color={"uiTextMediumContrast"}>
{productTypePrices.localPrice.pricePerStay}{" "}
{productTypePrices.localPrice.currency}
</Caption>
</dd>
</div>
</>
)}
</dl>
)
}

View File

@@ -0,0 +1,19 @@
.voucherCard {
padding: var(--Spacing-x-one-and-half);
background-color: var(--Base-Surface-Secondary-light-Normal);
border-radius: var(--Corner-radius-Medium);
margin: 0;
width: 100%;
}
.voucherRow,
.voucher {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
align-items: baseline;
}
.voucher {
justify-content: end;
}

View File

@@ -0,0 +1,32 @@
import { useIntl } from "react-intl"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./hotelVoucherCard.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
import type { ProductTypeVoucher } from "@/types/trpc/routers/hotel/availability"
export default function HotelVoucherCard({
productTypeVoucher,
}: {
productTypeVoucher: ProductTypeVoucher
}) {
const intl = useIntl()
return (
<div className={styles.voucherCard}>
<div className={styles.voucherRow}>
<Caption>{intl.formatMessage({ id: "From" })}</Caption>
<div className={styles.voucher}>
<Subtitle type="two" color="uiTextHighContrast">
{productTypeVoucher.numberOfVouchers}
</Subtitle>
<Caption color="uiTextHighContrast" className={styles.currency}>
{CurrencyEnum.Voucher}
</Caption>
</div>
</div>
</div>
)
}

View File

@@ -13,6 +13,7 @@ import HotelLogo from "@/components/Icons/Logos"
import ImageGallery from "@/components/ImageGallery"
import Button from "@/components/TempDesignSystem/Button"
import Divider from "@/components/TempDesignSystem/Divider"
import IconChip from "@/components/TempDesignSystem/IconChip"
import Link from "@/components/TempDesignSystem/Link"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -22,8 +23,10 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
import ReadMore from "../ReadMore"
import TripAdvisorChip from "../TripAdvisorChip"
import HotelChequeCard from "./HotelChequeCard"
import HotelPointsRow from "./HotelPointsRow"
import HotelPriceCard from "./HotelPriceCard"
import HotelVoucherCard from "./HotelVoucherCard"
import NoPriceAvailableCard from "./NoPriceAvailableCard"
import { hotelCardVariants } from "./variants"
@@ -31,6 +34,7 @@ import styles from "./hotelCard.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelCardProps } from "@/types/components/hotelReservation/selectHotel/hotelCardProps"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { Lang } from "@/constants/languages"
function HotelCard({
@@ -64,7 +68,8 @@ function HotelCard({
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const galleryImages = mapApiImagesToGalleryImages(hotel.galleryImages || [])
const fullPrice =
availability.productType?.public?.rateType?.toLowerCase() === "regular"
availability.productType?.public?.rateType === RateTypeEnum.Regular ||
availability.productType?.member?.rateType === RateTypeEnum.Regular
const price = availability.productType
return (
@@ -155,8 +160,12 @@ function HotelCard({
<>
{bookingCode && (
<span className={`${fullPrice ? styles.strikedText : ""}`}>
<PriceTagIcon height={20} width={20} />
{bookingCode}
<IconChip
color="blue"
icon={<PriceTagIcon height={20} width={20} />}
>
{bookingCode}
</IconChip>
</span>
)}
{(!isUserLoggedIn ||
@@ -171,6 +180,12 @@ function HotelCard({
isMemberPrice
/>
)}
{price?.voucher && (
<HotelVoucherCard productTypeVoucher={price.voucher} />
)}
{price?.bonusCheque && (
<HotelChequeCard productTypeCheque={price.bonusCheque} />
)}
{!!price?.redemptions?.length && (
<div className={styles.pointsCard}>
<Caption>

View File

@@ -43,6 +43,13 @@ export default function HotelCardListing({
const sortBy = searchParams.get("sort") ?? DEFAULT_SORT
const bookingCode = searchParams.get("bookingCode")
// Special rates (corporate cheque, voucher and reward nights) will not have regular rate hotels availability
const isSpecialRate = hotelData.find(
(hotel) =>
hotel.availability.productType?.bonusCheque ||
hotel.availability.productType?.voucher ||
hotel.availability.productType?.redemptions
)
const activeCodeFilter = useBookingCodeFilterStore(
(state) => state.activeCodeFilter
)
@@ -51,21 +58,26 @@ export default function HotelCardListing({
const sortedHotels = getSortedHotels({
hotels: hotelData,
sortBy,
bookingCode,
bookingCode: isSpecialRate ? null : bookingCode,
})
const updatedHotelsList = bookingCode
? sortedHotels.filter(
(hotel) =>
!hotel.availability.productType ||
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.availability.productType.public?.rateType !==
RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.availability.productType.public?.rateType ===
RateTypeEnum.Regular)
)
: sortedHotels
const updatedHotelsList =
bookingCode && !isSpecialRate
? sortedHotels.filter(
(hotel) =>
!hotel.availability.productType ||
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.availability.productType.public?.rateType !==
RateTypeEnum.Regular &&
hotel.availability.productType.member?.rateType !==
RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
(hotel.availability.productType.public?.rateType ===
RateTypeEnum.Regular ||
hotel.availability.productType.member?.rateType ===
RateTypeEnum.Regular))
)
: sortedHotels
if (!activeFilters.length) {
return updatedHotelsList
@@ -78,7 +90,14 @@ export default function HotelCardListing({
)
)
)
}, [activeCodeFilter, activeFilters, bookingCode, hotelData, sortBy])
}, [
activeCodeFilter,
activeFilters,
bookingCode,
hotelData,
sortBy,
isSpecialRate,
])
useEffect(() => {
setResultCount(hotels.length)

View File

@@ -1,4 +1,5 @@
import { SortOrder } from "@/types/components/hotelReservation/selectHotel/hotelSorter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { HotelResponse } from "@/components/HotelReservation/SelectHotel/helpers"
function getPricePerNight(hotel: HotelResponse): number {
@@ -47,23 +48,21 @@ export function getSortedHotels({
sortingStrategies[sortBy] ?? sortingStrategies[SortOrder.Distance]
if (bookingCode) {
const bookingCodeHotels = hotels.filter(
const bookingCodeRateHotels = availableHotels.filter(
(hotel) =>
(hotel.availability.productType?.public?.rateType?.toLowerCase() !==
"regular" ||
hotel.availability.productType?.member?.rateType?.toLowerCase() !==
"regular") &&
(hotel.availability.productType?.public?.rateType !== RateTypeEnum.Regular &&
hotel.availability.productType?.member?.rateType !== RateTypeEnum.Regular) &&
!!hotel.availability.productType
)
const regularHotels = hotels.filter(
const regularRateHotels = availableHotels.filter(
(hotel) =>
hotel.availability.productType?.public?.rateType?.toLowerCase() ===
"regular"
hotel.availability.productType?.public?.rateType === RateTypeEnum.Regular ||
hotel?.availability.productType?.member?.rateType === RateTypeEnum.Regular
)
return bookingCodeHotels
return bookingCodeRateHotels
.sort(sortStrategy)
.concat(regularHotels.sort(sortStrategy))
.concat(regularRateHotels.sort(sortStrategy))
.concat(unavailableHotels.sort(sortStrategy))
}

View File

@@ -34,6 +34,7 @@ import styles from "./selectHotel.module.css"
import type { SelectHotelProps } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import { RateTypeEnum } from "@/types/enums/rateType"
export default async function SelectHotel({
params,
@@ -71,7 +72,7 @@ export default async function SelectHotel({
isAlternativeFor,
bookingCode,
city,
!!redemption,
!!redemption
)
const arrivalDate = new Date(selectHotelParams.fromDate)
@@ -95,24 +96,24 @@ export default async function SelectHotel({
},
isAlternativeFor
? {
title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels",
}
title: intl.formatMessage({ id: "Alternative hotels" }),
href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
uid: "alternative-hotels",
}
: {
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
title: intl.formatMessage({ id: "Select hotel" }),
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
isAlternativeFor
? {
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
: {
title: city.name,
uid: city.id,
},
title: city.name,
uid: city.id,
},
]
const isAllUnavailable = !hotels.length
@@ -133,6 +134,33 @@ export default async function SelectHotel({
)
const suspenseKey = stringify(searchParams)
let isFullPriceHotelAvailable
let isBookingCodeRateAvaliable
if (bookingCode) {
isFullPriceHotelAvailable = hotels?.find(
(hotel) =>
hotel.availability.productType?.public?.rateType ===
RateTypeEnum.Regular ||
hotel.availability.productType?.member?.rateType ===
RateTypeEnum.Regular
)
isBookingCodeRateAvaliable = hotels?.find(
(hotel) =>
hotel.availability.productType?.public?.rateType !==
RateTypeEnum.Regular ||
hotel.availability.productType?.member?.rateType !==
RateTypeEnum.Regular
)
}
// Special rates (corporate cheque, voucher and reward nights) will not have regular rate hotels availability
const isSpecialRate = hotels?.some(
(hotel) =>
hotel.availability.productType?.bonusCheque ||
hotel.availability.productType?.voucher ||
hotel.availability.productType?.redemptions
)
return (
<>
<header className={styles.header}>
@@ -160,7 +188,11 @@ export default async function SelectHotel({
</div>
</header>
<main className={styles.main}>
{bookingCode ? <BookingCodeFilter /> : null}
{isBookingCodeRateAvaliable &&
isFullPriceHotelAvailable &&
!isSpecialRate ? (
<BookingCodeFilter />
) : null}
<div className={styles.sideBar}>
{hotels.length ? (
<Link

View File

@@ -53,9 +53,9 @@ export default function Summary({
function getMemberPrice(roomRate: RoomRate) {
return roomRate?.memberRate
? {
currency: roomRate.memberRate.localPrice.currency,
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
amount: roomRate.memberRate.localPrice.pricePerStay,
currency: roomRate.memberRate.localPrice.currency ?? "",
pricePerNight: roomRate.memberRate.localPrice.pricePerNight ?? 0,
amount: roomRate.memberRate.localPrice.pricePerStay ?? 0,
}
: null
}
@@ -299,7 +299,9 @@ export default function Summary({
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
),
}
)}

View File

@@ -20,7 +20,11 @@ import {
} from "@/utils/numberFormatting"
import MobileSummary from "./MobileSummary"
import { calculateTotalPrice } from "./utils"
import {
calculateChequePrice,
calculateTotalPrice,
calculateVoucherPrice,
} from "./utils"
import styles from "./rateSummary.module.css"
@@ -137,13 +141,26 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const isBookingCodeRate = rateSummary.some(
(rate) => rate.public?.rateType !== RateTypeEnum.Regular
)
const showDiscounted = isUserLoggedIn || isBookingCodeRate
const isVoucherRate = rateSummary.some((rate) => rate.voucher)
const isChequeRate = rateSummary.some((rate) => rate.bonusCheque)
const showDiscounted =
isUserLoggedIn || isBookingCodeRate || isVoucherRate || isChequeRate
// 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)
let totalPriceToShow: Price
if (isVoucherRate) {
totalPriceToShow = calculateVoucherPrice(rateSummary)
} else if (isChequeRate) {
totalPriceToShow = calculateChequePrice(rateSummary)
} else if (rateSummary[0].redemption) {
// In case of reward night (redemption) only single room booking is supported by business rules
totalPriceToShow = PointsPriceSchema.parse(rateSummary[0].redemption)
} else {
totalPriceToShow = calculateTotalPrice(
rateSummary,
isUserLoggedIn,
petRoomPackage
)
}
return (
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
@@ -271,7 +288,9 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
value: formatPrice(
intl,
totalPriceToShow.requested.price,
totalPriceToShow.requested.currency
totalPriceToShow.requested.currency,
totalPriceToShow.requested.additionalPrice,
totalPriceToShow.requested.additionalPriceCurrency
),
}
)}

View File

@@ -4,6 +4,7 @@ import {
RoomPackageCodeEnum,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
export const calculateTotalPrice = (
selectedRateSummary: Rate[],
@@ -68,3 +69,83 @@ export const calculateTotalPrice = (
}
)
}
export const calculateVoucherPrice = (selectedRateSummary: Rate[]) => {
return selectedRateSummary.reduce<Price>(
(total, room) => {
const rate = room.voucher
if (!rate) {
return total
}
return <Price>{
local: {
currency: total.local.currency,
price: total.local.price + rate.numberOfVouchers,
},
requested: undefined,
}
},
{
local: {
currency: CurrencyEnum.Voucher,
price: 0,
},
requested: undefined,
}
)
}
export const calculateChequePrice = (selectedRateSummary: Rate[]) => {
return selectedRateSummary.reduce<Price>(
(total, room) => {
const rate = room.bonusCheque
if (!rate) {
return total
}
const price = total.local.price + rate.localPrice.numberOfBonusCheques
const additionalPrice =
rate.localPrice.numberOfBonusCheques &&
(total.local.additionalPrice ?? 0) +
(rate.localPrice.additionalPricePerStay ?? 0)
const additionalPriceCurrency = (rate.localPrice.numberOfBonusCheques &&
rate.localPrice.currency)!
const requestedPrice = rate.requestedPrice?.numberOfBonusCheques
? (total.requested?.price ?? 0) +
rate.requestedPrice?.numberOfBonusCheques
: total.requested?.price
const requestedAdditionalPrice =
rate.requestedPrice?.additionalPricePerStay &&
(total.requested?.additionalPrice ?? 0) +
(rate.requestedPrice?.additionalPricePerStay ?? 0)
return <Price>{
local: {
currency: CurrencyEnum.CC,
price,
additionalPrice,
additionalPriceCurrency,
},
requested: rate.requestedPrice
? {
currency: CurrencyEnum.CC,
price: requestedPrice,
additionalPrice: requestedAdditionalPrice,
additionalPriceCurrency: rate.requestedPrice?.currency,
}
: undefined,
}
},
{
local: {
currency: CurrencyEnum.CC,
price: 0,
},
requested: undefined,
}
)
}

View File

@@ -14,6 +14,7 @@ import { calculatePricesPerNight } from "./utils"
import styles from "./priceList.module.css"
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
import { RateTypeEnum } from "@/types/enums/rateType"
export default function PriceList({
publicPrice = {},
@@ -34,9 +35,11 @@ export default function PriceList({
const petRoomRequestedPrice = petRoomPackage?.requestedPrice
const showRequestedPrice =
publicRequestedPrice &&
memberRequestedPrice &&
publicRequestedPrice.currency !== publicLocalPrice.currency
(publicRequestedPrice &&
memberRequestedPrice &&
publicRequestedPrice.currency !== publicLocalPrice.currency) ||
(publicPrice.rateType !== RateTypeEnum.Regular && publicRequestedPrice)
const searchParams = useSearchParams()
const fromDate = searchParams.get("fromDate")
const toDate = searchParams.get("toDate")
@@ -76,16 +79,18 @@ export default function PriceList({
{isUserLoggedIn && isMainRoom && memberLocalPrice ? null : (
<div className={styles.priceRow}>
<dt>
{rateName ? null : (
{
<Caption
type="bold"
color={
totalPublicLocalPricePerNight ? priceLabelColor : "disabled"
}
>
{intl.formatMessage({ id: "Standard price" })}
{rateName
? rateName
: intl.formatMessage({ id: "Standard price" })}
</Caption>
)}
}
</dt>
<dd>
{publicLocalPrice ? (
@@ -163,19 +168,27 @@ export default function PriceList({
</dt>
<dd>
<Caption color="uiTextMediumContrast">
{isUserLoggedIn
? intl.formatMessage(
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
{totalMemberRequestedPricePerNight
? isUserLoggedIn
? intl.formatMessage(
{ id: "{memberPrice} {currency}" },
{
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
: intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
currency: publicRequestedPrice.currency,
}
)
: intl.formatMessage(
{ id: "{publicPrice}/{memberPrice} {currency}" },
{ id: "{price} {currency}" },
{
publicPrice: totalPublicRequestedPricePerNight,
memberPrice: totalMemberRequestedPricePerNight,
price: publicRequestedPrice.pricePerNight,
currency: publicRequestedPrice.currency,
}
)}

View File

@@ -20,6 +20,7 @@
.price {
display: flex;
gap: var(--Spacing-x-half);
align-items: baseline;
}
.priceStriked {

View File

@@ -104,11 +104,6 @@ export default function FlexibilityOption({
value={rate.rateCode}
/>
<div className={styles.card}>
{rateName ? (
<div className={styles.header}>
<Caption>{rateName}</Caption>
</div>
) : null}
<div className={styles.header}>
<Modal
trigger={

View File

@@ -0,0 +1,15 @@
.priceList {
margin: 0;
}
.priceRow {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
align-items: baseline;
}

View File

@@ -0,0 +1,79 @@
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./chequePrice.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
export default function ChequePrice({
chequePrice,
rateTitle,
}: {
chequePrice: NonNullable<Product["bonusCheque"]>
rateTitle: string
}) {
const intl = useIntl()
return (
<dl className={styles.priceList}>
<div className={styles.priceRow}>
<dt>
<Caption type="bold" color="red">
{rateTitle}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Subtitle type="two" color="red">
{chequePrice.localPrice.numberOfBonusCheques}
</Subtitle>
<Body color="red">{CurrencyEnum.CC}</Body>
{chequePrice.localPrice.additionalPricePerStay ? (
<>
<Body color="red">{" + "}</Body>
<Subtitle type="two" color="red">
{chequePrice.localPrice.additionalPricePerStay}
</Subtitle>
<Body color="red">{chequePrice.localPrice.currency}</Body>
</>
) : null}
</div>
</dd>
</div>
{chequePrice.requestedPrice?.additionalPricePerStay ? (
<div className={styles.priceRow}>
<dt>
<Caption type="bold">
{intl.formatMessage({ id: "Approx." })}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Caption>
{intl.formatMessage(
{ id: "{price} {currency}" },
{
price: chequePrice.requestedPrice.numberOfBonusCheques,
currency: CurrencyEnum.CC,
}
)}
{" + "}
{intl.formatMessage(
{ id: "{price} {currency}" },
{
price: chequePrice.requestedPrice.additionalPricePerStay,
currency: chequePrice.requestedPrice.currency,
}
)}
</Caption>
</div>
</dd>
</div>
) : null}
</dl>
)
}

View File

@@ -0,0 +1,73 @@
.card {
border-radius: var(--Corner-radius-Large);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
position: relative;
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
height: 100%;
}
.card:hover {
cursor: pointer;
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.checkIcon {
width: 24px;
height: 24px;
border-radius: 100px;
background-color: var(--UI-Input-Controls-Fill-Selected);
border: 2px solid var(--Base-Border-Inverted);
justify-content: center;
align-items: center;
display: none;
}
input[type="radio"].radio {
opacity: 0;
position: fixed;
width: 0;
}
input[type="radio"]:checked + .card {
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
input[type="radio"]:checked + .card .checkIcon {
display: flex;
position: absolute;
top: -10px;
right: -10px;
}
.header {
display: flex;
gap: var(--Spacing-x-half);
align-items: flex-start;
}
.priceType {
display: flex;
gap: var(--Spacing-x-half);
flex-wrap: wrap;
}
.terms {
padding-top: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
padding-bottom: var(--Spacing-x1);
}
.termsIcon {
padding-right: var(--Spacing-x1);
flex-shrink: 0;
flex-basis: 32px;
}

View File

@@ -0,0 +1,118 @@
"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 Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import ChequePrice from "./ChequePrice"
import styles from "./flexibilityOptionCheque.module.css"
import type { FlexibilityOptionChequeProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function FlexibilityOptionCheque({
features,
paymentTerm,
priceInformation,
product,
roomType,
roomTypeCode,
title,
rateName,
}: FlexibilityOptionChequeProps) {
const intl = useIntl()
const {
actions: { selectRateCheque },
roomNr,
selectedRate,
} = useRoomContext()
if (!product.bonusCheque) {
return null
}
function handleSelect() {
selectRateCheque({
features,
product,
roomType,
roomTypeCode,
})
}
const voucherRate = product.bonusCheque
const isSelected = !!(
selectedRate?.product.bonusCheque &&
selectedRate?.product.bonusCheque.rateCode === voucherRate?.rateCode &&
selectedRate?.roomTypeCode === roomTypeCode
)
const rate = product.bonusCheque
const chequeRateName =
rateName ?? intl.formatMessage({ id: "Corporate Cheque" })
return (
<label>
<input
checked={isSelected}
className={styles.radio}
name={`rateCode-${roomNr}-${rate.rateCode}`}
onChange={handleSelect}
type="radio"
value={rate.rateCode}
/>
<div className={styles.card}>
<div className={styles.header}>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
width={16}
height={16}
color="uiTextMediumContrast"
/>
</Button>
}
title={chequeRateName}
subtitle={`${title} (${paymentTerm})`}
>
<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}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
<div className={styles.priceType}>
<Caption color="uiTextHighContrast">{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
<ChequePrice
chequePrice={product.bonusCheque}
rateTitle={chequeRateName}
/>
<div className={styles.checkIcon}>
<CheckIcon color="white" height="16" width="16" />
</div>
</div>
</label>
)
}

View File

@@ -0,0 +1,36 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import styles from "./voucherPrice.module.css"
import { CurrencyEnum } from "@/types/enums/currency"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
export default function VoucherPrice({
voucherPrice,
rateTitle,
}: {
voucherPrice: NonNullable<Product["voucher"]>
rateTitle: string
}) {
return (
<dl className={styles.priceList}>
<div className={styles.priceRow}>
<dt>
<Caption type="bold" color="red">
{rateTitle}
</Caption>
</dt>
<dd>
<div className={styles.price}>
<Subtitle type="two" color="red">
{voucherPrice.numberOfVouchers}
</Subtitle>
<Body color="red">{CurrencyEnum.Voucher}</Body>
</div>
</dd>
</div>
</dl>
)
}

View File

@@ -0,0 +1,15 @@
.priceList {
margin: 0;
}
.priceRow {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.price {
display: flex;
gap: var(--Spacing-x-half);
align-items: baseline;
}

View File

@@ -0,0 +1,73 @@
.card {
border-radius: var(--Corner-radius-Large);
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
background-color: var(--Base-Surface-Secondary-light-Normal);
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
position: relative;
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
height: 100%;
}
.card:hover {
cursor: pointer;
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.checkIcon {
width: 24px;
height: 24px;
border-radius: 100px;
background-color: var(--UI-Input-Controls-Fill-Selected);
border: 2px solid var(--Base-Border-Inverted);
justify-content: center;
align-items: center;
display: none;
}
input[type="radio"].radio {
opacity: 0;
position: fixed;
width: 0;
}
input[type="radio"]:checked + .card {
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
input[type="radio"]:checked + .card .checkIcon {
display: flex;
position: absolute;
top: -10px;
right: -10px;
}
.header {
display: flex;
gap: var(--Spacing-x-half);
align-items: flex-start;
}
.priceType {
display: flex;
gap: var(--Spacing-x-half);
flex-wrap: wrap;
}
.terms {
padding-top: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
padding-bottom: var(--Spacing-x1);
}
.termsIcon {
padding-right: var(--Spacing-x1);
flex-shrink: 0;
flex-basis: 32px;
}

View File

@@ -0,0 +1,117 @@
"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 Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import VoucherPrice from "./VoucherPrice"
import styles from "./flexibilityOptionVoucher.module.css"
import type { FlexibilityOptionVoucherProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
export default function FlexibilityOptionVoucher({
features,
paymentTerm,
priceInformation,
product,
roomType,
roomTypeCode,
title,
rateName,
}: FlexibilityOptionVoucherProps) {
const intl = useIntl()
const {
actions: { selectRateVoucher },
roomNr,
selectedRate,
} = useRoomContext()
if (!product.voucher) {
return null
}
function handleSelect() {
selectRateVoucher({
features,
product,
roomType,
roomTypeCode,
})
}
const voucherRate = product.voucher
const isSelected = !!(
selectedRate?.product.voucher &&
selectedRate?.product.voucher.rateCode === voucherRate?.rateCode &&
selectedRate?.roomTypeCode === roomTypeCode
)
const rate = product.voucher
const voucherRateName = rateName ?? intl.formatMessage({ id: "Voucher" })
return (
<label>
<input
checked={isSelected}
className={styles.radio}
name={`rateCode-${roomNr}-${rate.rateCode}`}
onChange={handleSelect}
type="radio"
value={rate.rateCode}
/>
<div className={styles.card}>
<div className={styles.header}>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
width={16}
height={16}
color="uiTextMediumContrast"
/>
</Button>
}
title={voucherRateName}
subtitle={`${title} (${paymentTerm})`}
>
<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}
></CheckIcon>
{info}
</Body>
))}
</div>
</Modal>
<div className={styles.priceType}>
<Caption color="uiTextHighContrast">{title}</Caption>
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
</div>
</div>
<VoucherPrice
voucherPrice={product.voucher}
rateTitle={voucherRateName}
/>
<div className={styles.checkIcon}>
<CheckIcon color="white" height="16" width="16" />
</div>
</div>
</label>
)
}

View File

@@ -20,6 +20,8 @@ import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import { cardVariants } from "./cardVariants"
import FlexibilityOption from "./FlexibilityOption"
import FlexibilityOptionPoints from "./FlexibilityOptionPoints"
import FlexibilityOptionCheque from "./FlexibilityOptionCheque"
import FlexibilityOptionVoucher from "./FlexibilityOptionVoucher"
import RoomSize from "./RoomSize"
import styles from "./roomCard.module.css"
@@ -171,6 +173,10 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
rateCode = product.member.rateCode
} else if (product.public?.rateCode) {
rateCode = product.public.rateCode
} else if (product.voucher) {
rateCode = product.voucher.rateCode
} else if (product.bonusCheque) {
rateCode = product.bonusCheque.rateCode
} else if (product.redemptions?.length) {
// In case of redemption there will be same rate terms and title
// irrespective of ratecodes
@@ -292,7 +298,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const isAvailable =
product.public ||
(product.member && isUserLoggedIn && isMainRoom) ||
product.redemptions?.length
product.redemptions?.length ||
product.bonusCheque ||
product.voucher
const rateDefinition = getRateDefinition(
product,
roomAvailability.rateDefinitions
@@ -307,14 +315,35 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
roomTypeCode: roomConfiguration.roomTypeCode,
title: rateTitle,
rateName:
isBookingCodeRate || isRedemption
isBookingCodeRate || isRedemption ||
product.voucher ||
product.bonusCheque
? rateDefinition?.title
: undefined,
}
return isRedemption ? (
<FlexibilityOptionPoints key={product.rate} {...props} />
) : (
<FlexibilityOption key={product.rate} {...props} />
return (<>
{isRedemption &&
<FlexibilityOptionPoints key={product.rate} {...props} />}
{product.voucher ? (
<FlexibilityOptionVoucher
key={product.rate}
{...props}
rateName={rateDefinition?.title}
product={product}
/>
) : null}
{product.bonusCheque ? (
<FlexibilityOptionCheque
key={product.rate}
{...props}
rateName={rateDefinition?.title}
product={product}
/>
) : null}
{product.public || product.member ? (
<FlexibilityOption key={product.rate} {...props} />
) : null}
</>
)
})}
</>

View File

@@ -38,52 +38,60 @@ export default function RoomSelectionPanel() {
(state) => state.activeCodeFilter
)
// Regular Rates (Save, Change and Flex) always should send both public and member rates
// so we can check public rates for availability
const isRegularRatesAvailableWithCode =
bookingCode &&
rooms.some(
const isVoucherOrCorpChequeRate = rooms.find((room) =>
room.products.some((product) => product.voucher || product.bonusCheque)
)
let isRegularRatesAvailableWithCode = false,
isBookingCodeRatesAvailable = false
let visibleRooms = rooms
if (bookingCode && !isVoucherOrCorpChequeRate) {
// Regular Rates (Save, Change and Flex) always should send both public and member rates
// so we can check public rates for availability
isRegularRatesAvailableWithCode = rooms.some(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.some(
(product) => product.public?.rateType === RateTypeEnum.Regular
(product) =>
product.public?.rateType === RateTypeEnum.Regular ||
product.member?.rateType === RateTypeEnum.Regular
)
)
// Booking codes rate comes with various rate types but Regular is reserved
// for non-booking code rates (Save, Change & Flex)
// With Booking code rates we will always obtain public rate and maybe a member rate
// so we check for public rate and ignore member rate
const isBookingCodeRatesAvailable =
bookingCode &&
rooms.some(
// Booking codes rate comes with various rate types but Regular is reserved
// for non-booking code rates (Save, Change & Flex)
// With Booking code rates we will always obtain public rate and maybe a member rate
// so we check for public rate and ignore member rate
isBookingCodeRatesAvailable = rooms.some(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.some(
(product) => product.public?.rateType !== RateTypeEnum.Regular
(product) =>
product.public?.rateType !== RateTypeEnum.Regular ||
product.member?.rateType !== RateTypeEnum.Regular
)
)
// Show all rooms if either booking code rates or regular rates are not available
// or filter selection is All rooms
const showAllRooms =
!isBookingCodeRatesAvailable ||
!isRegularRatesAvailableWithCode ||
activeCodeFilter === BookingCodeFilterEnum.All
const bookingCodeDiscountedRooms = rooms.filter(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.every(
(product) => product.public?.rateType !== RateTypeEnum.Regular
if (activeCodeFilter === BookingCodeFilterEnum.Discounted) {
visibleRooms = rooms.filter(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.every(
(product) => product.public?.rateType !== RateTypeEnum.Regular
)
)
)
const regularRateRooms = rooms.filter(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.every(
(product) => product.public?.rateType === RateTypeEnum.Regular
} else if (activeCodeFilter === BookingCodeFilterEnum.Regular) {
visibleRooms = rooms.filter(
(room) =>
room.status === AvailabilityEnum.Available &&
room.products.every(
(product) =>
product.public?.rateType === RateTypeEnum.Regular ||
product.member?.rateType === RateTypeEnum.Regular
)
)
)
}
}
// Show booking code filter when both of the booking code rates or regular rates are available
const showBookingCodeFilter =
isRegularRatesAvailableWithCode && isBookingCodeRatesAvailable
@@ -114,7 +122,10 @@ export default function RoomSelectionPanel() {
return (
<>
{noAvailableRooms || (bookingCode && !isBookingCodeRatesAvailable) ? (
{noAvailableRooms ||
(bookingCode &&
!isBookingCodeRatesAvailable &&
!isVoucherOrCorpChequeRate) ? (
<div className={styles.hotelAlert}>
<Alert
type={AlertTypeEnum.Info}
@@ -142,27 +153,12 @@ export default function RoomSelectionPanel() {
<RoomTypeFilter />
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
<ul className={styles.roomList}>
{/* Show either Booking code filtered rooms or all the rooms */}
{showAllRooms
? rooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))
: activeCodeFilter === BookingCodeFilterEnum.Discounted
? bookingCodeDiscountedRooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))
: regularRateRooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
{visibleRooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}
/>
))}
</ul>
</>
)

View File

@@ -25,6 +25,7 @@ export default function Input({
readOnly = false,
registerOptions = {},
type = "text",
hideError,
}: InputProps) {
const intl = useIntl()
const { control } = useFormContext()
@@ -73,7 +74,7 @@ export default function Input({
</Text>
</Caption>
) : null}
{fieldState.error ? (
{fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{intl.formatMessage({ id: fieldState.error.message })}

View File

@@ -6,4 +6,5 @@ export interface InputProps
label: string
name: string
registerOptions?: RegisterOptions
hideError?: boolean
}