feat: SW-1583 Enabled reward night in booking widget

This commit is contained in:
Hrishikesh Vaipurkar
2025-02-27 11:41:04 +01:00
parent 6232ded991
commit 51b70f3032
12 changed files with 247 additions and 69 deletions

View File

@@ -102,8 +102,7 @@ export default function BookingWidgetClient({
value: selectedBookingCode, value: selectedBookingCode,
remember: false, remember: false,
}, },
redemption: false, redemption: bookingWidgetSearchData?.searchType === "redemption",
voucher: false,
rooms: defaultRoomsData, rooms: defaultRoomsData,
}, },
shouldFocusError: false, shouldFocusError: false,

View File

@@ -0,0 +1,103 @@
"use client"
import { useCallback, useEffect, useRef } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import styles from "./reward-night.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
export function RewardNight() {
const intl = useIntl()
const {
setValue,
getValues,
formState: { errors },
trigger,
} = useFormContext<BookingWidgetSchema>()
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 validateBookingWidget(value: boolean) {
trigger("redemption")
if (value && bookingCode) {
setValue("bookingCode.value", "", { shouldValidate: true })
setTimeout(() => {
trigger("redemption")
}, 5000)
}
}
const resetOnMultiroomError = useCallback(() => {
if (isMultiRoomError) {
setValue("redemption", false, { shouldValidate: true })
}
}, [isMultiRoomError, setValue])
function closeOnBlur(evt: FocusEvent) {
const target = evt.relatedTarget as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
resetOnMultiroomError()
}
}
useEffect(() => {
const clearIfOutside = function (evt: Event) {
const target = evt.target as HTMLElement
if (ref.current && target && !ref.current.contains(target)) {
resetOnMultiroomError()
}
}
document.body.addEventListener("click", clearIfOutside)
return () => {
document.body.removeEventListener("click", clearIfOutside)
}
}, [resetOnMultiroomError, ref])
return (
<div ref={ref} onBlur={(e) => closeOnBlur(e.nativeEvent)}>
<Checkbox
hideError
name="redemption"
registerOptions={{
onChange: (e) => {
validateBookingWidget(e.target.value)
},
}}
>
<Caption color="uiTextMediumContrast" asChild>
<span>{reward}</span>
</Caption>
</Checkbox>
{redemptionErr && (
<div className={styles.errorContainer}>
<Caption
className={styles.error}
type="regular"
color={errorInfoColor}
>
<ErrorCircleIcon
color={errorInfoColor}
className={styles.errorIcon}
/>
{intl.formatMessage({ id: redemptionErr.message })}
</Caption>
{isMultiRoomError ? (
// ToDo: Replace with Remove extra rooms JSX element after SW-963 is merged
<Body>{"Remove extra rooms"}</Body>
) : null}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
.errorContainer {
background: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Spacing-x-one-and-half);
display: grid;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
position: absolute;
top: calc(100% + 16px);
width: 320px;
}
.error {
display: flex;
gap: var(--Spacing-x-half);
align-items: center;
}
.errorIcon {
min-width: 20px;
}

View File

@@ -2,12 +2,15 @@
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { env } from "@/env/client"
import SkeletonShimmer from "@/components/SkeletonShimmer" import SkeletonShimmer from "@/components/SkeletonShimmer"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox" import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Caption from "@/components/TempDesignSystem/Text/Caption" import Caption from "@/components/TempDesignSystem/Text/Caption"
import { Tooltip } from "@/components/TempDesignSystem/Tooltip" import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
import BookingCode from "../BookingCode" import BookingCode from "../BookingCode"
import { RewardNight } from "../RewardNight"
import styles from "./voucher.module.css" import styles from "./voucher.module.css"
@@ -31,36 +34,44 @@ export default function Voucher() {
<BookingCode /> <BookingCode />
</div> </div>
<div className={styles.options}> <div className={styles.options}>
<div className={styles.option}> {env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
<Tooltip <>
heading={disabledBookingOptionsHeader} <div className={styles.option}>
text={disabledBookingOptionsText} <Tooltip
position="bottom" heading={disabledBookingOptionsHeader}
arrow="left" text={disabledBookingOptionsText}
> position="bottom"
<Checkbox name="useBonus" registerOptions={{ disabled: true }}> arrow="left"
<Caption color="disabled" asChild> >
<span>{bonus}</span> <Checkbox name="useBonus" registerOptions={{ disabled: true }}>
</Caption> <Caption color="disabled" asChild>
</Checkbox> <span>{bonus}</span>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */} </Caption>
</Tooltip> </Checkbox>
</div> {/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
<div className={styles.option}> </Tooltip>
<Tooltip </div>
heading={disabledBookingOptionsHeader} <div className={styles.option}>
text={disabledBookingOptionsText} <Tooltip
position="bottom" heading={disabledBookingOptionsHeader}
arrow="left" text={disabledBookingOptionsText}
> position="bottom"
<Checkbox name="useReward" registerOptions={{ disabled: true }}> arrow="left"
<Caption color="disabled" asChild> >
<span>{reward}</span> <Checkbox name="useReward" registerOptions={{ disabled: true }}>
</Caption> <Caption color="disabled" asChild>
</Checkbox> <span>{reward}</span>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */} </Caption>
</Tooltip> </Checkbox>
</div> {/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</Tooltip>
</div>
</>
) : (
<div className={styles.option}>
<RewardNight />
</div>
)}
</div> </div>
</div> </div>
) )
@@ -87,16 +98,18 @@ export function VoucherSkeleton() {
<SkeletonShimmer width={"100%"} /> <SkeletonShimmer width={"100%"} />
</div> </div>
<div className={styles.options}> <div className={styles.options}>
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? null : (
<div className={styles.option}>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
</div>
)}
<div className={styles.option}> <div className={styles.option}>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}> <Checkbox name="redemption">
<Caption color="disabled" asChild> <Caption color="uiTextMediumContrast" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
</div>
<div className={styles.option}>
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{reward}</span> <span>{reward}</span>
</Caption> </Caption>
</Checkbox> </Checkbox>

