feat: SW-1422 Updated UX for booking code desktop and mobile
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
flex-direction: row;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.switch {
|
||||
|
||||
Reference in New Issue
Block a user