feat: SW-1422 Updated UX for booking code desktop and mobile

This commit is contained in:
Hrishikesh Vaipurkar
2025-01-30 23:14:07 +01:00
parent 6741a0a21c
commit 63f456da5a
14 changed files with 313 additions and 115 deletions

View File

@@ -85,7 +85,7 @@ export default function BookingWidgetClient({
const selectedBookingCode = bookingWidgetSearchData
? bookingWidgetSearchData.bookingCode
: undefined
: ""
const defaultRoomsData: BookingWidgetSchema["rooms"] =
bookingWidgetSearchData?.rooms?.map((room) => ({
@@ -113,7 +113,7 @@ export default function BookingWidgetClient({
: now.utc().add(1, "day").format("YYYY-MM-DD"),
},
bookingCode: {
value: selectedBookingCode ?? "",
value: selectedBookingCode,
remember: false,
},
redemption: false,
@@ -156,36 +156,36 @@ export default function BookingWidgetClient({
}, [])
useEffect(() => {
const sessionStorageSearchData =
typeof window !== "undefined"
? sessionStorage.getItem("searchData")
: undefined
if (typeof window !== "undefined" && !selectedLocation) {
const sessionStorageSearchData = sessionStorage.getItem("searchData")
const initialSelectedLocation: Location | undefined =
sessionStorageSearchData && isValidJson(sessionStorageSearchData)
? JSON.parse(sessionStorageSearchData)
: undefined
const initialSelectedLocation: Location | undefined =
sessionStorageSearchData && isValidJson(sessionStorageSearchData)
? JSON.parse(sessionStorageSearchData)
: undefined
!selectedLocation?.name &&
initialSelectedLocation?.name &&
methods.setValue("search", initialSelectedLocation.name)
!selectedLocation &&
methods.setValue("search", initialSelectedLocation.name)
sessionStorageSearchData &&
methods.setValue("location", encodeURIComponent(sessionStorageSearchData))
methods.setValue(
"location",
encodeURIComponent(sessionStorageSearchData)
)
}
}, [methods, selectedLocation])
const storedBookingCode =
typeof window !== "undefined"
? localStorage.getItem("bookingCode")
: undefined
const initialBookingCode: BookingCodeSchema | undefined =
storedBookingCode && isValidJson(storedBookingCode)
? JSON.parse(storedBookingCode)
: undefined
!selectedBookingCode &&
initialBookingCode &&
initialBookingCode.remember &&
methods.setValue("bookingCode", initialBookingCode)
}, [methods, selectedLocation, selectedBookingCode])
useEffect(() => {
if (typeof window !== "undefined" && !selectedBookingCode) {
const storedBookingCode = localStorage.getItem("bookingCode")
const initialBookingCode: BookingCodeSchema | undefined =
storedBookingCode && isValidJson(storedBookingCode)
? JSON.parse(storedBookingCode)
: undefined
initialBookingCode?.remember &&
methods.setValue("bookingCode", initialBookingCode)
}
}, [methods, selectedBookingCode])
return (
<FormProvider {...methods}>

View File

@@ -1,7 +1,12 @@
.container {
position: relative;
}
.bookingCodeLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
position: relative;
}
.error {
@@ -13,10 +18,33 @@
min-width: 20px;
}
.bookingCodeRemember {
display: flex;
gap: var(--Spacing-x1);
/* ToDo: Remove once remember checkbox design are ready */
.bookingCodeRemember,
.bookingCodeRememberVisible {
display: none;
gap: var(--Spacing-x1);
}
.bookingCodeRememberVisible {
display: flex;
position: absolute;
top: calc(100% + 16px);
width: 100%;
}
@media screen and (max-width: 768px) {
.hideOnMobile {
display: none;
}
}
@media screen and (min-width: 1367px) {
.bookingCodeRememberVisible {
padding: var(--Spacing-x2);
border-radius: var(--Spacing-x-one-and-half);
width: 320px;
background: white;
top: calc(100% + 24px);
justify-content: space-between;
align-items: center;
}
}

View File

@@ -1,10 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { ErrorCircleIcon, InfoCircleIcon } from "@/components/Icons"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Switch from "@/components/TempDesignSystem/Form/Switch"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
@@ -19,72 +23,228 @@ import type {
export default function BookingCode() {
const intl = useIntl()
const checkIsTablet = useMediaQuery(
"(min-width: 768px) and (max-width: 1367px)"
)
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isTablet, setIsTablet] = useState(false)
const [isDesktop, setIsDesktop] = useState(false)
const {
setValue,
formState: { errors },
getValues,
} = useFormContext<BookingWidgetSchema>()
const codeError = errors["bookingCode"]?.value
const bookingCode: BookingCodeSchema = getValues("bookingCode")
const [isOpen, setIsOpen] = useState(!!bookingCode?.value)
const [showRemember, setShowRemember] = useState(false)
const [showRememberMobile, setShowRememberMobile] = useState(false)
const codeError = errors["bookingCode"]?.value
const codeVoucher = intl.formatMessage({ id: "Code / Voucher" })
const addCode = intl.formatMessage({ id: "Add code" })
const bookingCodeTooltipText = intl.formatMessage({
id: "booking.codes.information",
id: "If you're booking a promotional offer or a Corporate negotiated rate you'll need a special booking code. Don't use any special characters such as (.) (,) (-) (:). If you would like to make a booking with code VOF, please call us +46 8 517 517 20.Save your booking code for the next time you visit the page by ticking the box “Remember”. Don't tick the box if you're using a public computer to avoid unauthorized access to your booking code.",
})
const bookingCode: BookingCodeSchema = getValues("bookingCode")
const ref = useRef<HTMLDivElement | null>(null)
function updateBookingCodeFormValue(value: string) {
setValue("bookingCode.value", value, { shouldValidate: true })
}
return (
function toggleModal(isOpen: boolean) {
if (!isOpen && codeError) {
// console.log("Cannot be closed!!")
// setValue("bookingCode.flag", false)
} else if (!isOpen && !bookingCode?.value) {
setValue("bookingCode.flag", false)
setIsOpen(isOpen)
} else {
setIsOpen(isOpen)
if (isOpen || bookingCode?.value) {
setValue("bookingCode.flag", true)
}
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
setShowRemember(false)
}
},
[setShowRemember, ref]
)
function showRememberCheck() {
setShowRemember(true)
}
useEffect(() => {
setIsTablet(checkIsTablet)
}, [checkIsTablet])
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const isRememberMobileVisible =
!isDesktop && (showRemember || !!bookingCode?.remember)
useEffect(() => {
setShowRememberMobile(isRememberMobileVisible)
}, [isRememberMobileVisible])
useEffect(() => {
function handleClickOutside(evt: Event) {
if (showRemember) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, showRemember])
return isTablet ? (
<>
<label htmlFor="booking-code">
<div className={styles.bookingCodeLabel}>
<Caption color="uiTextMediumContrast" type="bold" asChild>
<span>{codeVoucher}</span>
</Caption>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
color="uiTextPlaceholder"
height={20}
width={20}
<DialogTrigger isOpen={isOpen} onOpenChange={toggleModal}>
<Button type="button" intent="text">
<Checkbox name="bookingCode.flag" checked={!!bookingCode?.value}>
<Caption color="uiTextMediumContrast" type="bold" asChild>
<span>{codeVoucher}</span>
</Caption>
</Checkbox>
</Button>
<Popover
className="guests_picker_popover"
placement="bottom start"
offset={36}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => (
<div className={styles.popover}>
<Input
type="text"
placeholder={addCode}
name="bookingCode"
id="booking-code"
onChange={(event) =>
updateBookingCodeFormValue(event.target.value)
}
defaultValue={bookingCode?.value}
/>
</Button>
}
title={codeVoucher}
>
<Body color="uiTextHighContrast">{bookingCodeTooltipText}</Body>
</Modal>
</div>
<Input
type="text"
placeholder={addCode}
name="bookingCode"
id="booking-code"
onChange={(event) => updateBookingCodeFormValue(event.target.value)}
defaultValue={bookingCode?.value}
/>
</label>
{codeError && codeError.message ? (
{codeError?.message ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" className={styles.errorIcon} />
{intl.formatMessage({ id: codeError.message })}
</Caption>
) : null}
<div className={styles.bookingCode}>
<Checkbox name="bookingCode.remember">
<Caption asChild>
<span>{intl.formatMessage({ id: "Remember code" })}</span>
</Caption>
</Checkbox>
<Button
size="small"
intent="primary"
type="button"
onClick={close}
disabled={codeError ? true : undefined}
>
{intl.formatMessage({ id: "Apply" })}
</Button>
</div>
</div>
)}
</Dialog>
</Popover>
</DialogTrigger>
</>
) : (
<div
className={styles.container}
ref={ref}
onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)}
>
<div className={styles.bookingCodeLabel}>
<Caption color="uiTextMediumContrast" type="bold" asChild>
<span>{codeVoucher}</span>
</Caption>
<Modal
trigger={
<Button intent="text">
<InfoCircleIcon
color="uiTextPlaceholder"
height={20}
width={20}
/>
</Button>
}
title={codeVoucher}
>
<Body color="uiTextHighContrast">{bookingCodeTooltipText}</Body>
</Modal>
</div>
<Input
type="text"
placeholder={addCode}
name="bookingCode"
id="booking-code"
onChange={(event) => updateBookingCodeFormValue(event.target.value)}
defaultValue={bookingCode?.value}
onFocus={showRememberCheck}
onBlur={(e) =>
closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)
}
/>
{codeError?.message ? (
<Caption color="red" className={styles.error}>
<ErrorCircleIcon color="red" className={styles.errorIcon} />
{intl.formatMessage({ id: codeError.message })}
</Caption>
) : null}
{/* ToDo: Update styles once designs are ready */}
<div className={styles.bookingCodeRemember}>
<Checkbox name="bookingCode.remember">
<Caption asChild>
<span>{intl.formatMessage({ id: "Remember code" })}</span>
</Caption>
</Checkbox>
<Button size="small" intent="primary" type="button">
{intl.formatMessage({ id: "Apply" })}
</Button>
</div>
</>
{isDesktop ? (
<div
className={
showRemember
? styles.bookingCodeRememberVisible
: styles.bookingCodeRemember
}
>
<Checkbox name="bookingCode.remember">
<Caption asChild>
<span>{intl.formatMessage({ id: "Remember code" })}</span>
</Caption>
</Checkbox>
{bookingCode?.value ? (
<Button
size="small"
className={styles.hideOnMobile}
intent="secondary"
type="button"
onClick={() => setShowRemember(false)}
>
{intl.formatMessage({ id: "Apply" })}
</Button>
) : null}
</div>
) : null}
{!isDesktop ? (
<div
className={
showRememberMobile
? styles.bookingCodeRememberVisible
: styles.bookingCodeRemember
}
>
<Switch name="bookingCode.remember" className="mobile-switch">
<Caption asChild>
<span>{intl.formatMessage({ id: "Remember code" })}</span>
</Caption>
</Switch>
</div>
) : null}
</div>
)
}

View File

@@ -32,6 +32,12 @@
display: none;
}
@media screen and (max-width: 767px) {
.vouchers {
margin-bottom: var(--Spacing-x5);
}
}
@media screen and (min-width: 768px) {
.vouchers {
display: none;

View File

@@ -31,7 +31,8 @@ export default function Form({
type,
})
const { handleSubmit, register } = useFormContext<BookingWidgetSchema>()
const { handleSubmit, register, setValue } =
useFormContext<BookingWidgetSchema>()
function onSubmit(data: BookingWidgetSchema) {
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
@@ -44,7 +45,7 @@ export default function Form({
...(locationData.type == "cities"
? { city: locationData.name }
: { hotel: locationData.operaId || "" }),
...(data.bookingCode && data.bookingCode.value
...(data.bookingCode?.value
? { bookingCode: data.bookingCode.value }
: {}),
})
@@ -54,6 +55,9 @@ export default function Form({
if (data.bookingCode?.remember) {
localStorage.setItem("bookingCode", JSON.stringify(data.bookingCode))
}
if (!data.bookingCode?.value) {
setValue("bookingCode.remember", false)
}
}
return (

View File

@@ -37,22 +37,26 @@ export const guestRoomsSchema = z.array(guestRoomSchema)
export const bookingCodeSchema = z
.object({
value: z.string().refine(
(value) => {
if (
!value ||
/(^D\d*$)|(^DSH[0-9a-z]*$)|(^L\d*$)|(^LH[0-9a-z]*$)|(^B[a-z]{3}\d{6})|(^VO[0-9a-z]*$)|^[0-9a-z]*$/i.test(
value
)
) {
return true
} else {
return false
}
},
{ message: "Invalid booking code" }
),
remember: z.boolean(),
value: z
.string()
.refine(
(value) => {
if (
!value ||
/(^D\d*$)|(^DSH[0-9a-z]*$)|(^L\d*$)|(^LH[0-9a-z]*$)|(^B[a-z]{3}\d{6})|(^VO[0-9a-z]*$)|^[0-9a-z]*$/i.test(
value
)
) {
return true
} else {
return false
}
},
{ message: "Invalid booking code" }
)
.default(""),
remember: z.boolean().default(false),
flag: z.boolean().default(false),
})
.optional()

View File

@@ -32,20 +32,14 @@ export default function Switch({
isDisabled={registerOptions?.disabled}
excludeFromTabOrder
>
{({ isSelected }) => (
<>
<span className={styles.switchContainer}>
{children}
<span className={styles.switch} tabIndex={0}></span>
</span>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</>
)}
{children}
<span className={styles.switch} tabIndex={0}></span>
{fieldState.error ? (
<Caption className={styles.error} fontOnly>
<InfoCircleIcon color="red" />
{fieldState.error.message}
</Caption>
) : null}
</AriaSwitch>
)
}

View File

@@ -3,6 +3,8 @@
flex-direction: row;
color: var(--text-color);
cursor: pointer;
width: 100%;
justify-content: space-between;
}
.switch {