View File

@@ -49,6 +49,8 @@ export default function Form({
...(data.bookingCode?.value ...(data.bookingCode?.value
? { bookingCode: data.bookingCode.value } ? { bookingCode: data.bookingCode.value }
: {}), : {}),
// Followed current url structure to keep searchType=redemption param incase of reward night
...(data.redemption ? { searchType: "redemption" } : {}),
}) })
onClose() onClose()

View File

@@ -91,7 +91,6 @@ export const bookingWidgetSchema = z
redemption: z.boolean().default(false), redemption: z.boolean().default(false),
rooms: guestRoomsSchema, rooms: guestRoomsSchema,
search: z.string({ coerce: true }).min(1, "Required"), search: z.string({ coerce: true }).min(1, "Required"),
voucher: z.boolean().default(false),
hotel: z.number().optional(), hotel: z.number().optional(),
city: z.string().optional(), city: z.string().optional(),
}) })
@@ -130,4 +129,28 @@ export const bookingWidgetSchema = z
path: ["rooms"], 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: ["redemption"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Multi-room booking is not available with reward night.",
path: ["rooms"],
})
}
if (value.bookingCode?.value && value.redemption) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Code and voucher is not available with reward night.",
path: ["redemption"],
})
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Code and voucher is not available with reward night.",
path: ["bookingCode.value"],
})
}
}) })

View File

