Merged in feat/sw-2879-booking-widget-to-booking-flow-package (pull request #2532)
feat(SW-2879): Move BookingWidget to booking-flow package * Fix lockfile * Fix styling * a tiny little booking widget test * Tiny fixes * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Remove unused scripts * lint:fix * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Tiny lint fixes * update test * Update Input in booking-flow * Clean up comments etc * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Setup tracking context for booking-flow * Add missing use client * Fix temp tracking function * Pass booking to booking-widget * Remove comment * Add use client to booking widget tracking provider * Add use client to tracking functions * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Move debug page * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package * Merge branch 'master' into feat/sw-2879-booking-widget-to-booking-flow-package Approved-by: Bianca Widstam
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
.container {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.bookingCode {
|
||||
height: 60px;
|
||||
background-color: var(--Background-Primary);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.bookingCodeLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x-half);
|
||||
position: relative;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
white-space: break-spaces;
|
||||
color: var(--UI-Text-Error);
|
||||
}
|
||||
|
||||
.bookingCodeRemember,
|
||||
.bookingCodeRememberVisible {
|
||||
display: none;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.bookingCodeRememberVisible {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookingCodeTooltip {
|
||||
max-width: 560px;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bookingCodeRememberVisible label {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
.removeButton {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bookingCode {
|
||||
height: auto;
|
||||
background-color: transparent;
|
||||
}
|
||||
.bookingCodeRememberVisible {
|
||||
align-items: center;
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
justify-content: space-between;
|
||||
border-radius: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
.error {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
.codePopover {
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Spacing-x-one-and-half);
|
||||
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
|
||||
padding: var(--Spacing-x2);
|
||||
width: 320px;
|
||||
}
|
||||
.popover {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.overlayTrigger {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
left: 0;
|
||||
right: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container:hover {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
.container:focus-within,
|
||||
.container:has([data-focused="true"]),
|
||||
.container:has([data-pressed="true"]) {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
border: 1px solid var(--Border-Interactive-Focus);
|
||||
}
|
||||
.bookingCodeRememberVisible {
|
||||
padding: var(--Spacing-x2);
|
||||
position: absolute;
|
||||
top: calc(100% + var(--Spacing-x3));
|
||||
left: calc(0% - var(--Spacing-x-half));
|
||||
width: 360px;
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||
import { type FieldError, useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import {
|
||||
type ButtonProps,
|
||||
OldDSButton as Button,
|
||||
} from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Switch from "@scandic-hotels/design-system/Switch"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
|
||||
import BookingFlowInput from "../../../../BookingFlowInput"
|
||||
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
|
||||
import Modal from "../../../../TEMP/Modal"
|
||||
import { Input as BookingWidgetInput } from "../Input"
|
||||
import { isMultiRoomError } from "../utils"
|
||||
|
||||
import styles from "./booking-code.module.css"
|
||||
|
||||
import type { BookingCodeSchema, BookingWidgetSchema } from "../../../Client"
|
||||
|
||||
export default function BookingCode() {
|
||||
const intl = useIntl()
|
||||
const checkIsTablet = useMediaQuery(
|
||||
"(min-width: 768px) and (max-width: 1366px)"
|
||||
)
|
||||
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
const [isDesktop, setIsDesktop] = useState(false)
|
||||
const {
|
||||
setValue,
|
||||
formState: { errors },
|
||||
getValues,
|
||||
trigger,
|
||||
} = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const bookingCode: BookingCodeSchema = getValues("bookingCode")
|
||||
const [showRemember, setShowRemember] = useState(false)
|
||||
const [showRememberMobile, setShowRememberMobile] = useState(false)
|
||||
const codeError = errors["bookingCode"]?.value
|
||||
const codeVoucher = intl.formatMessage({
|
||||
defaultMessage: "Code / Voucher",
|
||||
})
|
||||
const addCode = intl.formatMessage({
|
||||
defaultMessage: "Add code",
|
||||
})
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const removeExtraRoomsText = intl.formatMessage({
|
||||
defaultMessage: "Remove extra rooms",
|
||||
})
|
||||
|
||||
function updateBookingCodeFormValue(value: string) {
|
||||
// Set value and show error if validation fails
|
||||
setValue("bookingCode.value", value.toUpperCase(), {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
if (getValues(SEARCH_TYPE_REDEMPTION)) {
|
||||
// Remove the redemption as user types booking code and show notification for the same
|
||||
// Add delay to handle table mode rendering
|
||||
setTimeout(function () {
|
||||
setValue(SEARCH_TYPE_REDEMPTION, false, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
})
|
||||
// Hide the above notification popup after 5 seconds by re-triggering validation
|
||||
// This is kept consistent with location search field error notification timeout
|
||||
setTimeout(function () {
|
||||
trigger("bookingCode.value")
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const closeIfOutside = useCallback(
|
||||
(target: HTMLElement) => {
|
||||
if (
|
||||
ref.current &&
|
||||
target &&
|
||||
!ref.current.contains(target) &&
|
||||
// This is for mobile layout having "Remove extra rooms" button outside the container
|
||||
target.innerText !== removeExtraRoomsText
|
||||
) {
|
||||
setShowRemember(false)
|
||||
if (codeError) {
|
||||
setValue("bookingCode.value", "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[setShowRemember, setValue, removeExtraRoomsText, ref, codeError]
|
||||
)
|
||||
|
||||
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 ? (
|
||||
<TabletBookingCode
|
||||
bookingCode={bookingCode}
|
||||
updateValue={updateBookingCodeFormValue}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={ref}
|
||||
onFocus={showRememberCheck}
|
||||
onBlur={(e) => closeIfOutside(e.nativeEvent.relatedTarget as HTMLElement)}
|
||||
>
|
||||
<div className={styles.bookingCode}>
|
||||
<div className={styles.bookingCodeLabel}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{codeVoucher}</span>
|
||||
</Typography>
|
||||
<CodeRulesModal />
|
||||
</div>
|
||||
<BookingWidgetInput
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder={addCode}
|
||||
name="bookingCode.value"
|
||||
id="booking-code"
|
||||
onChange={(event) => updateBookingCodeFormValue(event.target.value)}
|
||||
autoComplete="off"
|
||||
value={bookingCode?.value}
|
||||
/>
|
||||
</div>
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className={
|
||||
showRemember
|
||||
? styles.bookingCodeRememberVisible
|
||||
: styles.bookingCodeRemember
|
||||
}
|
||||
>
|
||||
{codeError?.message ? (
|
||||
<BookingCodeError codeError={codeError} isDesktop />
|
||||
) : (
|
||||
<CodeRemember
|
||||
bookingCodeValue={bookingCode?.value}
|
||||
onApplyClick={() => setShowRemember(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{codeError?.message ? (
|
||||
<BookingCodeError codeError={codeError} />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
showRememberMobile
|
||||
? styles.bookingCodeRememberVisible
|
||||
: styles.bookingCodeRemember
|
||||
}
|
||||
>
|
||||
<Switch name="bookingCode.remember" className="mobile-switch">
|
||||
<Caption asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Remember code",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type CodeRememberProps = {
|
||||
bookingCodeValue: string | undefined
|
||||
onApplyClick: () => void
|
||||
}
|
||||
|
||||
function CodeRulesModal() {
|
||||
const intl = useIntl()
|
||||
const codeVoucher = intl.formatMessage({
|
||||
defaultMessage: "Code / Voucher",
|
||||
})
|
||||
const bookingCodeTooltipText = intl.formatMessage({
|
||||
defaultMessage:
|
||||
"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.",
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon
|
||||
icon="info"
|
||||
color="Icon/Interactive/Placeholder"
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={codeVoucher}
|
||||
>
|
||||
<Body color="uiTextHighContrast" className={styles.bookingCodeTooltip}>
|
||||
{bookingCodeTooltipText}
|
||||
</Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeRemember({ bookingCodeValue, onApplyClick }: CodeRememberProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox name="bookingCode.remember">
|
||||
<Caption asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Remember code",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
{bookingCodeValue ? (
|
||||
<Button
|
||||
size="small"
|
||||
className={styles.hideOnMobile}
|
||||
intent="tertiary"
|
||||
theme="base"
|
||||
type="button"
|
||||
onClick={onApplyClick}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Apply",
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BookingCodeError({
|
||||
codeError,
|
||||
isDesktop = false,
|
||||
}: {
|
||||
codeError: FieldError
|
||||
isDesktop?: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const isMultiroomError = isMultiRoomError(codeError.message)
|
||||
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
size={20}
|
||||
icon="error"
|
||||
color="Icon/Feedback/Error"
|
||||
isFilled={!isDesktop}
|
||||
/>
|
||||
{getErrorMessage(intl, codeError.message)}
|
||||
</span>
|
||||
</Typography>
|
||||
{isMultiroomError ? (
|
||||
<div className={styles.removeButton}>
|
||||
<RemoveExtraRooms fullWidth />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RemoveExtraRooms({ ...props }: ButtonProps) {
|
||||
const intl = useIntl()
|
||||
const { getValues, setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
function removeExtraRooms() {
|
||||
// Timeout to delay the event scheduling issue with touch events on mobile
|
||||
window.setTimeout(() => {
|
||||
const rooms = getValues("rooms")[0]
|
||||
setValue("rooms", [rooms], { shouldValidate: true, shouldDirty: true })
|
||||
trigger("bookingCode.value")
|
||||
trigger(SEARCH_TYPE_REDEMPTION)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={removeExtraRooms}
|
||||
size="small"
|
||||
intent="secondary"
|
||||
{...props}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Remove extra rooms",
|
||||
})}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function TabletBookingCode({
|
||||
bookingCode,
|
||||
updateValue,
|
||||
}: {
|
||||
bookingCode: BookingCodeSchema
|
||||
updateValue: (value: string) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(!!bookingCode?.value)
|
||||
const {
|
||||
setValue,
|
||||
register,
|
||||
formState: { errors },
|
||||
} = useFormContext<BookingWidgetSchema>()
|
||||
const codeError = errors["bookingCode"]?.value
|
||||
const codeVoucher = intl.formatMessage({
|
||||
defaultMessage: "Code / Voucher",
|
||||
})
|
||||
|
||||
function toggleModal(isOpen: boolean) {
|
||||
if (document.body) {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "visible"
|
||||
} else {
|
||||
// !important needed to override 'overflow: hidden' set by react-aria.
|
||||
// 'overflow: hidden' does not work in combination with other sticky positioned elements, which clip does.
|
||||
document.body.style.overflow = "clip !important"
|
||||
}
|
||||
}
|
||||
if (!isOpen && !bookingCode?.value) {
|
||||
setValue("bookingCode.flag", false, { shouldDirty: true })
|
||||
setIsOpen(isOpen)
|
||||
} else if (!codeError || isOpen) {
|
||||
setIsOpen(isOpen)
|
||||
if (isOpen || bookingCode?.value) {
|
||||
setValue("bookingCode.flag", true, { shouldDirty: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={toggleModal}>
|
||||
<Button type="button" intent="text">
|
||||
{/* For some reason Checkbox click triggers twice modal state change, which returns the modal back to old state. So we are using overlay as trigger for modal */}
|
||||
<div className={styles.overlayTrigger}></div>
|
||||
<Checkbox
|
||||
checked={!!bookingCode?.value}
|
||||
{...register("bookingCode.flag", {
|
||||
onChange: function () {
|
||||
if (bookingCode?.value || isOpen) {
|
||||
setValue("bookingCode.flag", true, { shouldDirty: true })
|
||||
}
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Caption color="uiTextMediumContrast" type="bold" asChild>
|
||||
<span>{codeVoucher}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</Button>
|
||||
<Popover
|
||||
className={styles.codePopover}
|
||||
placement="bottom start"
|
||||
offset={36}
|
||||
>
|
||||
<Dialog>
|
||||
{({ close }) => (
|
||||
<div className={styles.popover}>
|
||||
<BookingFlowInput
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Add code",
|
||||
})}
|
||||
{...register("bookingCode.value", {
|
||||
onChange: (e) => updateValue(e.target.value),
|
||||
})}
|
||||
autoComplete="off"
|
||||
hideError
|
||||
/>
|
||||
<div className={styles.bookingCodeRememberVisible}>
|
||||
{codeError?.message ? (
|
||||
<BookingCodeError codeError={codeError} />
|
||||
) : (
|
||||
<CodeRemember
|
||||
bookingCodeValue={bookingCode?.value}
|
||||
onApplyClick={close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
<CodeRulesModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { forwardRef, type InputHTMLAttributes } from "react"
|
||||
import { Input as InputRAC } from "react-aria-components"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
|
||||
import styles from "./input.module.css"
|
||||
|
||||
export const Input = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputHTMLAttributes<HTMLInputElement>
|
||||
>(function InputComponent(props, ref) {
|
||||
return (
|
||||
<Body asChild color="uiTextHighContrast">
|
||||
<InputRAC {...props} ref={ref} className={styles.input} />
|
||||
</Body>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
|
||||
&:placeholder-shown::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
background-image: url("/_static/icons/cancel.svg");
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown)::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/cancel.svg");
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled::placeholder {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Checkbox from "@scandic-hotels/design-system/Form/Checkbox"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
|
||||
import { getErrorMessage } from "../../../../BookingFlowInput/errors"
|
||||
import Modal from "../../../../TEMP/Modal"
|
||||
import { RemoveExtraRooms } from "../BookingCode"
|
||||
import { isMultiRoomError } from "../utils"
|
||||
|
||||
import styles from "./reward-night.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "../../../Client"
|
||||
|
||||
export default function RewardNight() {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors },
|
||||
trigger,
|
||||
} = useFormContext<BookingWidgetSchema>()
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const reward = intl.formatMessage({
|
||||
defaultMessage: "Reward Night",
|
||||
})
|
||||
const rewardNightTooltip = intl.formatMessage({
|
||||
defaultMessage:
|
||||
"To book a reward night, make sure you're logged in to your Scandic Friends account.",
|
||||
})
|
||||
const redemptionErr = errors[SEARCH_TYPE_REDEMPTION]
|
||||
const isDesktop = useMediaQuery("(min-width: 767px)")
|
||||
|
||||
function validateRedemption(value: boolean) {
|
||||
// Validate redemption as per the rules defined in the schema
|
||||
trigger(SEARCH_TYPE_REDEMPTION)
|
||||
if (value && getValues("bookingCode.value")) {
|
||||
setValue("bookingCode.flag", false)
|
||||
setValue("bookingCode.value", "", { shouldValidate: true })
|
||||
// Hide the notification popup after 5 seconds by re-triggering validation
|
||||
// This is kept consistent with location search field error notification timeout
|
||||
setTimeout(() => {
|
||||
trigger(SEARCH_TYPE_REDEMPTION)
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const resetOnMultiroomError = useCallback(() => {
|
||||
if (isMultiRoomError(redemptionErr?.message) && isDesktop) {
|
||||
setValue(SEARCH_TYPE_REDEMPTION, false, { shouldValidate: true })
|
||||
}
|
||||
}, [redemptionErr?.message, setValue, isDesktop])
|
||||
|
||||
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={SEARCH_TYPE_REDEMPTION}
|
||||
registerOptions={{
|
||||
onChange: (e) => {
|
||||
validateRedemption(e.target.value)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className={styles.rewardNightLabel}>
|
||||
<Caption color="uiTextMediumContrast" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon
|
||||
icon="info"
|
||||
size={20}
|
||||
color="Icon/Interactive/Placeholder"
|
||||
className={styles.errorIcon}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={reward}
|
||||
>
|
||||
<Typography
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
className={styles.rewardNightTooltip}
|
||||
>
|
||||
<span>{rewardNightTooltip}</span>
|
||||
</Typography>
|
||||
</Modal>
|
||||
</div>
|
||||
</Checkbox>
|
||||
{redemptionErr && (
|
||||
<div className={styles.errorContainer}>
|
||||
<Typography
|
||||
className={styles.error}
|
||||
variant="Body/Supporting text (caption)/smRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
size={20}
|
||||
color="Icon/Feedback/Error"
|
||||
className={styles.errorIcon}
|
||||
isFilled={!isDesktop}
|
||||
/>
|
||||
{getErrorMessage(intl, redemptionErr.message)}
|
||||
</span>
|
||||
</Typography>
|
||||
{isMultiRoomError(redemptionErr.message) ? (
|
||||
<div className={styles.hideOnMobile}>
|
||||
<RemoveExtraRooms fullWidth />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.errorContainer {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
margin-top: var(--Space-x2);
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
color: var(--UI-Text-Error);
|
||||
white-space: break-spaces;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
min-width: 20px;
|
||||
}
|
||||
.rewardNightLabel {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.rewardNightTooltip {
|
||||
max-width: 560px;
|
||||
margin-top: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.hideOnMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.errorContainer {
|
||||
border-radius: var(--Space-x15);
|
||||
padding: var(--Space-x2);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
position: absolute;
|
||||
top: calc(100% + 16px);
|
||||
width: 320px;
|
||||
margin-top: 0;
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
}
|
||||
.error {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.button {
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
outline: none;
|
||||
padding: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.default {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.active,
|
||||
.button:focus {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import { buttonVariants } from "./variants"
|
||||
|
||||
import type { SearchListProps } from ".."
|
||||
|
||||
interface ClearSearchButtonProps
|
||||
extends Pick<
|
||||
SearchListProps,
|
||||
"getItemProps" | "handleClearSearchHistory" | "highlightedIndex"
|
||||
> {
|
||||
index: number
|
||||
}
|
||||
|
||||
export default function ClearSearchButton({
|
||||
getItemProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
index,
|
||||
}: ClearSearchButtonProps) {
|
||||
const intl = useIntl()
|
||||
const classNames = buttonVariants({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<button
|
||||
{...getItemProps({
|
||||
className: classNames,
|
||||
id: "clear-search",
|
||||
index,
|
||||
item: "clear-search",
|
||||
role: "button",
|
||||
})}
|
||||
onClick={handleClearSearchHistory}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<MaterialIcon icon="delete" color="Icon/Interactive/Default" size={20} />
|
||||
<Caption color="burgundy" type="bold" asChild>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Clear searches",
|
||||
})}
|
||||
</span>
|
||||
</Caption>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const buttonVariants = cva(styles.button, config)
|
||||
@@ -0,0 +1,45 @@
|
||||
.dialog {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: fixed;
|
||||
top: calc(140px + max(var(--sitewide-alert-height), 25px));
|
||||
width: 100%;
|
||||
height: calc(100% - 200px);
|
||||
z-index: 10010;
|
||||
}
|
||||
|
||||
.default {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.error {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.search {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dialog {
|
||||
position: absolute;
|
||||
width: 360px;
|
||||
/**
|
||||
* var(--Spacing-x4) to account for padding inside
|
||||
* the bookingwidget and to add the padding for the
|
||||
* box itself
|
||||
*/
|
||||
top: calc(100% + var(--Spacing-x4));
|
||||
z-index: 99;
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
max-height: 380px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { dialogVariants } from "./variants"
|
||||
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
|
||||
import type { SearchListProps } from ".."
|
||||
|
||||
interface DialogProps
|
||||
extends React.PropsWithChildren,
|
||||
VariantProps<typeof dialogVariants>,
|
||||
Pick<SearchListProps, "getMenuProps"> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Dialog({
|
||||
children,
|
||||
className,
|
||||
getMenuProps,
|
||||
variant,
|
||||
}: DialogProps) {
|
||||
const classNames = dialogVariants({ className, variant })
|
||||
return <div {...getMenuProps({ className: classNames })}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./dialog.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: styles.default,
|
||||
error: styles.error,
|
||||
search: styles.search,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const dialogVariants = cva(styles.dialog, config)
|
||||
@@ -0,0 +1,13 @@
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
|
||||
import styles from "./list.module.css"
|
||||
|
||||
export default function Label({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={styles.label}>
|
||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||
{children}
|
||||
</Footnote>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import { listItemVariants } from "./variants"
|
||||
|
||||
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
|
||||
import type { PropGetters } from "downshift"
|
||||
|
||||
type ListItemProps = {
|
||||
getItemProps: PropGetters<unknown>["getItemProps"]
|
||||
highlightedIndex: number | null
|
||||
index: number
|
||||
location: AutoCompleteLocation
|
||||
}
|
||||
|
||||
export default function ListItem({
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
index,
|
||||
location,
|
||||
}: ListItemProps) {
|
||||
const classNames = listItemVariants({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li
|
||||
{...getItemProps({
|
||||
className: classNames,
|
||||
index,
|
||||
item: location,
|
||||
})}
|
||||
>
|
||||
<Body color="black" textTransform="bold">
|
||||
{location.name}
|
||||
</Body>
|
||||
{location.destination && (
|
||||
<Body color="uiTextPlaceholder">{location.destination}</Body>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListItemSkeleton() {
|
||||
const classNames = listItemVariants({
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<div style={{ marginBottom: "0.25rem" }}>
|
||||
<SkeletonShimmer width={"200px"} height="18px" display="block" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SkeletonShimmer width={"70px"} height="18px" display="block" />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.listItem {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
cursor: pointer;
|
||||
padding: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.default {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./listItem.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const listItemVariants = cva(styles.listItem, config)
|
||||
@@ -0,0 +1,56 @@
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import Label from "./Label"
|
||||
import ListItem, { ListItemSkeleton } from "./ListItem"
|
||||
|
||||
import styles from "./list.module.css"
|
||||
|
||||
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
|
||||
import type { PropGetters } from "downshift"
|
||||
|
||||
type ListProps = {
|
||||
getItemProps: PropGetters<unknown>["getItemProps"]
|
||||
highlightedIndex: number | null
|
||||
initialIndex?: number
|
||||
label?: string
|
||||
locations: AutoCompleteLocation[]
|
||||
}
|
||||
|
||||
export default function List({
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
initialIndex = 0,
|
||||
label,
|
||||
locations,
|
||||
}: ListProps) {
|
||||
if (!locations.length) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{label ? <Label>{label}</Label> : null}
|
||||
{locations.map((location, index) => (
|
||||
<ListItem
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={initialIndex + index}
|
||||
key={location.id + index}
|
||||
location={location}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListSkeleton() {
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
<Label>
|
||||
<SkeletonShimmer width="50px" height="15px" display="block" />
|
||||
</Label>
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<ListItemSkeleton key={index} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import useLang from "../../../../../../hooks/useLang"
|
||||
import ClearSearchButton from "./ClearSearchButton"
|
||||
import Dialog from "./Dialog"
|
||||
import List, { ListSkeleton } from "./List"
|
||||
|
||||
import styles from "./searchList.module.css"
|
||||
|
||||
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
|
||||
import type { PropGetters } from "downshift"
|
||||
|
||||
type HighlightedIndex = number | null
|
||||
|
||||
export type SearchListProps = {
|
||||
getItemProps: PropGetters<unknown>["getItemProps"]
|
||||
getMenuProps: PropGetters<unknown>["getMenuProps"]
|
||||
isOpen: boolean
|
||||
handleClearSearchHistory: () => void
|
||||
highlightedIndex: HighlightedIndex
|
||||
searchInputName: string
|
||||
search: string
|
||||
searchHistory: AutoCompleteLocation[] | null
|
||||
includeTypes: ("cities" | "hotels" | "countries")[]
|
||||
}
|
||||
|
||||
export default function SearchList({
|
||||
searchInputName,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
search,
|
||||
searchHistory,
|
||||
includeTypes,
|
||||
}: SearchListProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors, isSubmitted },
|
||||
} = useFormContext()
|
||||
const searchError = errors[searchInputName]
|
||||
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 300)
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearch(search)
|
||||
}, [search, setDebouncedSearch])
|
||||
|
||||
const autocompleteQueryEnabled = !!debouncedSearch
|
||||
const {
|
||||
data: autocompleteData,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.autocomplete.destinations.useQuery(
|
||||
{ query: debouncedSearch, lang, includeTypes },
|
||||
{ enabled: autocompleteQueryEnabled }
|
||||
)
|
||||
|
||||
const typeFilteredSearchHistory = searchHistory?.filter((item) => {
|
||||
return includeTypes.includes(item.type)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
clearErrors(searchInputName)
|
||||
}, [search, clearErrors, searchInputName])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError) {
|
||||
timeoutID = setTimeout(() => {
|
||||
clearErrors(searchInputName)
|
||||
// magic number originates from animation
|
||||
// 5000ms delay + 120ms exectuion
|
||||
}, 5120)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutID) {
|
||||
clearTimeout(timeoutID)
|
||||
}
|
||||
}
|
||||
}, [clearErrors, searchError, searchInputName])
|
||||
|
||||
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
||||
if (searchError.message === "Required") {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({
|
||||
defaultMessage: "Enter destination or hotel",
|
||||
})}
|
||||
body={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"A destination or hotel name is needed to be able to search for a hotel room.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (searchError.type === "custom") {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({
|
||||
defaultMessage: "No results",
|
||||
})}
|
||||
body={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"We couldn't find a matching location for your search.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({
|
||||
defaultMessage: "Unable to search",
|
||||
})}
|
||||
body={intl.formatMessage({
|
||||
defaultMessage:
|
||||
"An error occurred while searching, please try again.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
(autocompleteQueryEnabled && isPending) ||
|
||||
(search !== debouncedSearch && search)
|
||||
) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps}>
|
||||
<ListSkeleton />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const hasAutocompleteItems =
|
||||
!!autocompleteData &&
|
||||
(autocompleteData.hits.cities.length > 0 ||
|
||||
autocompleteData.hits.hotels.length > 0)
|
||||
|
||||
if (!hasAutocompleteItems && debouncedSearch) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps} variant="error">
|
||||
<Body className={styles.text} textTransform="bold">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "No results",
|
||||
})}
|
||||
</Body>
|
||||
<Body className={styles.text} color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"We couldn't find a matching location for your search.",
|
||||
})}
|
||||
</Body>
|
||||
{typeFilteredSearchHistory && typeFilteredSearchHistory.length > 0 && (
|
||||
<>
|
||||
<Divider className={styles.noResultsDivider} />
|
||||
<Footnote
|
||||
className={styles.text}
|
||||
color="uiTextPlaceholder"
|
||||
textTransform="uppercase"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Latest searches",
|
||||
})}
|
||||
</Footnote>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
locations={typeFilteredSearchHistory}
|
||||
/>
|
||||
|
||||
<Divider className={styles.divider} />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={typeFilteredSearchHistory.length}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const displaySearchHistory =
|
||||
!debouncedSearch && typeFilteredSearchHistory?.length
|
||||
if (displaySearchHistory) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps}>
|
||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Latest searches",
|
||||
})}
|
||||
</Footnote>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
locations={typeFilteredSearchHistory}
|
||||
/>
|
||||
<Divider className={styles.divider} />
|
||||
<ClearSearchButton
|
||||
getItemProps={getItemProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
index={typeFilteredSearchHistory.length}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (!search) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps} variant="search">
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Countries",
|
||||
})}
|
||||
locations={autocompleteData?.hits.countries ?? []}
|
||||
/>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
initialIndex={autocompleteData?.hits.countries.length ?? 0}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Cities",
|
||||
})}
|
||||
locations={autocompleteData?.hits.cities ?? []}
|
||||
/>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
initialIndex={
|
||||
(autocompleteData?.hits.countries.length ?? 0) +
|
||||
(autocompleteData?.hits.cities.length ?? 0)
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
defaultMessage: "Hotels",
|
||||
})}
|
||||
locations={autocompleteData?.hits.hotels ?? []}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchListError({
|
||||
caption,
|
||||
body,
|
||||
getMenuProps,
|
||||
}: {
|
||||
caption: string
|
||||
body: string
|
||||
getMenuProps: SearchListProps["getMenuProps"]
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
className={`${styles.fadeOut} ${styles.searchError}`}
|
||||
getMenuProps={getMenuProps}
|
||||
variant="error"
|
||||
>
|
||||
<Caption className={styles.heading} color="red">
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{caption}
|
||||
</Caption>
|
||||
<Body>{body}</Body>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.searchError {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fadeOut {
|
||||
animation: fade-out 120ms ease-out 5000ms forwards;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: var(--Spacing-x2) var(--Spacing-x0) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.noResultsDivider {
|
||||
margin: var(--Spacing-x2) 0;
|
||||
}
|
||||
|
||||
.heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client"
|
||||
|
||||
import { cva } from "class-variance-authority"
|
||||
import Downshift from "downshift"
|
||||
import { type ChangeEvent, type FormEvent, useId } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useSearchHistory } from "../../../../../hooks/useSearchHistory"
|
||||
import { Input } from "../Input"
|
||||
import SearchList from "./SearchList"
|
||||
|
||||
import styles from "./search.module.css"
|
||||
|
||||
import type { AutoCompleteLocation } from "@scandic-hotels/trpc/routers/autocomplete/schema"
|
||||
|
||||
interface SearchProps {
|
||||
autoFocus?: boolean
|
||||
alwaysShowResults?: boolean
|
||||
className?: string
|
||||
handlePressEnter: () => void
|
||||
inputName: string
|
||||
onSelect?: (selectedItem: AutoCompleteLocation) => void
|
||||
variant?: "rounded" | "default"
|
||||
withSearchButton?: boolean
|
||||
selectOnBlur?: boolean
|
||||
includeTypes: ("cities" | "hotels" | "countries")[]
|
||||
}
|
||||
|
||||
// TODO this component is not only used in the BookingWidget, but also in the DestinationPage
|
||||
// so we should probably move it to a more generic location
|
||||
export function Search({
|
||||
autoFocus,
|
||||
alwaysShowResults,
|
||||
handlePressEnter,
|
||||
inputName: SEARCH_TERM_NAME,
|
||||
onSelect,
|
||||
variant,
|
||||
withSearchButton = false,
|
||||
selectOnBlur = false,
|
||||
includeTypes,
|
||||
}: SearchProps) {
|
||||
const { register, setValue, setFocus } = useFormContext()
|
||||
const intl = useIntl()
|
||||
const searchLabelId = useId()
|
||||
const searchTerm = useWatch({ name: SEARCH_TERM_NAME }) as string
|
||||
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||
useSearchHistory()
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const newValue = evt.currentTarget.value
|
||||
setValue(SEARCH_TERM_NAME, newValue)
|
||||
}
|
||||
|
||||
function handleOnSelect(selectedItem: AutoCompleteLocation | null) {
|
||||
if (!selectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
setValue("selectedSearch", selectedItem.name)
|
||||
setValue(SEARCH_TERM_NAME, selectedItem.name)
|
||||
insertSearchHistoryItem(selectedItem)
|
||||
|
||||
switch (selectedItem.type) {
|
||||
case "cities":
|
||||
setValue("hotel", undefined)
|
||||
setValue("city", selectedItem.cityIdentifier)
|
||||
|
||||
break
|
||||
case "hotels":
|
||||
setValue("hotel", +selectedItem.id)
|
||||
setValue("city", undefined)
|
||||
break
|
||||
default:
|
||||
logger.error("Unhandled type:", selectedItem.type)
|
||||
break
|
||||
}
|
||||
|
||||
onSelect?.(selectedItem)
|
||||
}
|
||||
|
||||
function handleClearSearchHistory() {
|
||||
clearHistory()
|
||||
}
|
||||
|
||||
const searchInputClassName = searchInputVariants({
|
||||
withSearchButton: withSearchButton,
|
||||
})
|
||||
const clearButtonClassName = clearButtonVariants({
|
||||
visible: !!searchTerm?.trim(),
|
||||
})
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
inputValue={searchTerm}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
defaultHighlightedIndex={0}
|
||||
isOpen={alwaysShowResults}
|
||||
>
|
||||
{({
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
getRootProps,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
openMenu,
|
||||
selectHighlightedItem,
|
||||
}) => (
|
||||
<div className={searchContainerVariants({ variant })}>
|
||||
<div className={styles.inputContainer}>
|
||||
<label
|
||||
{...getLabelProps({
|
||||
htmlFor: SEARCH_TERM_NAME,
|
||||
id: searchLabelId,
|
||||
})}
|
||||
className={styles.label}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>
|
||||
{intl.formatMessage({ defaultMessage: "Where to?" })}
|
||||
</span>
|
||||
</Typography>
|
||||
<div
|
||||
{...getRootProps(
|
||||
{ "aria-labelledby": searchLabelId },
|
||||
{ suppressRefError: true }
|
||||
)}
|
||||
>
|
||||
<div className={searchInputClassName}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: SEARCH_TERM_NAME,
|
||||
"aria-labelledby": searchLabelId,
|
||||
onFocus() {
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
defaultMessage: "Hotels & Destinations",
|
||||
}),
|
||||
value: searchTerm,
|
||||
...register(SEARCH_TERM_NAME, {
|
||||
onChange: handleOnChange,
|
||||
onBlur: () => {
|
||||
if (selectOnBlur) {
|
||||
selectHighlightedItem()
|
||||
}
|
||||
},
|
||||
}),
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Enter" && !isOpen) {
|
||||
handlePressEnter()
|
||||
}
|
||||
},
|
||||
type: "search",
|
||||
})}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{withSearchButton && (
|
||||
<div className={styles.searchButtonContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button
|
||||
variant="Text"
|
||||
size="Small"
|
||||
aria-label={intl.formatMessage({ defaultMessage: "Clear" })}
|
||||
onPress={() => {
|
||||
setValue(SEARCH_TERM_NAME, "")
|
||||
}}
|
||||
className={clearButtonClassName}
|
||||
>
|
||||
<MaterialIcon icon="close" />
|
||||
</Button>
|
||||
</Typography>
|
||||
<Button
|
||||
className={styles.searchButton}
|
||||
variant="Primary"
|
||||
size="Small"
|
||||
type="submit"
|
||||
onPress={() => {
|
||||
if (!searchTerm) {
|
||||
setFocus(SEARCH_TERM_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
openMenu()
|
||||
setTimeout(() => {
|
||||
// This is a workaround to ensure that the menu is open before selecting the highlighted item
|
||||
// Otherwise there is no highlighted item.
|
||||
// Would need to keep track of the last highlighted item otherwise
|
||||
selectHighlightedItem()
|
||||
}, 0)
|
||||
}}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>
|
||||
<MaterialIcon icon="search" color="CurrentColor" />
|
||||
{intl.formatMessage({ defaultMessage: "Search" })}
|
||||
</span>
|
||||
</Typography>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
getMenuProps={getMenuProps}
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isOpen={isOpen}
|
||||
search={searchTerm}
|
||||
searchHistory={searchHistory}
|
||||
searchInputName={SEARCH_TERM_NAME}
|
||||
includeTypes={includeTypes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchSkeleton() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.label} ${styles.red}`}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{intl.formatMessage({ defaultMessage: "Where to?" })}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const searchContainerVariants = cva(styles.container, {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
rounded: styles.rounded,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
})
|
||||
|
||||
const searchInputVariants = cva(styles.searchInput, {
|
||||
variants: {
|
||||
withSearchButton: {
|
||||
true: styles.withSearchButton,
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
withSearchButton: false,
|
||||
},
|
||||
})
|
||||
|
||||
const clearButtonVariants = cva(styles.clearButton, {
|
||||
variants: {
|
||||
visible: {
|
||||
true: styles.clearButtonVisible,
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
.container {
|
||||
border-color: transparent;
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-radius: var(--Corner-radius-md);
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
position: relative;
|
||||
height: 60px;
|
||||
|
||||
&.rounded {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
padding: var(--Space-x15) var(--Space-x15) var(--Space-x15) var(--Space-x3);
|
||||
border: 1px solid var(--Border-Intense);
|
||||
border-radius: var(--Corner-radius-rounded);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:has(input:active, input:focus, input:focus-within) {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
}
|
||||
|
||||
&:has(input:active, input:focus, input:focus-within) {
|
||||
border-color: 1px solid var(--Border-Interactive-Focus);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
color: var(--Text-Accent-Primary);
|
||||
&:focus-within,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: var(--Text-Interactive-Focus);
|
||||
}
|
||||
}
|
||||
|
||||
.searchButtonContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x05);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
|
||||
align-items: center;
|
||||
display: grid;
|
||||
|
||||
& input[type="search"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.withSearchButton {
|
||||
& input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s ease;
|
||||
pointer-events: none;
|
||||
|
||||
&.clearButtonVisible {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import styles from "./validationError.module.css"
|
||||
|
||||
export default function ValidationError() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Caption className={styles.title} color="red" type="bold">
|
||||
<MaterialIcon
|
||||
icon="error_circle_rounded"
|
||||
color="Icon/Feedback/Error"
|
||||
size={20}
|
||||
/>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Enter destination or hotel",
|
||||
})}
|
||||
</Caption>
|
||||
<Caption className={styles.message} type="regular">
|
||||
{intl.formatMessage({
|
||||
defaultMessage:
|
||||
"A destination or hotel name is needed to be able to search for a hotel room.",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.container {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--Space-x2));
|
||||
background: var(--Surface-Primary-Default);
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
padding: var(--Space-x15);
|
||||
max-width: min(100vw, calc(360px - var(--Space-x2)));
|
||||
width: 360px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--Space-x15);
|
||||
align-items: flex-start;
|
||||
gap: var(--Space-x05);
|
||||
z-index: var(--dialog-z-index);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.message {
|
||||
text-wrap: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.container {
|
||||
top: calc(100% + var(--Space-x1) + var(--Space-x2));
|
||||
left: calc(var(--Space-x1) * -1);
|
||||
padding: var(--Space-x2);
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
import { FormProvider, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import BookingCode from "../BookingCode"
|
||||
import RewardNight from "../RewardNight"
|
||||
|
||||
import styles from "./voucher.module.css"
|
||||
|
||||
export default function Voucher() {
|
||||
return (
|
||||
<div className={styles.optionsContainer}>
|
||||
<BookingCode />
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<RewardNight />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VoucherSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
const vouchers = intl.formatMessage({
|
||||
defaultMessage: "Code / Voucher",
|
||||
})
|
||||
const reward = intl.formatMessage({
|
||||
defaultMessage: "Reward Night",
|
||||
})
|
||||
|
||||
const form = useForm()
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.voucherSkeletonContainer}>
|
||||
<label>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>{vouchers}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<SkeletonShimmer width={"100%"} display={"block"} />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
<div className={styles.option}>
|
||||
<SkeletonShimmer width="24px" height="24px" />
|
||||
<Caption color="uiTextMediumContrast" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x2);
|
||||
margin-top: var(--Spacing-x2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.optionsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.voucherSkeletonContainer {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.options {
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x4);
|
||||
}
|
||||
.option {
|
||||
margin-top: 0;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
.optionsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
column-gap: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.options {
|
||||
flex-direction: column;
|
||||
max-width: 190px;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
.option:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
.vouchersHeader {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.where,
|
||||
.rooms,
|
||||
.when {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.showOnTablet {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
.when:has([data-isopen="true"]) .label,
|
||||
.rooms:has([data-pressed="true"]) .label {
|
||||
color: var(--Text-Interactive-Focus);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.voucherContainer {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1366px) {
|
||||
.inputContainer {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when,
|
||||
.where {
|
||||
background-color: var(--Background-Primary);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.button {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rooms {
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
.voucherContainer {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
.voucherContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when,
|
||||
.where {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputContainer input[type="text"] {
|
||||
border: none;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
|
||||
.when:hover,
|
||||
.rooms:hover {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
}
|
||||
.when:has([data-isopen="true"]),
|
||||
.rooms:has([data-focus-visible="true"], [data-pressed="true"]) {
|
||||
background-color: var(--Surface-Primary-Hover);
|
||||
border: 1px solid var(--Border-Interactive-Focus);
|
||||
color: var(--Text-Interactive-Focus);
|
||||
}
|
||||
|
||||
.where {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button {
|
||||
justify-content: center;
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
.showOnMobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
margin-top: auto;
|
||||
@media screen and (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||
.input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.inputContainer {
|
||||
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2)
|
||||
var(--Layout-Tablet-Margin-Margin-min);
|
||||
flex-basis: 80%;
|
||||
}
|
||||
.buttonContainer {
|
||||
padding-right: var(--Layout-Tablet-Margin-Margin-min);
|
||||
margin: 0;
|
||||
}
|
||||
.input .buttonContainer .button {
|
||||
padding: var(--Spacing-x1);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.buttonText {
|
||||
display: none;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.voucherRow {
|
||||
display: flex;
|
||||
background: var(--Base-Surface-Primary-light-Hover);
|
||||
border-bottom: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
padding: var(--Spacing-x2) var(--Layout-Tablet-Margin-Margin-min);
|
||||
}
|
||||
|
||||
.showOnTablet {
|
||||
display: flex;
|
||||
}
|
||||
.hideOnTablet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.input {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { hotelreservation } from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
|
||||
import useLang from "../../../../hooks/useLang"
|
||||
import GuestsRoomsPickerForm from "../../../BookingWidget/GuestsRoomsPicker"
|
||||
import DatePicker from "../../DatePicker"
|
||||
import { RemoveExtraRooms } from "./BookingCode"
|
||||
import { Search, SearchSkeleton } from "./Search"
|
||||
import { isMultiRoomError } from "./utils"
|
||||
import ValidationError from "./ValidationError"
|
||||
import Voucher, { VoucherSkeleton } from "./Voucher"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
import type { BookingWidgetSchema } from "../../Client"
|
||||
|
||||
type BookingWidgetFormContentProps = {
|
||||
formId: string
|
||||
onSubmit: () => void
|
||||
isSearching: boolean
|
||||
}
|
||||
export default function FormContent({
|
||||
formId,
|
||||
onSubmit,
|
||||
isSearching,
|
||||
}: BookingWidgetFormContentProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
formState: { errors, isDirty },
|
||||
} = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
const lang = useLang()
|
||||
const pathName = usePathname()
|
||||
const isBookingFlow = pathName.includes(hotelreservation(lang))
|
||||
|
||||
const selectedDate = useWatch<BookingWidgetSchema, "date">({ name: "date" })
|
||||
|
||||
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<Search
|
||||
handlePressEnter={onSubmit}
|
||||
selectOnBlur={true}
|
||||
inputName="search"
|
||||
includeTypes={["cities", "hotels"]}
|
||||
/>
|
||||
{errors.search && <ValidationError />}
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.label}
|
||||
>
|
||||
<label htmlFor="date">
|
||||
{nights > 0
|
||||
? intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: nights }
|
||||
)
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Check in",
|
||||
})}
|
||||
</label>
|
||||
</Typography>
|
||||
<DatePicker name="date" />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.label}
|
||||
>
|
||||
<label id="rooms-and-guests">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rooms & Guests",
|
||||
})}
|
||||
</label>
|
||||
</Typography>
|
||||
<GuestsRoomsPickerForm ariaLabelledBy="rooms-and-guests" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.buttonContainer} ${styles.showOnTablet}`}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
<span className={styles.icon}>
|
||||
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`${styles.voucherContainer} ${styles.voucherRow}`}>
|
||||
<Voucher />
|
||||
</div>
|
||||
<div className={`${styles.buttonContainer} ${styles.hideOnTablet}`}>
|
||||
{isMultiRoomError(errors.bookingCode?.value?.message) ||
|
||||
isMultiRoomError(errors[SEARCH_TYPE_REDEMPTION]?.message) ? (
|
||||
<div className={styles.showOnMobile}>
|
||||
<RemoveExtraRooms size="medium" fullWidth />
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
className={styles.button}
|
||||
form={formId}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
disabled={isSearching}
|
||||
>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.buttonText}
|
||||
>
|
||||
<span>
|
||||
{isDirty && isBookingFlow
|
||||
? intl.formatMessage({ defaultMessage: "Update" })
|
||||
: intl.formatMessage({ defaultMessage: "Search" })}
|
||||
</span>
|
||||
</Typography>
|
||||
<span className={styles.icon}>
|
||||
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormContentSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<SearchSkeleton />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.label}
|
||||
>
|
||||
<label>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: 0 }
|
||||
)}
|
||||
</label>
|
||||
</Typography>
|
||||
<SkeletonShimmer width={"100%"} display={"block"} />
|
||||
</div>
|
||||
<div className={styles.rooms}>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.label}
|
||||
>
|
||||
<label id="rooms-and-guests">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rooms & Guests",
|
||||
})}
|
||||
</label>
|
||||
</Typography>
|
||||
<SkeletonShimmer width={"100%"} display={"block"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.voucherContainer}>
|
||||
<VoucherSkeleton />
|
||||
</div>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
intent="primary"
|
||||
theme="base"
|
||||
type="submit"
|
||||
disabled
|
||||
>
|
||||
<Typography
|
||||
variant="Body/Supporting text (caption)/smBold"
|
||||
className={styles.buttonText}
|
||||
>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Search",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
|
||||
<span className={styles.icon}>
|
||||
<MaterialIcon icon="search" color="Icon/Inverted" size={28} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { bookingWidgetErrors } from "../schema"
|
||||
|
||||
export function isMultiRoomError(errorMessage: string | undefined): boolean {
|
||||
return (
|
||||
errorMessage === bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE ||
|
||||
errorMessage === bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.section {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.section {
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.form {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.default {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.default {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
|
||||
var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.full {
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: var(--max-width-page);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.compact {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
|
||||
var(--Spacing-x-one-and-half) var(--Spacing-x1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client"
|
||||
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useTransition } from "react"
|
||||
import { Form as FormRAC } from "react-aria-components"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
|
||||
import {
|
||||
selectHotel,
|
||||
selectHotelMap,
|
||||
selectRate,
|
||||
} from "@scandic-hotels/common/constants/routes/hotelReservation"
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
|
||||
import useLang from "../../../hooks/useLang"
|
||||
import {
|
||||
BookingCodeFilterEnum,
|
||||
useBookingCodeFilterStore,
|
||||
} from "../../../stores/bookingCode-filter"
|
||||
import { useTrackingContext } from "../../../trackingContext"
|
||||
import { serializeBookingSearchParams } from "../../../utils/url"
|
||||
import FormContent, { BookingWidgetFormContentSkeleton } from "./FormContent"
|
||||
import { bookingWidgetVariants } from "./variants"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { BookingWidgetType } from ".."
|
||||
import type { BookingWidgetSchema } from "../Client"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
type BookingWidgetFormProps = {
|
||||
type?: BookingWidgetType
|
||||
onClose: () => void
|
||||
}
|
||||
export default function Form({ type, onClose }: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const lang = useLang()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { trackBookingSearchClick } = useTrackingContext()
|
||||
const setBookingCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.setFilter
|
||||
)
|
||||
|
||||
const classNames = bookingWidgetVariants({
|
||||
type,
|
||||
})
|
||||
|
||||
const { handleSubmit, setValue, reset } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
trackBookingSearchClick(data.search, data.hotel ? "hotel" : "destination")
|
||||
const isMapView = pathname.endsWith("/map")
|
||||
const bookingFlowPage = data.hotel
|
||||
? selectRate(lang)
|
||||
: isMapView
|
||||
? selectHotelMap(lang)
|
||||
: selectHotel(lang)
|
||||
const bookingWidgetParams = serializeBookingSearchParams({
|
||||
rooms: data.rooms,
|
||||
...data.date,
|
||||
...(data.city ? { city: data.city } : {}),
|
||||
...(data.hotel ? { hotel: data.hotel } : {}),
|
||||
...(data.bookingCode?.value
|
||||
? { bookingCode: data.bookingCode.value }
|
||||
: {}),
|
||||
// Followed current url structure to keep searchtype=redemption param incase of reward night
|
||||
...(data.redemption ? { searchType: SEARCH_TYPE_REDEMPTION } : {}),
|
||||
})
|
||||
|
||||
onClose()
|
||||
startTransition(() => {
|
||||
router.push(`${bookingFlowPage}?${bookingWidgetParams.toString()}`)
|
||||
})
|
||||
|
||||
if (data.bookingCode?.value) {
|
||||
// Reset the booking code filter if changed by user to "All rates"
|
||||
setBookingCodeFilter(BookingCodeFilterEnum.Discounted)
|
||||
|
||||
if (data.bookingCode.remember) {
|
||||
localStorage.setItem("bookingCode", JSON.stringify(data.bookingCode))
|
||||
}
|
||||
} else {
|
||||
setValue("bookingCode.remember", false, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
localStorage.removeItem("bookingCode")
|
||||
}
|
||||
reset(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={classNames}>
|
||||
<FormRAC
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
>
|
||||
<FormContent
|
||||
formId={formId}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
isSearching={isPending}
|
||||
/>
|
||||
</FormRAC>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookingWidgetFormSkeleton({
|
||||
type,
|
||||
}: {
|
||||
type: BookingWidgetType
|
||||
}) {
|
||||
const classNames = bookingWidgetVariants({
|
||||
type,
|
||||
})
|
||||
|
||||
return (
|
||||
<section className={classNames}>
|
||||
<form className={styles.form}>
|
||||
<BookingWidgetFormContentSkeleton />
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
export const bookingWidgetErrors = {
|
||||
AGE_REQUIRED: "AGE_REQUIRED",
|
||||
BED_CHOICE_REQUIRED: "BED_CHOICE_REQUIRED",
|
||||
CHILDREN_EXCEEDS_ADULTS: "CHILDREN_EXCEEDS_ADULTS",
|
||||
BOOKING_CODE_INVALID: "BOOKING_CODE_INVALID",
|
||||
REQUIRED: "REQUIRED",
|
||||
DESTINATION_REQUIRED: "DESTINATION_REQUIRED",
|
||||
MULTIROOM_BOOKING_CODE_UNAVAILABLE: "MULTIROOM_BOOKING_CODE_UNAVAILABLE",
|
||||
MULTIROOM_REWARD_NIGHT_UNAVAILABLE: "MULTIROOM_REWARD_NIGHT_UNAVAILABLE",
|
||||
CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE:
|
||||
"CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE",
|
||||
} as const
|
||||
|
||||
export const guestRoomSchema = z
|
||||
.object({
|
||||
adults: z.number().default(1),
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
z.object({
|
||||
age: z.number().min(0, bookingWidgetErrors.AGE_REQUIRED),
|
||||
bed: z.number().min(0, bookingWidgetErrors.BED_CHOICE_REQUIRED),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const childrenInAdultsBed = value.childrenInRoom.filter(
|
||||
(c) => c.bed === ChildBedMapEnum.IN_ADULTS_BED
|
||||
)
|
||||
if (value.adults < childrenInAdultsBed.length) {
|
||||
const lastAdultBedIndex = value.childrenInRoom
|
||||
.map((c) => c.bed)
|
||||
.lastIndexOf(ChildBedMapEnum.IN_ADULTS_BED)
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.CHILDREN_EXCEEDS_ADULTS,
|
||||
path: ["childrenInRoom", lastAdultBedIndex],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const guestRoomsSchema = z.array(guestRoomSchema)
|
||||
|
||||
export const bookingCodeSchema = z
|
||||
.object({
|
||||
value: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) =>
|
||||
!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
|
||||
),
|
||||
{ message: bookingWidgetErrors.BOOKING_CODE_INVALID }
|
||||
)
|
||||
.default(""),
|
||||
remember: z.boolean().default(false),
|
||||
flag: z.boolean().default(false),
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const bookingWidgetSchema = z
|
||||
.object({
|
||||
bookingCode: bookingCodeSchema,
|
||||
date: z.object({
|
||||
// Update this as required once started working with Date picker in Nights component
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
}),
|
||||
redemption: z.boolean().default(false),
|
||||
rooms: guestRoomsSchema,
|
||||
search: z.string({ coerce: true }).min(1, bookingWidgetErrors.REQUIRED),
|
||||
selectedSearch: z.string().optional(),
|
||||
hotel: z.number().optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.hotel && !value.city) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.DESTINATION_REQUIRED,
|
||||
path: ["search"],
|
||||
})
|
||||
}
|
||||
if (value.rooms.length > 1 && value.bookingCode?.value.startsWith("VO")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE,
|
||||
path: ["bookingCode.value"],
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE,
|
||||
path: ["rooms"],
|
||||
})
|
||||
}
|
||||
if (value.rooms.length > 1 && value.redemption) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE,
|
||||
path: [SEARCH_TYPE_REDEMPTION],
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE,
|
||||
path: ["rooms"],
|
||||
})
|
||||
}
|
||||
if (value.bookingCode?.value && value.redemption) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE,
|
||||
path: [SEARCH_TYPE_REDEMPTION],
|
||||
})
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE,
|
||||
path: ["bookingCode.value"],
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
export const bookingWidgetVariants = cva(styles.section, {
|
||||
variants: {
|
||||
type: {
|
||||
default: styles.default,
|
||||
full: styles.full,
|
||||
compact: styles.compact,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
type: "full",
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user