feat: SW-1588 Implemented booking code select-rate

This commit is contained in:
Hrishikesh Vaipurkar
2025-02-14 20:50:42 +01:00
parent 832b6c27e0
commit 8966e56820
28 changed files with 242 additions and 60 deletions

View File

@@ -76,6 +76,7 @@ export default async function DetailsPage({
roomStayStartDate: booking.fromDate,
roomStayEndDate: booking.toDate,
roomTypeCode: room.roomTypeCode,
bookingCode: booking.bookingCode,
}
const packages = room.packages

View File

@@ -18,6 +18,7 @@ export default function TabletCodeInput({
{...register("bookingCode.value", {
onChange: (e) => updateValue(e.target.value),
})}
defaultValue={defaultValue}
autoComplete="off"
/>
)

View File

@@ -31,6 +31,11 @@
width: 100%;
}
.bookingCodeTooltip {
max-width: 560px;
margin-top: var(--Spacing-x2);
}
@media screen and (max-width: 767px) {
.hideOnMobile {
display: none;

View File

@@ -115,7 +115,7 @@ export default function BookingCode() {
},
})}
>
<Caption color="red" type="bold" asChild>
<Caption color="uiTextMediumContrast" asChild>
<span>{codeVoucher}</span>
</Caption>
</Checkbox>
@@ -228,7 +228,9 @@ function CodeRulesModal() {
}
title={codeVoucher}
>
<Body color="uiTextHighContrast">{bookingCodeTooltipText}</Body>
<Body color="uiTextHighContrast" className={styles.bookingCodeTooltip}>
{bookingCodeTooltipText}
</Body>
</Modal>
)
}

View File