@@ -37,9 +37,8 @@ export default function GuestsRoomsPickerDialog({
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" }) const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const addRoomLabel = intl.formatMessage({ id: "Add room" }) const addRoomLabel = intl.formatMessage({ id: "Add room" })
const doneLabel = intl.formatMessage({ id: "Done" }) const doneLabel = intl.formatMessage({ id: "Done" })
// Disable add room if booking code is either voucher or corporate cheque, or reward night is enabled
// Disable add room if booking code is voucher or reward night is enebaled const addRoomDisabledTextForSpecialRate = getValues("redemption")
const addRoomDisabledText = getValues("redemption")
? intl.formatMessage({ ? intl.formatMessage({
id: "Multi-room booking is not available with reward night.", id: "Multi-room booking is not available with reward night.",
}) })
@@ -102,39 +101,52 @@ export default function GuestsRoomsPickerDialog({
/> />
))} ))}
{canAddRooms && ( {addRoomDisabledTextForSpecialRate ? (
<div className={styles.addRoomMobileContainer}> <div className={styles.addRoomMobileContainer}>
<Button <Tooltip
intent="text" text={addRoomDisabledTextForSpecialRate}
variant="icon" position="bottom"
wrapping arrow="left"
theme="base"
fullWidth
onPress={handleAddRoom}
disabled={addRoomDisabledText ? true : false}
> >
<PlusIcon /> <Button
{addRoomLabel} intent="text"
</Button> variant="icon"
{addRoomDisabledText ? ( wrapping
<Caption theme="base"
color="blue" fullWidth
className={styles.addRoomMobileDisabledText} onPress={handleAddRoom}
disabled
> >
<ErrorCircleIcon color="blue" width={20} /> <PlusIcon />
{addRoomDisabledText} {addRoomLabel}
</Caption> </Button>
) : null} </Tooltip>
</div> </div>
) : (
canAddRooms && (
<div className={styles.addRoomMobileContainer}>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
>
<PlusIcon />
{addRoomLabel}
</Button>
</div>
)
)} )}
</div> </div>
</section> </section>
<footer className={styles.footer}> <footer className={styles.footer}>
{addRoomDisabledText ? ( {addRoomDisabledTextForSpecialRate ? (
<div className={styles.hideOnMobile}> <div className={styles.hideOnMobile}>
<Tooltip <Tooltip
text={addRoomDisabledText} text={addRoomDisabledTextForSpecialRate}
position={"bottom"} position="bottom"
arrow="left" arrow="left"
> >
<Button <Button
@@ -143,6 +155,7 @@ export default function GuestsRoomsPickerDialog({
wrapping wrapping
theme="base" theme="base"
disabled disabled
onPress={handleAddRoom}
> >
<PlusCircleIcon /> <PlusCircleIcon />
{addRoomLabel} {addRoomLabel}

View File

@@ -16,6 +16,7 @@ export default function Checkbox({
name, name,
children, children,
registerOptions, registerOptions,
hideError,
}: React.PropsWithChildren<CheckboxProps>) { }: React.PropsWithChildren<CheckboxProps>) {
const { control } = useFormContext() const { control } = useFormContext()
const { field, fieldState } = useController({ const { field, fieldState } = useController({
@@ -44,7 +45,7 @@ export default function Checkbox({
</span> </span>
{children} {children}
</span> </span>
{fieldState.error ? ( {fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly> <Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" /> <InfoCircleIcon color="red" />
{fieldState.error.message} {fieldState.error.message}

View File

@@ -150,6 +150,7 @@
"Close the map": "Close the map", "Close the map": "Close the map",
"Closed": "Closed", "Closed": "Closed",
"Code / Voucher": "Code / Voucher", "Code / Voucher": "Code / Voucher",
"Code and voucher is not available with reward night.": "Code and voucher is not available with reward night.",
"Codes, cheques and reward nights aren't available on the new website yet.": "Codes, cheques and reward nights aren't available on the new website yet.", "Codes, cheques and reward nights aren't available on the new website yet.": "Codes, cheques and reward nights aren't available on the new website yet.",
"Coming up": "Coming up", "Coming up": "Coming up",
"Compare all levels": "Compare all levels", "Compare all levels": "Compare all levels",

View File

@@ -19,6 +19,7 @@ export type BookingWidgetSearchData = {
toDate?: string toDate?: string
rooms?: TGuestsRoom[] rooms?: TGuestsRoom[]
bookingCode?: string bookingCode?: string
searchType?: "redemption"
} }
export type BookingWidgetType = VariantProps< export type BookingWidgetType = VariantProps<

View File

@@ -4,4 +4,5 @@ export interface CheckboxProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement> {
name: string name: string
registerOptions?: RegisterOptions registerOptions?: RegisterOptions
hideError?: boolean
} }

View File

@@ -27,6 +27,7 @@ const keyedSearchParams = new Map([
["todate", "toDate"], ["todate", "toDate"],
["hotel", "hotelId"], ["hotel", "hotelId"],
["child", "childrenInRoom"], ["child", "childrenInRoom"],
["searchtype", "searchType"],
]) ])
export type SelectHotelParams<T> = Omit<T, "hotel"> & { export type SelectHotelParams<T> = Omit<T, "hotel"> & {