feat: SW-1583 Enabled reward night in booking widget
This commit is contained in:
@@ -102,8 +102,7 @@ export default function BookingWidgetClient({
|
||||
value: selectedBookingCode,
|
||||
remember: false,
|
||||
},
|
||||
redemption: false,
|
||||
voucher: false,
|
||||
redemption: bookingWidgetSearchData?.searchType === "redemption",
|
||||
rooms: defaultRoomsData,
|
||||
},
|
||||
shouldFocusError: false,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,12 +2,15 @@
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { env } from "@/env/client"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
|
||||
|
||||
import BookingCode from "../BookingCode"
|
||||
import { RewardNight } from "../RewardNight"
|
||||
|
||||
import styles from "./voucher.module.css"
|
||||
|
||||
@@ -31,36 +34,44 @@ export default function Voucher() {
|
||||
<BookingCode />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
|
||||
<>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Tooltip
|
||||
heading={disabledBookingOptionsHeader}
|
||||
text={disabledBookingOptionsText}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.option}>
|
||||
<RewardNight />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -87,16 +98,18 @@ export function VoucherSkeleton() {
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
<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}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<Checkbox name="redemption">
|
||||
<Caption color="uiTextMediumContrast" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
|
||||
@@ -49,6 +49,8 @@ export default function Form({
|
||||
...(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()
|
||||
|
||||
@@ -91,7 +91,6 @@ export const bookingWidgetSchema = z
|
||||
redemption: z.boolean().default(false),
|
||||
rooms: guestRoomsSchema,
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
voucher: z.boolean().default(false),
|
||||
hotel: z.number().optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
@@ -130,4 +129,28 @@ 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: ["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"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,9 +37,8 @@ export default function GuestsRoomsPickerDialog({
|
||||
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
|
||||
const addRoomLabel = intl.formatMessage({ id: "Add room" })
|
||||
const doneLabel = intl.formatMessage({ id: "Done" })
|
||||
|
||||
// Disable add room if booking code is voucher or reward night is enebaled
|
||||
const addRoomDisabledText = getValues("redemption")
|
||||
// Disable add room if booking code is either voucher or corporate cheque, or reward night is enabled
|
||||
const addRoomDisabledTextForSpecialRate = getValues("redemption")
|
||||
? intl.formatMessage({
|
||||
id: "Multi-room booking is not available with reward night.",
|
||||
})
|
||||
@@ -102,39 +101,52 @@ export default function GuestsRoomsPickerDialog({
|
||||
/>
|
||||
))}
|
||||
|
||||
{canAddRooms && (
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
fullWidth
|
||||
onPress={handleAddRoom}
|
||||
disabled={addRoomDisabledText ? true : false}
|
||||
<Tooltip
|
||||
text={addRoomDisabledTextForSpecialRate}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
{addRoomDisabledText ? (
|
||||
<Caption
|
||||
color="blue"
|
||||
className={styles.addRoomMobileDisabledText}
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
fullWidth
|
||||
onPress={handleAddRoom}
|
||||
disabled
|
||||
>
|
||||
<ErrorCircleIcon color="blue" width={20} />
|
||||
{addRoomDisabledText}
|
||||
</Caption>
|
||||
) : null}
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
canAddRooms && (
|
||||
<div className={styles.addRoomMobileContainer}>
|
||||
<Button
|
||||
intent="text"
|
||||
variant="icon"
|
||||
wrapping
|
||||
theme="base"
|
||||
fullWidth
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<PlusIcon />
|
||||
{addRoomLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<footer className={styles.footer}>
|
||||
{addRoomDisabledText ? (
|
||||
{addRoomDisabledTextForSpecialRate ? (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<Tooltip
|
||||
text={addRoomDisabledText}
|
||||
position={"bottom"}
|
||||
text={addRoomDisabledTextForSpecialRate}
|
||||
position="bottom"
|
||||
arrow="left"
|
||||
>
|
||||
<Button
|
||||
@@ -143,6 +155,7 @@ export default function GuestsRoomsPickerDialog({
|
||||
wrapping
|
||||
theme="base"
|
||||
disabled
|
||||
onPress={handleAddRoom}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{addRoomLabel}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function Checkbox({
|
||||
name,
|
||||
children,
|
||||
registerOptions,
|
||||
hideError,
|
||||
}: React.PropsWithChildren<CheckboxProps>) {
|
||||
const { control } = useFormContext()
|
||||
const { field, fieldState } = useController({
|
||||
@@ -44,7 +45,7 @@ export default function Checkbox({
|
||||
</span>
|
||||
{children}
|
||||
</span>
|
||||
{fieldState.error ? (
|
||||
{fieldState.error && !hideError ? (
|
||||
<Caption className={styles.error} fontOnly>
|
||||
<InfoCircleIcon color="red" />
|
||||
{fieldState.error.message}
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
"Close the map": "Close the map",
|
||||
"Closed": "Closed",
|
||||
"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.",
|
||||
"Coming up": "Coming up",
|
||||
"Compare all levels": "Compare all levels",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BookingWidgetSearchData = {
|
||||
toDate?: string
|
||||
rooms?: TGuestsRoom[]
|
||||
bookingCode?: string
|
||||
searchType?: "redemption"
|
||||
}
|
||||
|
||||
export type BookingWidgetType = VariantProps<
|
||||
|
||||
@@ -4,4 +4,5 @@ export interface CheckboxProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string
|
||||
registerOptions?: RegisterOptions
|
||||
hideError?: boolean
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const keyedSearchParams = new Map([
|
||||
["todate", "toDate"],
|
||||
["hotel", "hotelId"],
|
||||
["child", "childrenInRoom"],
|
||||
["searchtype", "searchType"],
|
||||
])
|
||||
|
||||
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
|
||||
|
||||
Reference in New Issue
Block a user