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:
Anton Gunnarsson
2025-08-05 09:20:20 +00:00
parent 03c9244fdf
commit 1bd8fe6821
206 changed files with 1936 additions and 796 deletions

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
})

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -0,0 +1,9 @@
.list {
display: flex;
flex-direction: column;
list-style: none;
}
.label {
padding: 0 var(--Spacing-x1);
}

View File

@@ -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>
)
}

View File

@@ -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);
}

View File

@@ -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: "",
},
},
})

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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>
)
}

View File

@@ -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
)
}

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

@@ -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"],
})
}
})

View File

@@ -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",
},
})