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

@@ -110,6 +110,8 @@ export default async function DetailsPage({
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
redemptionRate: roomAvailability.redemptionRate,
voucherRate: roomAvailability.voucherRate,
chequeRate: roomAvailability.chequeRate,
},
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,

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
}

View File

@@ -205,6 +205,7 @@
"Continue with new price": "Fortsæt med ny pris",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Kunne ikke finde den anmodede ressource",
"Country": "Land",
"Country code": "Landekode",

View File

@@ -206,6 +206,7 @@
"Continue with new price": "Mit neuer Preis fortsetzen",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Die angeforderte Ressource konnte nicht gefunden werden.",
"Country": "Land",
"Country code": "Landesvorwahl",

View File

@@ -204,6 +204,7 @@
"Continue with new price": "Continue with new price",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Could not find requested resource",
"Country": "Country",
"Country code": "Country code",

View File

@@ -205,6 +205,7 @@
"Continue with new price": "Jatka uudella hinnalla",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Pyydettyä resurssia ei löytynyt",
"Country": "Maa",
"Country code": "Maatunnus",

View File

@@ -204,6 +204,7 @@
"Continue with new price": "Fortsett med ny pris",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Kunne ikke finne den forespurte ressursen",
"Country": "Land",
"Country code": "Landskode",

View File

@@ -204,6 +204,7 @@
"Continue with new price": "Fortsätt med nytt pris",
"Copied to clipboard": "Copied to clipboard",
"Copy promotion code": "Copy promotion code",
"Corporate Cheque": "Corporate Cheque",
"Could not find requested resource": "Det gick inte att hitta den begärda resursen",
"Country": "Land",
"Country code": "Landskod",

View File

@@ -17,6 +17,7 @@ import { guestDetailsSchema } from "@/components/HotelReservation/EnterDetails/D
import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { InitialState, RoomState } from "@/types/stores/enter-details"

View File