@@ -24,6 +24,8 @@ import {
HotelCardListingTypeEnum,
} from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import { AlertTypeEnum } from "@/types/enums/alert"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
export default function HotelCardListing({
hotelData,
@@ -55,10 +57,11 @@ export default function HotelCardListing({
? sortedHotels.filter(
(hotel) =>
!hotel.price ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.price?.public?.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.price?.public?.rateType?.toLowerCase()
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.price?.public?.rateType !== RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.price?.public?.rateType === RateTypeEnum.Regular)
)
: sortedHotels

View File

@@ -11,6 +11,8 @@ import styles from "./bookingCodeFilter.module.css"
import type { Key } from "react"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
export default function BookingCodeFilter() {
const intl = useIntl()
const activeCodeFilter = useBookingCodeFilterStore(
@@ -21,20 +23,20 @@ export default function BookingCodeFilter() {
const bookingCodeFilterItems = [
{
label: intl.formatMessage({ id: "Discounted rooms" }),
value: "discounted",
value: BookingCodeFilterEnum.Discounted,
},
{
label: intl.formatMessage({ id: "Full price rooms" }),
value: "regular",
value: BookingCodeFilterEnum.Regular,
},
{
label: intl.formatMessage({ id: "See all" }),
value: "all",
value: BookingCodeFilterEnum.All,
},
]
function updateFilter(selectedFilter: Key) {
setFilter(selectedFilter as string)
setFilter(selectedFilter as BookingCodeFilterEnum)
}
return (

View File

@@ -28,6 +28,8 @@ import styles from "./selectHotelMapContent.module.css"
import type { HotelData } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { SelectHotelMapProps } from "@/types/components/hotelReservation/selectHotel/map"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
const SKELETON_LOAD_DELAY = 750
@@ -83,10 +85,11 @@ export default function SelectHotelContent({
? hotelPins.filter(
(hotel) =>
!hotel.publicPrice ||
activeCodeFilter === "all" ||
(activeCodeFilter === "discounted" &&
hotel.rateType?.toLowerCase() !== "regular") ||
activeCodeFilter === hotel.rateType?.toLowerCase()
activeCodeFilter === BookingCodeFilterEnum.All ||
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
hotel.rateType !== RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
hotel.rateType === RateTypeEnum.Regular)
)
: hotelPins
return updatedHotelsList.filter((hotel) =>

View File

@@ -101,32 +101,57 @@ export default function PriceList({
</div>
)}
<div className={styles.priceRow}>
<dt>
<Caption type="bold" color={memberLocalPrice ? "red" : "disabled"}>
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
<dd>
{memberLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="red">
{totalMemberLocalPricePerNight}
</Subtitle>
<Body color="red" textTransform="bold">
{memberLocalPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
{memberLocalPrice && !publicLocalPrice.regularPricePerNight ? (
<div className={styles.priceRow}>
<dt>
<Caption type="bold" color={memberLocalPrice ? "red" : "disabled"}>
{intl.formatMessage({ id: "Member price" })}
</Caption>
</dt>
<dd>
{memberLocalPrice ? (
<div className={styles.price}>
<Subtitle type="two" color="red">
{totalMemberLocalPricePerNight}
</Subtitle>
<Body color="red" textTransform="bold">
{memberLocalPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
) : (
<Body textTransform="bold" color="disabled">
-
</Body>
</div>
) : (
<Body textTransform="bold" color="disabled">
-
</Body>
)}
</dd>
</div>
)}
</dd>
</div>
) : (
publicLocalPrice.regularPricePerNight && (
<div className={styles.priceRow}>
<dt>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({ id: "Regular price" })}
</Caption>
</dt>
<dd>
<div className={styles.priceStriked}>
<Subtitle type="two" color="uiTextHighContrast">
{publicLocalPrice.regularPricePerNight}
</Subtitle>
<Body color="uiTextHighContrast" textTransform="bold">
{publicLocalPrice.currency}
<span className={styles.perNight}>
/{intl.formatMessage({ id: "night" })}
</span>
</Body>
</div>
</dd>
</div>
)
)}
{showRequestedPrice && (
<div className={styles.priceRow}>
<dt>

View File

@@ -17,6 +17,12 @@
gap: var(--Spacing-x-half);
}
.priceStriked {
display: flex;
gap: var(--Spacing-x-half);
text-decoration: line-through;
}
.perNight {
font-weight: 400;
font-size: var(--typography-Caption-Regular-fontSize);

View File

@@ -100,4 +100,5 @@ input[type="radio"]:checked + .card .checkIcon {
.termsIcon {
padding-right: var(--Spacing-x1);
flex-shrink: 0;
flex-basis: 32px;
}

View File

@@ -9,7 +9,7 @@ import { useRatesStore } from "@/stores/select-rate"
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
import { getRates } from "@/components/HotelReservation/SelectRate/utils"
import { getIconForFeatureCode } from "@/components/HotelReservation/utils"
import { ErrorCircleIcon } from "@/components/Icons"
import { ErrorCircleIcon, PriceTagIcon } from "@/components/Icons"
import ImageGallery from "@/components/ImageGallery"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
@@ -28,7 +28,9 @@ import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHote
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Product, RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
import { RateTypeEnum } from "@/types/enums/rateType"
import { useSearchParams } from "next/navigation"
function getBreakfastMessage(
publicBreakfastIncluded: boolean,
@@ -72,6 +74,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const lessThanFiveRoomsLeft =
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
const searchParams = useSearchParams()
const bookingCode = searchParams.get("bookingCode")
const {
hotelId,
hotelType,
@@ -138,39 +143,52 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
const payLater = intl.formatMessage({ id: "Pay later" })
const payNow = intl.formatMessage({ id: "Pay now" })
function getRate(rateCode: string) {
function getRate(rateCode: string, rateDefinition?: RateDefinition) {
switch (rateCode) {
case "change":
return {
isFlex: false,
notAvailable: false,
title: freeBooking,
terms: rateDefinition?.generalTerms,
}
case "flex":
return {
isFlex: true,
notAvailable: false,
title: freeCancelation,
terms: rateDefinition?.generalTerms,
}
case "save":
return {
isFlex: false,
notAvailable: false,
title: nonRefundable,
terms: rateDefinition?.generalTerms,
}
case "special":
return {
isFlex: rateDefinition?.cancellationRule === "CancellableBefore6PM",
notAvailable: false,
title: rateDefinition?.title ?? "",
terms: rateDefinition?.generalTerms,
}
default:
throw new Error(
`Unknown key for rate, should be "change", "flex" or "save", but got ${rateCode}`
`Unknown key for rate, should be "change", "flex", "save" or "special", but got ${rateCode}`
)
}
}
function getRateInfo(product: Product) {
const rateDefinition = rateDefinitions?.filter((rateDefinition) =>
rateDefinition.rateCode === product.productType.public.rateCode
)[0]
if (
!product.productType.public.rateCode &&
!product.productType.member?.rateCode
) {
const possibleRate = getRate(product.productType.public.rate)
const possibleRate = getRate(product.productType.public.rate, rateDefinition)
if (possibleRate) {
return {
...possibleRate,
@@ -181,6 +199,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
isFlex: false,
notAvailable: true,
title: "",
terms: undefined,
}
}
@@ -198,14 +217,31 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
)
}
if (!publicRate && !memberRate) {
throw new Error(
"We should never make it here without any single available rateCode"
)
}
const specialRate = publicRate || memberRate
if (product.productType.public.rateType !== "Regular" && specialRate) {
return getRate(specialRate, rateDefinition)
}
if (!publicRate || !memberRate) {
throw new Error("We should never make it where without rateCodes")
throw new Error(
"We should never make it here without both public and member rateCodes"
)
}
const key = isUserLoggedIn && isMainRoom ? memberRate : publicRate
return getRate(key)
return getRate(key, rateDefinition)
}
const isSpecialRate =
bookingCode &&
roomConfiguration.products.every((item) => {
return item.productType.public.rateType !== RateTypeEnum.Regular
})
return (
<li className={classNames}>
<div className={styles.imageContainer}>
@@ -289,6 +325,12 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
) : (
<>
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
{bookingCode ? (
<span className={!isSpecialRate ? styles.strikedText : ""}>
<PriceTagIcon />
{bookingCode}
</span>
) : null}
{roomConfiguration.products.map((product) => {
const rate = getRateInfo(product)
const isSelectedRateCode =
@@ -311,6 +353,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
roomType={roomConfiguration.roomType}
roomTypeCode={roomConfiguration.roomTypeCode}
title={rate.title}
priceInformation={rate.terms}
/>
)
})}

View File

@@ -97,4 +97,7 @@ div[data-multiroom="true"] .imageContainer {
gap: var(--Spacing-x1);
margin: 0;
padding: var(--Spacing-x2);
}
}
.strikedText {
text-decoration: line-through;
}

View File

@@ -1,9 +1,11 @@
"use client"
import { useIntl } from "react-intl"
import { useSearchParams } from "next/navigation"
import { alternativeHotels } from "@/constants/routes/hotelReservation"
import Alert from "@/components/TempDesignSystem/Alert"
import BookingCodeFilter from "@/components/HotelReservation/SelectHotel/BookingCodeFilter"
import { useRoomContext } from "@/contexts/Room"
import useLang from "@/hooks/useLang"
@@ -14,14 +16,48 @@ import styles from "./roomSelectionPanel.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { AlertTypeEnum } from "@/types/enums/alert"
import { RateTypeEnum } from "@/types/enums/rateType"
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
export default function RoomSelectionPanel() {
const { rooms } = useRoomContext()
const searchParams = useSearchParams()
const bookingCode = searchParams.get("bookingCode")
const intl = useIntl()
const lang = useLang()
const noAvailableRooms = rooms.every(
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
)
const activeCodeFilter = useBookingCodeFilterStore((state) => state.activeCodeFilter)
let filteredRooms = rooms, isRegularRatesAvailableWithCode: boolean = false
if (bookingCode) {
isRegularRatesAvailableWithCode =
!!bookingCode ?
rooms?.some((room) => {
return (
room.status === "Available" &&
room.products.some(
(product) =>
product.productType.public.rateType === RateTypeEnum.Regular
)
)
})
: false
filteredRooms = !isRegularRatesAvailableWithCode || activeCodeFilter === BookingCodeFilterEnum.All
? rooms : rooms.filter((room) => {
return room.products.every(
(product) =>
(activeCodeFilter === BookingCodeFilterEnum.Discounted &&
product.productType.public.rateType !== RateTypeEnum.Regular) ||
(activeCodeFilter === BookingCodeFilterEnum.Regular &&
product.productType.public.rateType === RateTypeEnum.Regular)
)
})
}
return (
<>
{noAvailableRooms ? (
@@ -41,8 +77,9 @@ export default function RoomSelectionPanel() {
</div>
) : null}
<RoomTypeFilter />
{bookingCode && isRegularRatesAvailableWithCode ? <BookingCodeFilter /> : null}
<ul className={styles.roomList}>
{rooms.map((roomConfiguration) => (
{filteredRooms.map((roomConfiguration) => (
<RoomCard
key={roomConfiguration.roomTypeCode}
roomConfiguration={roomConfiguration}

View File

@@ -22,6 +22,7 @@ export function RoomsContainer({
hotelId,
hotelData,
toDate,
bookingCode,
}: RoomsContainerProps) {
const { data: session } = useSession()
const isUserLoggedIn = isValidClientSession(session)
@@ -39,7 +40,8 @@ export function RoomsContainer({
fromDateString,
toDateString,
lang,
childArray
childArray,
bookingCode
)
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(

View File

@@ -33,7 +33,7 @@ export default async function SelectRatePage({
if (!searchDetails?.hotel) {
return notFound()
}
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams } =
const { hotel, adultsInRoom, childrenInRoom, selectHotelParams, bookingCode } =
searchDetails
const { fromDate, toDate } = getValidDates(

View File

@@ -2,6 +2,7 @@ import { trpc } from "@/lib/trpc/client"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
import type { Lang } from "@/constants/languages"
@@ -38,14 +39,17 @@ export function getRates(
) {
return {
change: rateDefinitions.filter(
(rate) => rate.cancellationRule === "Changeable"
(rate) => rate.cancellationRule === "Changeable" && rate.rateType === RateTypeEnum.Regular
),
flex: rateDefinitions.filter(
(rate) => rate.cancellationRule === "CancellableBefore6PM"
(rate) => rate.cancellationRule === "CancellableBefore6PM" && rate.rateType === RateTypeEnum.Regular
),
save: rateDefinitions.filter(
(rate) => rate.cancellationRule === "NotCancellable"
(rate) => rate.cancellationRule === "NotCancellable" && rate.rateType === RateTypeEnum.Regular
),
special: rateDefinitions.filter(
(rate) => rate.rateType !== RateTypeEnum.Regular
)
}
}
@@ -55,7 +59,8 @@ export function useRoomsAvailability(
fromDateString: string,
toDateString: string,
lang: Lang,
childArray?: Child[]
childArray?: Child[],
bookingCode?: string,
) {
const returnValue =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
@@ -65,6 +70,7 @@ export function useRoomsAvailability(
uniqueAdultsCount,
childArray,
lang,
bookingCode,
})
const combinedAvailability = returnValue.data?.length

View File

@@ -79,7 +79,9 @@ function InnerModal({
>
{({ close }) => (
<>
<header className={styles.header}>
<header
className={`${styles.header} ${!subtitle ? styles.verticalCenter : ""}`}
>
<div>
{title && (
<Subtitle type="one" color="uiTextHighContrast">

View File

@@ -67,6 +67,10 @@
justify-content: center;
}
.verticalCenter {
align-items: center;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;

View File

@@ -524,6 +524,7 @@
"Reference": "Reference",
"Reference #{bookingNr}": "Reference #{bookingNr}",
"Reference number": "Reference number",
"Regular price": "Regular price",
"Relax": "Relax",
"Remember code": "Remember code",
"Remove card from member profile": "Remove card from member profile",

View File

@@ -70,6 +70,7 @@ import type { HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
RoomsAvailabilityInputSchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
@@ -801,6 +802,8 @@ export const hotelQueryRouter = router({
})
const bookingCodeAvailabilityResponse =
await getHotelsAvailabilityByCity(input, apiLang, ctx.serviceToken)
// If API or network failed with no response
if (!bookingCodeAvailabilityResponse) {
metrics.hotelsAvailabilityBookingCode.fail.add(1, {
...input,

View File

@@ -6,6 +6,8 @@ export const priceSchema = z.object({
currency: z.nativeEnum(CurrencyEnum),
pricePerNight: z.coerce.number(),
pricePerStay: z.coerce.number(),
regularPricePerNight: z.coerce.number().optional(),
regularPricePerStay: z.coerce.number().optional(),
})
export const productTypePriceSchema = z.object({

View File

@@ -5,6 +5,7 @@ import { productSchema } from "./product"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
export const roomConfigurationSchema = z
.object({
@@ -43,6 +44,11 @@ export const roomConfigurationSchema = z
return product
}
// Return rate even if single when special rates
if (product.productType.public.rateType !== RateTypeEnum.Regular) {
return product
}
/**
* Reset both rateCodes if one is missing to show `No prices available` for the same reason as
* mentioned above.
@@ -67,12 +73,14 @@ export const roomConfigurationSchema = z
/**
* When all products miss at least one rateCode (member or public), we change the status to NotAvailable
* since we cannot as of now (31 january) guarantee the flow with missing rateCodes.
* Exception Special rate (Booking code rates)
*
* TODO: (Maybe) notify somewhere that this happened
*/
const allProductsMissAtLeastOneRateCode = data.products.every(
({ productType }) =>
!productType.public.rateCode || !productType.member?.rateCode
(!productType.public.rateCode || !productType.member?.rateCode) &&
productType.public.rateType === RateTypeEnum.Regular
)
if (allProductsMissAtLeastOneRateCode) {
data.status = AvailabilityEnum.NotAvailable

View File

@@ -3,7 +3,7 @@ import { z } from "zod"
export const rateDefinitionSchema = z.object({
breakfastIncluded: z.boolean(),
cancellationRule: z.string(),
cancellationText: z.string(),
cancellationText: z.string().optional(),
generalTerms: z.array(z.string()),
mustBeGuaranteed: z.boolean(),
rateCode: z.string(),

View File

@@ -1,13 +1,15 @@
import { create } from "zustand"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
interface BookingCodeFilterState {
activeCodeFilter: string
setFilter: (filter: string) => void
activeCodeFilter: keyof typeof BookingCodeFilterEnum
setFilter: (filter: BookingCodeFilterEnum) => void
}
export const useBookingCodeFilterStore = create<BookingCodeFilterState>(
(set) => ({
activeCodeFilter: "discounted",
activeCodeFilter: BookingCodeFilterEnum.Discounted,
setFilter: (filter) => set({ activeCodeFilter: filter }),
})
)

View File

@@ -9,4 +9,5 @@ export interface RoomsContainerProps {
hotelId: number
toDate: Date
hotelData: HotelData | null
bookingCode?: string
}

View File

@@ -0,0 +1,5 @@
export enum BookingCodeFilterEnum {
Discounted = "Discounted",
Regular = "Regular",
All = "All",
}

View File

@@ -0,0 +1,10 @@
export enum RateTypeEnum {
Arb = "Arb",
BonusCheque = "BonusCheque",
Company = "Company",
Promotion = "Promotion",
Redemption = "Redemption",
Regular = "Regular",
TravelAgent = "TravelAgent",
Voucher = "Voucher",
}

View File

@@ -3,6 +3,7 @@ import type { z } from "zod"
import type {
getHotelsByHotelIdsAvailabilityInputSchema,
hotelsAvailabilityInputSchema,
roomsAvailabilityInputSchema,
} from "@/server/routers/hotels/input"
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
@@ -15,6 +16,9 @@ export type HotelsAvailabilityInputSchema = z.output<
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
typeof getHotelsByHotelIdsAvailabilityInputSchema
>
export type RoomsAvailabilityInputSchema = z.output<
typeof roomsAvailabilityInputSchema
>
export type ProductType = z.output<typeof productTypeSchema>
export type ProductTypePrices = z.output<typeof productTypePriceSchema>