@@ -22,6 +22,12 @@ export default function RoomProvider({
const selectRateRedemption = useRatesStore((state) =>
state.actions.selectRateRedemption(idx)
)
const selectRateCheque = useRatesStore((state) =>
state.actions.selectRateCheque(idx)
)
const selectRateVoucher = useRatesStore((state) =>
state.actions.selectRateVoucher(idx)
)
const roomNr = idx + 1
return (
<RoomContext.Provider
@@ -33,6 +39,8 @@ export default function RoomProvider({
selectFilter,
selectRate,
selectRateRedemption,
selectRateCheque,
selectRateVoucher,
},
isActiveRoom: activeRoom === idx,
isMainRoom: roomNr === 1,

View File

@@ -261,6 +261,38 @@ function transformRoomConfigs({
}
}
const voucherRate = product.voucher
if (voucherRate?.rateCode) {
const voucherRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === voucherRate.rateCode
)
if (voucherRateDefinition) {
const rate = getRate(voucherRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
const chequeRate = product.bonusCheque
if (chequeRate?.rateCode) {
const chequeRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === chequeRate.rateCode
)
if (chequeRateDefinition) {
const rate = getRate(chequeRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
return product
})

View File

@@ -717,7 +717,9 @@ export const getRoomAvailability = async (
(rate) =>
rate.public?.rateCode === rateCode ||
rate.member?.rateCode === rateCode ||
rate.redemptions?.find((r) => r?.rateCode === rateCode)
rate.redemptions?.find((r) => r?.rateCode === rateCode) ||
rate.bonusCheque?.rateCode === rateCode ||
rate.voucher?.rateCode === rateCode
)
if (!rateTypes) {
@@ -791,6 +793,7 @@ export const getRoomAvailability = async (
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
chequeRate: rates?.bonusCheque,
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
@@ -809,6 +812,7 @@ export const getRoomAvailability = async (
: undefined,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
voucherRate: rates?.voucher,
}
}
@@ -889,15 +893,23 @@ export const hotelQueryRouter = router({
return null
}
// Do not search for regular rates if voucher or corporate cheque codes
const isVoucherOrChqRate =
bookingCodeAvailabilityResponse?.availability.some(
(hotel) =>
hotel.productType?.bonusCheque || hotel.productType?.voucher
)
// Get regular availability of hotels which don't have availability with booking code.
const unavailableHotelIds =
bookingCodeAvailabilityResponse?.availability
.filter((hotel) => {
return hotel.status === "NotAvailable"
})
.flatMap((hotel) => {
return hotel.hotelId
})
const unavailableHotelIds = !isVoucherOrChqRate
? bookingCodeAvailabilityResponse?.availability
.filter((hotel) => {
return hotel.status === "NotAvailable"
})
.flatMap((hotel) => {
return hotel.hotelId
})
: null
// All hotels have availability with booking code no need to fetch regular prices.
// return response as is without any filtering as below.

View File

@@ -1,14 +1,18 @@
import { z } from "zod"
import {
productTypeChequeSchema,
productTypePointsSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "../productTypePrice"
export const productTypeSchema = z
.object({
bonusCheque: productTypeChequeSchema.optional(),
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
voucher: productTypeVoucherSchema.optional(),
})
.optional()

View File

@@ -27,6 +27,17 @@ export const pointsSchema = z
additionalPrice: data.additionalPricePerStay,
}))
export const voucherSchema = z.object({
currency: z.nativeEnum(CurrencyEnum),
pricePerStay: z.number(),
})
export const chequeSchema = z.object({
additionalPricePerStay: z.number().optional(),
currency: z.nativeEnum(CurrencyEnum).optional(),
numberOfBonusCheques: z.coerce.number(),
})
const partialPriceSchema = z.object({
rateCode: z.string(),
rateType: z.string().optional(),
@@ -39,4 +50,14 @@ export const productTypePriceSchema = partialPriceSchema.extend({
export const productTypePointsSchema = partialPriceSchema.extend({
localPrice: pointsSchema,
requestedPrice: pointsSchema.optional(),
})
export const productTypeVoucherSchema = partialPriceSchema.extend({
numberOfVouchers: z.coerce.number(),
})
export const productTypeChequeSchema = partialPriceSchema.extend({
localPrice: chequeSchema,
requestedPrice: chequeSchema.optional(),
})

View File

@@ -33,7 +33,14 @@ export const roomConfigurationSchema = z
// No need of rate check in reward night scenario
return { ...data }
} else {
/**
const isVoucher = data.products.some((product) => product.voucher)
const isCorpChq = data.products.some((product) => product.bonusCheque)
if (isVoucher || isCorpChq) {
return {
...data,
}
}
/**
* Just guaranteeing that if all products all miss
* both public and member rateCode that status is
* set to `NotAvailable`

View File

@@ -2,7 +2,9 @@ import { z } from "zod"
import {
productTypePointsSchema,
productTypeChequeSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "../productTypePrice"
export const productSchema = z
@@ -10,9 +12,11 @@ export const productSchema = z
// Is product flex rate
isFlex: z.boolean().default(false),
productType: z.object({
bonusCheque: productTypeChequeSchema.optional(),
member: productTypePriceSchema.optional(),
public: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
voucher: productTypeVoucherSchema.optional(),
}),
// Used to set the rate that we use to chose titles etc.
rate: z.enum(["change", "flex", "save"]).default("save"),

View File

@@ -79,6 +79,23 @@ export function subtract(...nums: (number | string | undefined)[]) {
}, 0)
}
export function getCurrency(roomRate: RoomRate) {
const requestedCurrency = <CurrencyEnum>(
(roomRate.publicRate?.requestedPrice?.currency ??
(roomRate.chequeRate && CurrencyEnum.CC))
)
const localCurrency = <CurrencyEnum>(
(roomRate.publicRate?.localPrice.currency ??
(roomRate.voucherRate && CurrencyEnum.Voucher) ??
(roomRate.chequeRate && CurrencyEnum.CC))
)
return {
requestedCurrency,
localCurrency,
}
}
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && roomRate.memberRate) {
return {
@@ -132,6 +149,62 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
}
}
if (roomRate.chequeRate) {
return {
perNight: {
requested: roomRate.chequeRate.requestedPrice && {
currency: CurrencyEnum.CC,
price: roomRate.chequeRate.requestedPrice.numberOfBonusCheques,
additionalPrice:
roomRate.chequeRate.requestedPrice.additionalPricePerStay,
additionalPriceCurrency: roomRate.chequeRate.requestedPrice.currency,
},
local: {
currency: CurrencyEnum.CC,
price: roomRate.chequeRate.localPrice.numberOfBonusCheques,
additionalPrice:
roomRate.chequeRate.localPrice.additionalPricePerStay,
additionalPriceCurrency: roomRate.chequeRate.localPrice.currency,
},
},
perStay: {
requested: roomRate.chequeRate.requestedPrice && {
currency: CurrencyEnum.CC,
price: roomRate.chequeRate.requestedPrice.numberOfBonusCheques,
additionalPrice:
roomRate.chequeRate.requestedPrice.additionalPricePerStay,
additionalPriceCurrency: roomRate.chequeRate.requestedPrice.currency,
},
local: {
currency: CurrencyEnum.CC,
price: roomRate.chequeRate.localPrice.numberOfBonusCheques,
additionalPrice:
roomRate.chequeRate.localPrice.additionalPricePerStay,
additionalPriceCurrency: roomRate.chequeRate.localPrice.currency,
},
},
}
}
if (roomRate.voucherRate) {
return {
perNight: {
requested: undefined,
local: {
currency: CurrencyEnum.Voucher,
price: roomRate.voucherRate.numberOfVouchers,
},
},
perStay: {
requested: undefined,
local: {
currency: CurrencyEnum.Voucher,
price: roomRate.voucherRate.numberOfVouchers,
},
},
}
}
if (roomRate.redemptionRate) {
return {
// ToDo Handle perNight as undefined
@@ -210,6 +283,87 @@ export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
)
}
export const calculateVoucherPrice = (roomRates: RoomRate[]) => {
return roomRates.reduce<Price>(
(total, room) => {
const rate = room.voucherRate
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 = (roomRates: RoomRate[]) => {
return roomRates.reduce<Price>(
(total, room) => {
const rate = room.chequeRate
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,
}
)
}
export function calcTotalPrice(
rooms: RoomState[],
currency: Price["local"]["currency"],
@@ -277,6 +431,12 @@ export function calcTotalPrice(
breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal?.requestedPrice ?? 0
),
additionalPrice: add(
acc.local.additionalPrice,
roomPrice.perStay.local.additionalPrice,
breakfastLocalPrice * room.adults * nights,
roomFeaturesTotal?.local ?? 0
),
},
}

View File

@@ -11,6 +11,8 @@ import { DetailsContext } from "@/contexts/Details"
import {
add,
calcTotalPrice,
calculateChequePrice,
calculateVoucherPrice,
checkRoomProgress,
extractGuestFromUser,
findNextInvalidStep,
@@ -56,11 +58,22 @@ export function createDetailsStore(
const isRedemption =
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION
const isVoucher = initialState.rooms.some((room) => room.roomRate.voucherRate)
const isCorpChq = initialState.rooms.some((room) => room.roomRate.chequeRate)
let initialTotalPrice: Price
if (isRedemption && initialState.rooms[0].roomRate.redemptionRate) {
initialTotalPrice = PointsPriceSchema.parse(
initialState.rooms[0].roomRate.redemptionRate
)
} else if (isVoucher) {
initialTotalPrice = calculateVoucherPrice(
initialState.rooms.map((r) => r.roomRate)
)
} else if (isCorpChq) {
initialTotalPrice = calculateChequePrice(
initialState.rooms.map((r) => r.roomRate)
)
} else {
initialTotalPrice = getTotalPrice(
initialState.rooms.map((r) => r.roomRate),
@@ -270,7 +283,7 @@ export function createDetailsStore(
const currentTotalPriceRequested = state.totalPrice.requested
let stateTotalRequestedPrice = 0
if (currentTotalPriceRequested) {
stateTotalRequestedPrice = currentTotalPriceRequested.price
stateTotalRequestedPrice = currentTotalPriceRequested.price ?? 0
}
const stateTotalLocalPrice = state.totalPrice.local.price
@@ -305,7 +318,7 @@ export function createDetailsStore(
},
local: {
currency: breakfast.localPrice.currency,
price: stateTotalLocalPrice + breakfastTotalPrice,
price: stateTotalLocalPrice ?? 0 + breakfastTotalPrice,
regularPrice: stateTotalLocalRegularPrice
? stateTotalLocalRegularPrice + breakfastTotalPrice
: undefined,

View File

@@ -28,7 +28,9 @@ function findSelectedRate(
room.products.find(
(product) =>
product.public?.rateCode === rateCode ||
product.member?.rateCode === rateCode
product.member?.rateCode === rateCode ||
product.bonusCheque?.rateCode === rateCode ||
product.voucher?.rateCode === rateCode
)
)
}
@@ -94,7 +96,9 @@ export function createRatesStore({
product.member?.rateCode === room.rateCode ||
product.redemptions?.find(
(redemption) => redemption?.rateCode === room.rateCode
)
) ||
product.bonusCheque?.rateCode === room.rateCode ||
product.voucher?.rateCode === room.rateCode
)
)
const redemptionProduct = selectedRoom?.products[0].redemptions?.find(
@@ -103,18 +107,26 @@ export function createRatesStore({
const product = selectedRoom?.products.find(
(p) =>
p.public?.rateCode === room.rateCode ||
p.member?.rateCode === room.rateCode
p.member?.rateCode === room.rateCode ||
p.bonusCheque?.rateCode === room.rateCode ||
p.voucher?.rateCode === room.rateCode
)
if (selectedRoom && product) {
rateSummary[idx] = {
features: selectedRoom.features,
member: product.member,
public: product.public,
redemption: undefined,
rate: product.rate,
roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode,
}
if (product.member || product.public) {
rateSummary[idx].member = product.member
rateSummary[idx].public = product.public
} else if (product.bonusCheque) {
rateSummary[idx].bonusCheque = product.bonusCheque
} else if (product.voucher) {
rateSummary[idx].voucher = product.voucher
}
} else if (selectedRoom && redemptionProduct) {
rateSummary[idx] = {
features: selectedRoom.features,
@@ -300,6 +312,90 @@ export function createRatesStore({
selectedRate.roomTypeCode
)
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
},
selectRateVoucher() {
return function (selectedRate) {
return set(
produce((state: RatesState) => {
const voucherRate = selectedRate.product.voucher
if (!voucherRate) {
return
}
state.rooms[0].selectedRate = selectedRate
state.rateSummary[0] = {
features: selectedRate.features,
voucher: selectedRate.product.voucher,
bonusCheque: undefined,
package: state.rooms[0].selectedPackage,
rate: selectedRate.product.rate,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
const searchParams = new URLSearchParams(state.searchParams)
searchParams.set(`room[0].ratecode`, voucherRate.rateCode)
searchParams.set(`room[0].roomtype`, selectedRate.roomTypeCode)
if (state.rateSummary.length === state.booking.rooms.length) {
state.activeRoom = -1
} else {
state.activeRoom = 1
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
"",
`${state.pathname}?${searchParams}`
)
})
)
}
},
selectRateCheque(idx) {
return function (selectedRate) {
return set(
produce((state: RatesState) => {
const chequeRate = selectedRate.product.bonusCheque
if (!chequeRate) {
return
}
state.rooms[idx].selectedRate = selectedRate
state.rateSummary[idx] = {
features: selectedRate.features,
package: state.rooms[idx].selectedPackage,
rate: selectedRate.product.rate,
voucher: undefined,
bonusCheque: chequeRate,
roomType: selectedRate.roomType,
roomTypeCode: selectedRate.roomTypeCode,
}
const searchParams = new URLSearchParams(state.searchParams)
searchParams.set(`room[${idx}].ratecode`, chequeRate.rateCode)
searchParams.set(
`room[${idx}].roomtype`,
selectedRate.roomTypeCode
)
if (state.rateSummary.length === state.booking.rooms.length) {
state.activeRoom = -1
} else {
state.activeRoom = idx + 1
}
state.searchParams = new ReadonlyURLSearchParams(searchParams)
window.history.pushState(
{},
@@ -332,7 +428,9 @@ export function createRatesStore({
const product = selectedRate?.products.find(
(prd) =>
prd.public?.rateCode === room.rateCode ||
prd.member?.rateCode === room.rateCode
prd.member?.rateCode === room.rateCode ||
prd.bonusCheque?.rateCode === room.rateCode ||
prd.voucher?.rateCode === room.rateCode
)
const selectedPackage = room.packages?.[0]

View File

@@ -29,7 +29,9 @@ export type JoinScandicFriendsCardProps = {
}
export type RoomRate = {
memberRate?: Product["member"]
publicRate?: Product["public"]
memberRate?: NonNullable<Product["member"]>
publicRate?: NonNullable<Product["public"]>
voucherRate?: NonNullable<Product["voucher"]>
chequeRate?: NonNullable<Product["bonusCheque"]>
redemptionRate?: ProductTypePointsSchema
}

View File

@@ -1,4 +1,8 @@
import type { ProductTypePrices } from "@/types/trpc/routers/hotel/availability"
import type {
ProductTypeCheque,
ProductTypePrices,
ProductTypeVoucher,
} from "@/types/trpc/routers/hotel/availability"
export type PriceCardProps = {
productTypePrices: ProductTypePrices
@@ -10,3 +14,11 @@ export type PointsRowProps = {
additionalPricePerStay?: number
additionalPriceCurrency?: string
}
export type VoucherCardProps = {
productTypeVoucher: ProductTypeVoucher
}
export type BonusChequeCardProps = {
productTypeVoucher: ProductTypeCheque
}

View File

@@ -25,6 +25,12 @@ export type FlexibilityOptionProps = {
rateName?: string // Obtained in case of booking code and redemption rates
}
export interface FlexibilityOptionVoucherProps
extends Omit<FlexibilityOptionProps, "| product"> {
product: Product
}
export type FlexibilityOptionChequeProps = FlexibilityOptionVoucherProps
export interface PriceListProps {
publicPrice?: ProductPrice | Record<string, never>
memberPrice?: ProductPrice | Record<string, never>

View File

@@ -44,14 +44,32 @@ export type Rate = {
roomTypeCode: RoomConfiguration["roomTypeCode"]
} & (
| {
bonusCheque?: never
member?: NonNullable<Product["member"]>
public?: NonNullable<Product["public"]>
redemption?: never
voucher?: never
}
| {
bonusCheque?: never
member?: never
public?: never
redemption: NonNullable<ProductTypePointsSchema>
redemption?: never
voucher?: NonNullable<Product["voucher"]>
}
| {
bonusCheque?: NonNullable<Product["bonusCheque"]>
member?: never
public?: never
redemption?: never
voucher?: never
}
| {
bonusCheque?: never
member?: never
public?: never
redemption?: NonNullable<ProductTypePointsSchema>
voucher?: never
}
)

View File

@@ -15,6 +15,8 @@ export interface RoomContextValue extends SelectedRoom {
rate: SelectedRate,
selectedRateCode?: string
) => void
selectRateCheque: (rate: SelectedRate) => void
selectRateVoucher: (rate: SelectedRate) => void
}
isActiveRoom: boolean
isMainRoom: boolean

View File

@@ -5,5 +5,7 @@ export enum CurrencyEnum {
PLN = "PLN",
SEK = "SEK",
POINTS = "POINTS",
Voucher = "Voucher",
CC = "CC",
Unknown = "Unknown",
}

View File

@@ -3,6 +3,7 @@ export enum RateTypeEnum {
BonusCheque = "BonusCheque",
Company = "Company",
Promotion = "Promotion",
PublicPromotion = "PublicPromotion",
Redemption = "Redemption",
Regular = "Regular",
TravelAgent = "TravelAgent",

View File

@@ -30,6 +30,8 @@ interface Actions {
selectRateRedemption: (
idx: number
) => (rate: SelectedRate, selectedRateCode?: string) => void
selectRateVoucher: (idx: number) => (rate: SelectedRate) => void
selectRateCheque: (idx: number) => (rate: SelectedRate) => void
}
export interface SelectedRate {

View File

@@ -10,8 +10,10 @@ import type { z } from "zod"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
import type {
productTypeChequeSchema,
productTypePointsSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "@/server/routers/hotels/schemas/productTypePrice"
export type HotelsAvailability = z.output<typeof hotelsAvailabilitySchema>
@@ -30,6 +32,8 @@ export type SelectedRoomAvailabilitySchema = z.output<
export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema>
export type ProductTypePoints = z.output<typeof productTypePointsSchema>
export type ProductTypeVoucher = z.output<typeof productTypeVoucherSchema>
export type ProductTypeCheque = z.output<typeof productTypeChequeSchema>
export type HotelsAvailabilityItem =
HotelsAvailability["data"][number]["attributes"]

View File

@@ -16,11 +16,17 @@ export function getSingleDecimal(n: Number | string) {
* @param currency - currency code
* @returns localized and formatted number in string type with currency
*/
export function formatPrice(intl: IntlShape, price: number, currency: string) {
export function formatPrice(
intl: IntlShape,
price: number,
currency: string,
additionalPrice?: number,
additionalPriceCurrency?: string
) {
const localizedPrice = intl.formatNumber(price, {
minimumFractionDigits: 0,
})
return `${localizedPrice} ${currency}`
return `${localizedPrice} ${currency} ${additionalPrice ? "+ " + additionalPrice + " " + additionalPriceCurrency : ""}`
}
// This will handle redemption and bonus cheque (corporate cheque) scneario with partial payments