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,53 @@
import { logger } from "@scandic-hotels/common/logger"
import { bookingWidgetErrors } from "../BookingWidget/BookingWidgetForm/schema"
import type { IntlShape } from "react-intl"
export function getErrorMessage(intl: IntlShape, errorCode?: string) {
switch (errorCode) {
case bookingWidgetErrors.BOOKING_CODE_INVALID:
return intl.formatMessage({
defaultMessage: "Booking code is invalid",
})
case bookingWidgetErrors.AGE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Age is required",
})
case bookingWidgetErrors.BED_CHOICE_REQUIRED:
return intl.formatMessage({
defaultMessage: "Bed choice is required",
})
case bookingWidgetErrors.CHILDREN_EXCEEDS_ADULTS:
return intl.formatMessage({
defaultMessage:
"You cannot have more children in adults bed than adults in the room",
})
case bookingWidgetErrors.REQUIRED:
return intl.formatMessage({
defaultMessage: "Required",
})
case bookingWidgetErrors.DESTINATION_REQUIRED:
return intl.formatMessage({
defaultMessage: "Destination required",
})
case bookingWidgetErrors.MULTIROOM_BOOKING_CODE_UNAVAILABLE:
return intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with this booking code.",
})
case bookingWidgetErrors.MULTIROOM_REWARD_NIGHT_UNAVAILABLE:
return intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with reward night.",
})
case bookingWidgetErrors.CODE_VOUCHER_REWARD_NIGHT_UNAVAILABLE:
return intl.formatMessage({
defaultMessage:
"Reward nights can't be combined with codes or vouchers.",
})
default:
logger.warn("Error code not supported:", errorCode)
return errorCode
}
}

View File

@@ -0,0 +1,109 @@
"use client"
// This is almost a copy of the Input in TempDesignSystem, but since it's tightly coupled
// to the error messages we need to duplicate it for now. In the future we should
// rewrite it to be more reusable.
import { forwardRef } from "react"
import { Text, TextField } from "react-aria-components"
import {
Controller,
type RegisterOptions,
useFormContext,
} from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Input as InputWithLabel } from "@scandic-hotels/design-system/Input"
import { getErrorMessage } from "./errors"
import styles from "./input.module.css"
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
helpText?: string
label: string
name: string
registerOptions?: RegisterOptions
hideError?: boolean
}
const BookingFlowInput = forwardRef<HTMLInputElement, InputProps>(
function Input(
{
"aria-label": ariaLabel,
autoComplete,
className = "",
disabled = false,
helpText = "",
label,
maxLength,
name,
placeholder,
readOnly = false,
registerOptions = {},
type = "text",
hideError,
inputMode,
},
ref
) {
const intl = useIntl()
const { control } = useFormContext()
return (
<Controller
disabled={disabled}
control={control}
name={name}
rules={registerOptions}
render={({ field, fieldState }) => (
<TextField
aria-label={ariaLabel}
className={className}
isDisabled={field.disabled}
isInvalid={fieldState.invalid}
isRequired={!!registerOptions.required}
name={field.name}
onBlur={field.onBlur}
onChange={field.onChange}
validationBehavior="aria"
value={field.value}
>
<InputWithLabel
{...field}
ref={ref}
aria-labelledby={field.name}
autoComplete={autoComplete}
id={field.name}
label={label}
maxLength={maxLength}
placeholder={placeholder}
readOnly={readOnly}
required={!!registerOptions.required}
type={type}
inputMode={inputMode}
/>
{helpText && !fieldState.error ? (
<Caption asChild color="black">
<Text className={styles.helpText} slot="description">
<MaterialIcon icon="check" size={20} />
{helpText}
</Text>
</Caption>
) : null}
{fieldState.error && !hideError ? (
<Caption className={styles.error} fontOnly>
<MaterialIcon icon="info" color="Icon/Feedback/Error" />
{getErrorMessage(intl, fieldState.error.message)}
</Caption>
) : null}
</TextField>
)}
/>
)
}
)
export default BookingFlowInput

View File

@@ -0,0 +1,17 @@
.helpText {
align-items: flex-start;
display: flex;
gap: var(--Space-x05);
}
.error {
align-items: center;
color: var(--Text-Interactive-Error);
display: flex;
gap: var(--Space-x05);
margin: var(--Space-x1) 0 0;
}
.error svg {
min-width: 20px;
}

View File

@@ -0,0 +1,16 @@
"use client"
import { TrackingContext, type TrackingFunctions } from "../trackingContext"
import type { ReactNode } from "react"
export function BookingFlowTrackingProvider(props: {
children: ReactNode
trackingFunctions: TrackingFunctions
}) {
return (
<TrackingContext value={props.trackingFunctions}>
{props.children}
</TrackingContext>
)
}

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

View File

@@ -0,0 +1,237 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useSearchParams } from "next/navigation"
import { use, useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { dt } from "@scandic-hotels/common/dt"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { debounce } from "@scandic-hotels/common/utils/debounce"
import isValidJson from "@scandic-hotels/common/utils/isValidJson"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { trpc } from "@scandic-hotels/trpc/client"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import useLang from "../../hooks/useLang"
import {
type bookingCodeSchema,
bookingWidgetSchema,
} from "./BookingWidgetForm/schema"
import Form from "./BookingWidgetForm"
import MobileToggleButton from "./MobileToggleButton"
import { BookingWidgetSkeleton } from "./Skeleton"
import {
bookingWidgetContainerVariants,
formContainerVariants,
} from "./variant"
import styles from "./bookingWidget.module.css"
import type { z } from "zod"
import type { BookingWidgetSearchData, BookingWidgetType } from "."
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>
export type BookingCodeSchema = z.output<typeof bookingCodeSchema>
export type BookingWidgetClientProps = {
type?: BookingWidgetType
data: BookingWidgetSearchData
pageSettingsBookingCodePromise: Promise<string> | null
}
export default function BookingWidgetClient({
type,
data,
pageSettingsBookingCodePromise,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const [originalOverflowY, setOriginalOverflowY] = useState<string | null>(
null
)
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
const { data: destinationsData, isPending } =
trpc.autocomplete.destinations.useQuery(
{
lang,
query: "",
includeTypes: ["hotels", "cities"],
selectedHotelId: data.hotelId ? data.hotelId.toString() : undefined,
selectedCity: data.city,
},
{ enabled: shouldFetchAutoComplete }
)
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const now = dt()
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
// this is fine as isDateParamValid will catch this and default the values accordingly.
let fromDate = dt(data.fromDate)
let toDate = dt(data.toDate)
const isDateParamValid =
fromDate.isValid() &&
toDate.isValid() &&
fromDate.isSameOrAfter(now, "day") &&
toDate.isAfter(fromDate)
if (!isDateParamValid) {
fromDate = now
toDate = now.add(1, "day")
}
let selectedLocation =
destinationsData?.currentSelection.hotel ??
destinationsData?.currentSelection.city
// if bookingCode is not provided in the search params,
// we will fetch it from the page settings stored in Contentstack.
const selectedBookingCode =
data.bookingCode ||
(pageSettingsBookingCodePromise !== null
? use(pageSettingsBookingCodePromise)
: "")
const defaultRoomsData: BookingWidgetSchema["rooms"] = data.rooms?.map(
(room) => ({
adults: room.adults,
childrenInRoom: room.childrenInRoom || [],
})
) ?? [
{
adults: 1,
childrenInRoom: [],
},
]
const hotelId = data.hotelId ? parseInt(data.hotelId) : undefined
const methods = useForm({
defaultValues: {
search: selectedLocation?.name ?? "",
// Only used for displaying the selected location for mobile, not for actual form input
selectedSearch: selectedLocation?.name ?? "",
date: {
fromDate: fromDate.format("YYYY-MM-DD"),
toDate: toDate.format("YYYY-MM-DD"),
},
bookingCode: {
value: selectedBookingCode,
remember: false,
},
redemption: data.searchType === SEARCH_TYPE_REDEMPTION,
rooms: defaultRoomsData,
city: data.city || undefined,
hotel: hotelId,
},
shouldFocusError: false,
mode: "onSubmit",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onSubmit",
})
const searchParams = useSearchParams()
const bookingCodeFromSearchParams = searchParams.get("bookingCode") || ""
const [bookingCode, setBookingCode] = useState(bookingCodeFromSearchParams)
if (bookingCode !== bookingCodeFromSearchParams) {
methods.setValue("bookingCode", {
value: bookingCodeFromSearchParams,
})
setBookingCode(bookingCodeFromSearchParams)
}
useEffect(() => {
if (!selectedLocation) return
/*
If `trpc.hotel.locations.get.useQuery` hasn't been fetched previously and is hence async
we need to update the default values when data is available
*/
methods.setValue("search", selectedLocation.name)
methods.setValue("selectedSearch", selectedLocation.name)
}, [selectedLocation, methods])
function closeMobileSearch() {
setIsOpen(false)
const overflowY = originalOverflowY ?? "visible"
document.body.style.overflowY = overflowY
}
function openMobileSearch() {
setIsOpen(true)
setOriginalOverflowY(document.body.style.overflowY)
document.body.style.overflowY = "hidden"
}
useEffect(() => {
const observer = new ResizeObserver(
debounce(([entry]) => {
if (entry.contentRect.width > 768) {
setIsOpen(false)
document.body.style.removeProperty("overflow-y")
}
})
)
observer.observe(document.body)
return () => {
observer.unobserve(document.body)
}
}, [])
useEffect(() => {
if (!window?.sessionStorage || !window?.localStorage) return
if (!selectedBookingCode) {
const storedBookingCode = localStorage.getItem("bookingCode")
const initialBookingCode: BookingCodeSchema | undefined =
storedBookingCode && isValidJson(storedBookingCode)
? JSON.parse(storedBookingCode)
: undefined
initialBookingCode?.remember &&
methods.setValue("bookingCode", initialBookingCode)
}
}, [methods, selectedBookingCode])
if (shouldShowSkeleton) {
return <BookingWidgetSkeleton type={type} />
}
const classNames = bookingWidgetContainerVariants({
type,
})
const formContainerClassNames = formContainerVariants({
type,
})
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={classNames} data-open={isOpen}>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={formContainerClassNames}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<MaterialIcon icon="close" />
</button>
<Form type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
</FormProvider>
)
}

View File

@@ -0,0 +1,145 @@
"use client"
import { useState } from "react"
import { type DateRange, DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import Caption from "@scandic-hotels/design-system/Caption"
import { Divider } from "@scandic-hotels/design-system/Divider"
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 useLang from "../../../../hooks/useLang"
import { locales } from "../locales"
import styles from "./desktop.module.css"
import classNames from "react-day-picker/style.module.css"
type DatePickerRangeProps = {
close: () => void
startMonth?: Date
hideHeader?: boolean
selectedRange: DateRange | undefined
handleOnSelect: (nextRange: DateRange | undefined, selectedDay: Date) => void
}
export default function DatePickerRangeDesktop({
close,
handleOnSelect,
selectedRange,
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()
const [month, setMonth] = useState(selectedRange?.from ?? new Date())
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const currentDate = dt().toDate()
const lastDayOfPreviousMonth = dt(currentDate)
.set("date", 1)
.subtract(1, "day")
.toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
// Max future date allowed to book kept same as of existing prod.
const endDate = dt(currentDate).add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
function handleMonthChange(selected: Date) {
setMonth(selected)
}
return (
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
footer: styles.footer,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: `${classNames.months} ${styles.months}`,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.container}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
nav: `${classNames.nav} ${styles.nav}`,
button_next: `${classNames.button_next} ${styles.button_next}`,
button_previous: `${classNames.button_previous} ${styles.button_previous}`,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
excludeDisabled
footer
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
lang={lang}
locale={locale}
mode="range"
month={month}
numberOfMonths={2}
onSelect={handleOnSelect}
onMonthChange={handleMonthChange}
required={false}
selected={selectedRange}
startMonth={currentDate}
endMonth={endDate}
weekStartsOn={1}
components={{
Chevron(props) {
return (
<MaterialIcon
icon="chevron_left"
className={props.className}
size={20}
/>
)
},
Footer(props) {
return (
<>
<Divider
className={styles.divider}
color="Border/Divider/Subtle"
/>
<footer className={props.className}>
<Button
intent="tertiary"
onPress={close}
size="small"
theme="base"
>
<Caption color="white" type="bold" asChild>
<span>
{intl.formatMessage({
defaultMessage: "Select dates",
})}
</span>
</Caption>
</Button>
</footer>
</>
)
},
MonthCaption(props) {
return (
<div className={props.className}>
<Typography variant="Title/Subtitle/md">
<h3>{props.children}</h3>
</Typography>
</div>
)
},
}}
/>
)
}

View File

@@ -0,0 +1,151 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { type DateRange, DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { Lang } from "@scandic-hotels/common/constants/language"
import { dt } from "@scandic-hotels/common/dt"
import Body from "@scandic-hotels/design-system/Body"
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 useLang from "../../../../hooks/useLang"
import { locales } from "../locales"
import styles from "./mobile.module.css"
import classNames from "react-day-picker/style.module.css"
type DatePickerRangeProps = {
close: () => void
startMonth?: Date
hideHeader?: boolean
selectedRange: DateRange | undefined
handleOnSelect: (nextRange: DateRange | undefined, selectedDay: Date) => void
}
export default function DatePickerRangeMobile({
close,
handleOnSelect,
selectedRange,
}: DatePickerRangeProps) {
const lang = useLang()
const intl = useIntl()
/** English is default language and doesn't need to be imported */
const locale = lang === Lang.en ? undefined : locales[lang]
const monthsRef = useRef<HTMLDivElement | null>(null)
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true)
const currentDate = dt().toDate()
const lastDayOfPreviousMonth = dt(currentDate)
.set("date", 1)
.subtract(1, "day")
.toDate()
const yesterday = dt(currentDate).subtract(1, "day").toDate()
useEffect(() => {
if (!monthsRef.current || !selectedRange?.from || !autoScrollEnabled) return
const selectedDay = monthsRef.current.querySelector(
'td[aria-selected="true"]:not([data-outside="true"])'
)
const targetMonth = selectedDay?.closest(`.${styles.month}`)
if (targetMonth) {
targetMonth.scrollIntoView({ block: "start" })
}
}, [selectedRange, autoScrollEnabled])
function handleSelectWrapper(
dateRange: DateRange | undefined,
selectedDay: Date
) {
setAutoScrollEnabled(false)
handleOnSelect(dateRange, selectedDay)
}
// Max future date allowed to book kept same as of existing prod.
const endDate = dt(currentDate).add(395, "day").toDate()
const endOfLastMonth = dt(endDate).endOf("month").toDate()
return (
<div className={styles.container} ref={monthsRef}>
<header className={styles.header}>
<button className={styles.close} onClick={close} type="button">
<MaterialIcon icon="close" />
</button>
</header>
<DayPicker
classNames={{
...classNames,
caption_label: `${classNames.caption_label} ${styles.captionLabel}`,
day: `${classNames.day} ${styles.day}`,
day_button: `${classNames.day_button} ${styles.dayButton}`,
month: styles.month,
month_caption: `${classNames.month_caption} ${styles.monthCaption}`,
months: styles.months,
range_end: styles.rangeEnd,
range_middle: styles.rangeMiddle,
range_start: styles.rangeStart,
root: `${classNames.root} ${styles.root}`,
week: styles.week,
weekday: `${classNames.weekday} ${styles.weekDay}`,
}}
disabled={[
{ from: lastDayOfPreviousMonth, to: yesterday },
{ from: endDate, to: endOfLastMonth },
]}
endMonth={endDate}
excludeDisabled
formatters={{
formatWeekdayName(weekday) {
return dt(weekday).locale(lang).format("ddd")
},
}}
hideNavigation
lang={lang}
locale={locale}
mode="range"
/** Showing full year or what's left of it */
numberOfMonths={13}
onSelect={(dateRange, selectedDay) =>
handleSelectWrapper(dateRange, selectedDay)
}
required
selected={selectedRange}
startMonth={currentDate}
weekStartsOn={1}
components={{
MonthCaption(props) {
return (
<div className={props.className}>
<Typography variant="Title/Subtitle/md">
<h3>{props.children}</h3>
</Typography>
</div>
)
},
}}
/>
<footer className={styles.footer}>
<Button
className={styles.button}
intent="tertiary"
onPress={close}
size="large"
theme="base"
>
<Body color="white" textTransform="bold" asChild>
<span>
{intl.formatMessage({
defaultMessage: "Select dates",
})}
</span>
</Body>
</Button>
</footer>
</div>
)
}

View File

@@ -0,0 +1,128 @@
@media screen and (max-width: 1366px) {
.container {
display: none;
}
}
div.months {
flex-wrap: nowrap;
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Background-Primary);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day button.dayButton:hover {
background: var(--Base-Surface-Secondary-light-Hover);
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day.rangeMiddle[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Background-Primary);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--UI-Text-Placeholder);
font-family: var(--typography-Footnote-Labels-fontFamily);
font-size: var(--typography-Footnote-Labels-fontSize);
font-weight: var(--typography-Footnote-Labels-fontWeight);
letter-spacing: var(--typography-Footnote-Labels-letterSpacing);
line-height: var(--typography-Footnote-Labels-lineHeight);
text-decoration: var(--typography-Footnote-Labels-textDecoration);
text-transform: uppercase;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: var(--Spacing-x2);
}
.divider {
margin-top: var(--Spacing-x2);
}
.nav {
width: 100%;
display: flex;
justify-content: space-between;
}
.nav .button_next {
transform: rotate(180deg);
margin-left: auto;
}
.nav .button_previous:disabled,
.nav .button_next:disabled {
display: none;
}

View File

@@ -0,0 +1,182 @@
.container {
--header-height: 72px;
--sticky-button-height: 124px;
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
position: relative;
}
.container.noHeader {
grid-template-areas: "content";
grid-template-rows: auto;
}
.root {
display: grid;
grid-area: content;
}
.header {
align-self: flex-end;
background-color: var(--Main-Grey-White);
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2);
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
}
.select {
justify-self: center;
min-width: 100px;
transform: translateX(24px);
}
.close {
align-items: center;
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
}
div.months {
display: grid;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.month {
display: grid;
justify-items: center;
padding-top: var(--Space-x3);
scroll-snap-align: start;
}
.month:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + var(--Spacing-x2));
}
.monthCaption {
justify-content: center;
}
.captionLabel {
text-transform: capitalize;
}
.footer {
align-self: flex-start;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
display: flex;
grid-area: content;
padding: var(--Spacing-x1) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
bottom: 0;
right: 0;
left: 0;
z-index: 10;
}
.footer .button {
width: 100%;
}
td.day,
td.rangeEnd,
td.rangeStart {
font-family: var(--typography-Body-Bold-fontFamily);
font-size: var(--typography-Body-Bold-fontSize);
font-weight: 500;
letter-spacing: var(--typography-Body-Bold-letterSpacing);
line-height: var(--typography-Body-Bold-lineHeight);
text-decoration: var(--typography-Body-Bold-textDecoration);
}
td.rangeEnd,
td.rangeStart {
background: var(--Background-Primary);
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) {
border-radius: 0 50% 50% 0;
}
td.rangeStart[aria-selected="true"] {
border-radius: 50% 0 0 50%;
}
td.rangeEnd[aria-selected="true"] button.dayButton:hover,
td.rangeStart[aria-selected="true"] button.dayButton:hover {
background: var(--Primary-Light-On-Surface-Accent);
border-radius: 50%;
}
td.rangeEnd[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.rangeStart[aria-selected="true"]:not([data-outside="true"]) button.dayButton,
td.day[aria-selected="true"] button.dayButton {
background: var(--Primary-Light-On-Surface-Accent);
border: none;
color: var(--Base-Button-Inverted-Fill-Normal);
}
td.day,
td.day[data-today="true"] {
color: var(--UI-Text-High-contrast);
height: 40px;
padding: var(--Spacing-x-half);
width: 40px;
}
td.day[data-outside="true"] button.dayButton {
border: none;
}
td.day.rangeMiddle[aria-selected="true"],
td.rangeMiddle[aria-selected="true"] button.dayButton {
background: var(--Background-Primary);
border: none;
border-radius: 0;
color: var(--UI-Text-High-contrast);
}
td.day[data-disabled="true"],
td.day[data-disabled="true"] button.dayButton,
td.day[data-outside="true"] ~ td.day[data-disabled="true"],
td.day[data-outside="true"] ~ td.day[data-disabled="true"] button.dayButton,
.week:has(td.day[data-outside="true"] ~ td.day[data-disabled="true"])
td.day[data-outside="true"]
button.dayButton {
background: none;
color: var(--Base-Text-Disabled);
cursor: not-allowed;
}
.weekDay {
color: var(--Base-Text-Medium-contrast);
opacity: 1;
font-family: var(--typography-Caption-Labels-fontFamily);
font-size: var(--typography-Caption-Labels-fontSize);
font-weight: var(--typography-Caption-Labels-fontWeight);
letter-spacing: var(--typography-Caption-Labels-letterSpacing);
line-height: var(--typography-Caption-Labels-lineHeight);
text-decoration: var(--typography-Caption-Labels-textDecoration);
text-transform: uppercase;
}
@media screen and (min-width: 1367px) {
.container {
display: none;
}
}

View File

@@ -0,0 +1,66 @@
.btn {
background: none;
border: none;
cursor: pointer;
outline: none;
padding: 0;
width: 100%;
text-align: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px var(--Spacing-x-one-and-half) 0;
}
.body {
opacity: 0.8;
}
.hideWrapper {
background-color: var(--Main-Grey-White);
display: none;
}
.container[data-isopen="true"] .hideWrapper {
display: block;
}
@media screen and (max-width: 1366px) {
.container {
z-index: 10001;
height: 24px;
}
.hideWrapper {
bottom: 0;
left: 0;
overflow: hidden;
position: fixed;
right: 0;
top: 100%;
transition: top 300ms ease;
z-index: 10001;
}
.container[data-isopen="true"] .hideWrapper {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
top: calc(max(var(--sitewide-alert-height), 20px));
}
}
@media screen and (min-width: 1367px) {
.hideWrapper {
border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow);
padding: var(--Spacing-x2) var(--Spacing-x3);
position: absolute;
/**
BookingWidget padding +
border-width +
wanted space below booking widget
*/
top: calc(100% + var(--Spacing-x1) + 1px + var(--Spacing-x4));
}
}

View File

@@ -0,0 +1,196 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { Typography } from "@scandic-hotels/design-system/Typography"
import useLang from "../../../hooks/useLang"
import DatePickerRangeDesktop from "./Range/Desktop"
import DatePickerRangeMobile from "./Range/Mobile"
import styles from "./date-picker.module.css"
import type { DateRange } from "react-day-picker"
type DatePickerFormProps = {
name?: string
}
export default function DatePickerForm({ name = "date" }: DatePickerFormProps) {
const lang = useLang()
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const selectedDate = useWatch({ name })
const { register, setValue } = useFormContext()
const ref = useRef<HTMLDivElement | null>(null)
const close = useCallback(() => {
if (!selectedDate.toDate) {
setValue(
name,
{
fromDate: selectedDate.fromDate,
toDate: dt(selectedDate.fromDate).add(1, "day").format("YYYY-MM-DD"),
},
{ shouldDirty: true }
)
}
setIsOpen(false)
}, [name, setValue, selectedDate])
function showOnFocus() {
setIsOpen(true)
}
function handleSelectDate(
_nextRange: DateRange | undefined,
selectedDay: Date
) {
const now = dt()
const dateClicked = dt(selectedDay)
const dateClickedFormatted = dateClicked.format("YYYY-MM-DD")
/* check if selected date is not before todays date,
which happens when "Enter" key is pressed in any other input field of the form */
if (!dateClicked.isBefore(now, "day")) {
// Handle form value updates based on the requirements
if (selectedDate.fromDate && selectedDate.toDate) {
// Both dates were previously selected, starting fresh with new date
setValue(
name,
{
fromDate: dateClickedFormatted,
toDate: undefined,
},
{ shouldDirty: true }
)
} else if (selectedDate.fromDate && !selectedDate.toDate) {
// If the selected day is the same as the first date, we don't need to update the form value
if (dateClicked.isSame(selectedDate.fromDate)) {
return
}
// We're selecting the second date
if (dateClicked.isBefore(selectedDate.fromDate)) {
// If second selected date is before first date, swap them
setValue(
name,
{
fromDate: dateClickedFormatted,
toDate: selectedDate.fromDate,
},
{ shouldDirty: true }
)
} else {
// If second selected date is after first date, keep order
setValue(
name,
{
fromDate: selectedDate.fromDate,
toDate: dateClickedFormatted,
},
{ shouldDirty: true }
)
}
}
}
}
const closeIfOutside = useCallback(
(target: HTMLElement) => {
if (ref.current && target && !ref.current.contains(target)) {
close()
}
},
[close, ref]
)
function closeOnBlur(evt: FocusEvent) {
if (isOpen) {
const target = evt.relatedTarget as HTMLElement
closeIfOutside(target)
}
}
useEffect(() => {
function handleClickOutside(evt: Event) {
if (isOpen) {
const target = evt.target as HTMLElement
closeIfOutside(target)
}
}
document.body.addEventListener("click", handleClickOutside)
return () => {
document.body.removeEventListener("click", handleClickOutside)
}
}, [closeIfOutside, isOpen])
const selectedFromDate = dt(selectedDate.fromDate)
.locale(lang)
.format(longDateFormat[lang])
const selectedToDate = !!selectedDate.toDate
? dt(selectedDate.toDate).locale(lang).format(longDateFormat[lang])
: ""
return (
<div
className={styles.container}
onBlur={(e) => {
closeOnBlur(e.nativeEvent)
}}
data-isopen={isOpen}
ref={ref}
>
<button
className={styles.btn}
onFocus={showOnFocus}
onClick={() => setIsOpen(true)}
type="button"
>
<Typography variant="Body/Paragraph/mdRegular" className={styles.body}>
<span>
{intl.formatMessage(
{
defaultMessage: "{selectedFromDate} - {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)}
</span>
</Typography>
</button>
<input {...register("date.fromDate")} type="hidden" />
<input {...register("date.toDate")} type="hidden" />
<div aria-modal className={styles.hideWrapper} role="dialog">
<DatePickerRangeDesktop
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
{isOpen && (
<DatePickerRangeMobile
close={close}
handleOnSelect={handleSelectDate}
// DayPicker lib needs Daterange in form as below to show appropriate UI
selectedRange={{
from: dt(selectedDate.fromDate).toDate(),
to: selectedDate.toDate
? dt(selectedDate.toDate).toDate()
: undefined,
}}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { da, de, fi, nb, sv } from "date-fns/locale"
import { Lang } from "@scandic-hotels/common/constants/language"
export const locales = {
[Lang.da]: da,
[Lang.de]: de,
[Lang.fi]: fi,
[Lang.no]: nb,
[Lang.sv]: sv,
}

View File

@@ -0,0 +1,28 @@
.floatingBookingWidget {
width: var(--max-width-content);
margin: 0 auto;
min-height: 84px;
z-index: 1000;
position: relative;
.floatingBackground {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
}
&[data-intersecting="true"] {
.floatingBackground {
background: var(--Surface-UI-Fill-Default);
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
margin-top: var(--sitewide-alert-sticky-height);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
}
}
}

View File

@@ -0,0 +1,63 @@
"use client"
import { useEffect, useRef, useState } from "react"
import BookingWidgetClient, { type BookingWidgetClientProps } from "../Client"
import styles from "./FloatingBookingWidget.module.css"
type Props = Omit<BookingWidgetClientProps, "type">
export function FloatingBookingWidgetClient(props: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const [stickyTop, setStickyTop] = useState(false)
useEffect(() => {
observerRef.current = new IntersectionObserver(
([entry]) => {
const hasScrolledPastTop = entry.boundingClientRect.top < 0
setStickyTop(hasScrolledPastTop)
},
{ threshold: 0, rootMargin: "0px 0px -100% 0px" }
)
if (containerRef.current) {
observerRef.current?.observe(containerRef.current)
}
return () => {
observerRef.current?.disconnect()
}
}, [])
useEffect(() => {
/*
Re-observe the element on an interval to ensure the observer is up to date
This is a workaround for the fact that the observer doesn't always trigger
when the element is scrolled out of view if you do it too fast
*/
const interval = setInterval(() => {
if (!containerRef.current || !observerRef.current) {
return
}
observerRef.current.unobserve(containerRef.current)
observerRef.current.observe(containerRef.current)
}, 500)
return () => clearInterval(interval)
}, [])
return (
<div
className={styles.floatingBookingWidget}
data-intersecting={stickyTop}
ref={containerRef}
>
<div className={styles.floatingBackground}>
<BookingWidgetClient {...props} type={"compact"} />
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import {
getPageSettingsBookingCode,
isBookingWidgetHidden,
} from "../../../trpc/memoizedRequests"
import { FloatingBookingWidgetClient } from "./FloatingBookingWidgetClient"
import type { BookingWidgetProps } from ".."
export async function FloatingBookingWidget({
booking,
lang,
}: Omit<BookingWidgetProps, "type">) {
const isHidden = await isBookingWidgetHidden(lang)
if (isHidden) {
return null
}
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!booking.bookingCode) {
pageSettingsBookingCodePromise = getPageSettingsBookingCode(lang)
}
return (
<FloatingBookingWidgetClient
data={booking}
pageSettingsBookingCodePromise={pageSettingsBookingCodePromise}
/>
)
}

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

@@ -0,0 +1,53 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import Counter from "../Counter"
import styles from "./adult-selector.module.css"
type AdultSelectorProps = {
roomIndex: number
currentAdults: number
}
export default function AdultSelector({
roomIndex = 0,
currentAdults,
}: AdultSelectorProps) {
const name = `rooms.${roomIndex}.adults`
const intl = useIntl()
const adultsLabel = intl.formatMessage({
defaultMessage: "Adults",
})
const { setValue } = useFormContext()
function increaseAdultsCount() {
if (currentAdults < 6) {
setValue(name, currentAdults + 1, { shouldDirty: true })
}
}
function decreaseAdultsCount() {
if (currentAdults > 1) {
setValue(name, currentAdults - 1, { shouldDirty: true })
}
}
return (
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{adultsLabel}
</Caption>
<Counter
count={currentAdults}
handleOnDecrease={decreaseAdultsCount}
handleOnIncrease={increaseAdultsCount}
disableDecrease={currentAdults == 1}
disableIncrease={currentAdults == 6}
/>
</section>
)
}

View File

@@ -0,0 +1,157 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import DeprecatedSelect from "@scandic-hotels/design-system/DeprecatedSelect"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import styles from "./child-selector.module.css"
import type { Child } from "@scandic-hotels/trpc/types/child"
const ageList = [...Array(13)].map((_, i) => ({
label: i.toString(),
value: i,
}))
const childDefaultValues = { age: -1, bed: -1 }
type ChildBed = {
label: string
value: number
}
type ChildInfoSelectorProps = {
child: Child
adults: number
index: number
roomIndex: number
childrenInAdultsBed: number
}
export default function ChildInfoSelector({
child,
childrenInAdultsBed,
adults,
index = 0,
roomIndex = 0,
}: ChildInfoSelectorProps) {
const ageFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.age`
const bedFieldName = `rooms.${roomIndex}.childrenInRoom.${index}.bed`
const intl = useIntl()
const ageLabel = intl.formatMessage({
defaultMessage: "Age",
})
const bedLabel = intl.formatMessage({
defaultMessage: "Bed preference",
})
const errorMessage = intl.formatMessage({
defaultMessage: "Child age is required",
})
const { setValue, formState } = useFormContext()
function updateSelectedBed(bed: number) {
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.bed`, bed)
}
function updateSelectedAge(age: number) {
setValue(`rooms.${roomIndex}.childrenInRoom.${index}.age`, age)
const availableBedTypes = getAvailableBeds(age)
updateSelectedBed(availableBedTypes[0].value)
}
const allBedTypes: ChildBed[] = [
{
label: intl.formatMessage({
defaultMessage: "In adult's bed",
}),
value: ChildBedMapEnum.IN_ADULTS_BED,
},
{
label: intl.formatMessage({
defaultMessage: "In crib",
}),
value: ChildBedMapEnum.IN_CRIB,
},
{
label: intl.formatMessage({
defaultMessage: "In extra bed",
}),
value: ChildBedMapEnum.IN_EXTRA_BED,
},
]
function getAvailableBeds(age: number) {
let availableBedTypes: ChildBed[] = []
if (age <= 5 && (adults > childrenInAdultsBed || child.bed === 0)) {
availableBedTypes.push(allBedTypes[0])
}
if (age < 3) {
availableBedTypes.push(allBedTypes[1])
}
if (age > 2) {
availableBedTypes.push(allBedTypes[2])
}
return availableBedTypes
}
const roomErrors =
//@ts-expect-error: formState is typed with FormValues
formState.errors.rooms?.[roomIndex]?.childrenInRoom?.[index]
const ageError = roomErrors?.age
const bedError = roomErrors?.bed
return (
<>
<div key={index} className={styles.childInfoContainer}>
<div>
<DeprecatedSelect
required={true}
items={ageList}
label={ageLabel}
aria-label={ageLabel}
value={child.age ?? childDefaultValues.age}
onSelect={(key) => {
updateSelectedAge(key as number)
}}
maxHeight={180}
name={ageFieldName}
isNestedInModal={true}
/>
</div>
<div>
{child.age >= 0 ? (
<DeprecatedSelect
items={getAvailableBeds(child.age)}
label={bedLabel}
aria-label={bedLabel}
value={child.bed ?? childDefaultValues.bed}
onSelect={(key) => {
updateSelectedBed(key as number)
}}
name={bedFieldName}
isNestedInModal={true}
/>
) : null}
</div>
</div>
{roomErrors && roomErrors.message ? (
<Caption color="red" className={styles.error}>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{roomErrors.message}
</Caption>
) : null}
{ageError || bedError ? (
<Caption color="red" className={styles.error}>
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
{errorMessage}
</Caption>
) : null}
</>
)
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
justify-content: space-between;
align-items: center;
}
.captionBold {
font-weight: 600;
}
.childInfoContainer {
display: grid;
gap: var(--Spacing-x2);
grid-template-columns: 1fr 2fr;
}
.error {
display: flex;
align-items: center;
gap: var(--Spacing-x1);
}

View File

@@ -0,0 +1,84 @@
"use client"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Caption from "@scandic-hotels/design-system/Caption"
import Counter from "../Counter"
import ChildInfoSelector from "./ChildInfoSelector"
import styles from "./child-selector.module.css"
import type { Child } from "@scandic-hotels/trpc/types/child"
type ChildSelectorProps = {
roomIndex: number
currentAdults: number
currentChildren: Child[]
childrenInAdultsBed: number
}
export default function ChildSelector({
roomIndex = 0,
currentAdults,
childrenInAdultsBed,
currentChildren,
}: ChildSelectorProps) {
const intl = useIntl()
const childrenLabel = intl.formatMessage({
defaultMessage: "Children",
})
const { setValue } = useFormContext()
function increaseChildrenCount(roomIndex: number) {
if (currentChildren.length < 5) {
setValue(
`rooms.${roomIndex}.childrenInRoom.${currentChildren.length}`,
{
age: undefined,
bed: undefined,
},
{ shouldDirty: true }
)
}
}
function decreaseChildrenCount(roomIndex: number) {
if (currentChildren.length > 0) {
currentChildren.pop()
setValue(`rooms.${roomIndex}.childrenInRoom`, currentChildren, {
shouldDirty: true,
})
}
}
return (
<>
<section className={styles.container}>
<Caption color="uiTextHighContrast" type="bold">
{childrenLabel}
</Caption>
<Counter
count={currentChildren.length}
handleOnDecrease={() => {
decreaseChildrenCount(roomIndex)
}}
handleOnIncrease={() => {
increaseChildrenCount(roomIndex)
}}
disableDecrease={currentChildren.length == 0}
disableIncrease={currentChildren.length == 5}
/>
</section>
{currentChildren.map((child, index) => (
<ChildInfoSelector
roomIndex={roomIndex}
index={index}
child={child}
adults={currentAdults}
key={"child_" + index}
childrenInAdultsBed={childrenInAdultsBed}
/>
))}
</>
)
}

View File

@@ -0,0 +1,13 @@
.counterContainer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
}
.counterBtn {
width: 40px;
height: 40px;
}
.counterBtn:not([disabled]) {
box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,55 @@
"use client"
import Body from "@scandic-hotels/design-system/Body"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import styles from "./counter.module.css"
type CounterProps = {
count: number
handleOnIncrease: () => void
handleOnDecrease: () => void
disableIncrease: boolean
disableDecrease: boolean
}
export default function Counter({
count,
handleOnIncrease,
handleOnDecrease,
disableIncrease,
disableDecrease,
}: CounterProps) {
return (
<div className={styles.counterContainer}>
<Button
className={styles.counterBtn}
intent="inverted"
onClick={handleOnDecrease}
size="small"
theme="base"
variant="icon"
wrapping={true}
disabled={disableDecrease}
>
<MaterialIcon icon="remove" color="CurrentColor" />
</Button>
<Body color="baseTextHighContrast" textAlign="center">
{count}
</Body>
<Button
className={styles.counterBtn}
onClick={handleOnIncrease}
intent="inverted"
variant="icon"
theme="base"
wrapping={true}
size="small"
disabled={disableIncrease}
>
<MaterialIcon icon="add" color="CurrentColor" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,226 @@
"use client"
import { useCallback, useEffect } from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { Tooltip } from "@scandic-hotels/design-system/Tooltip"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
import { GuestsRoom } from "./GuestsRoom"
import styles from "./guests-rooms-picker.module.css"
import type { GuestsRoom as TGuestsRoom } from ".."
import type { BookingWidgetSchema } from "../Client"
const MAX_ROOMS = 4
interface GuestsRoomsPickerDialogProps {
rooms: TGuestsRoom[]
onClose: () => void
}
export default function GuestsRoomsPickerDialog({
rooms,
onClose,
}: GuestsRoomsPickerDialogProps) {
const intl = useIntl()
const { getFieldState, trigger, setValue, getValues } =
useFormContext<BookingWidgetSchema>()
const roomsValue = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const addRoomLabel = intl.formatMessage({
defaultMessage: "Add room",
})
const doneLabel = intl.formatMessage({
defaultMessage: "Done",
})
// Disable add room if booking code is either voucher or corporate cheque, or reward night is enabled
const addRoomDisabledTextForSpecialRate = getValues(SEARCH_TYPE_REDEMPTION)
? intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with reward night.",
})
: getValues("bookingCode.value")?.toLowerCase().startsWith("vo") &&
intl.formatMessage({
defaultMessage:
"Multi-room booking is not available with this booking code.",
})
const handleClose = useCallback(async () => {
const isValid = await trigger("rooms")
if (isValid) onClose()
}, [trigger, onClose])
const handleAddRoom = useCallback(() => {
setValue("rooms", [...roomsValue, { adults: 1, childrenInRoom: [] }], {
shouldValidate: true,
shouldDirty: true,
})
}, [roomsValue, setValue])
const handleRemoveRoom = useCallback(
(index: number) => {
const updatedRooms = roomsValue.filter((_, i) => i !== index)
setValue("rooms", updatedRooms, {
shouldValidate: true,
shouldDirty: true,
})
if (updatedRooms.length === 1) {
trigger("bookingCode.value")
trigger(SEARCH_TYPE_REDEMPTION)
}
},
[roomsValue, trigger, setValue]
)
// Validate rooms when they change
useEffect(() => {
const fieldState = getFieldState("rooms")
if (fieldState.invalid) trigger("rooms")
}, [roomsValue, getFieldState, trigger])
const isInvalid =
getFieldState("rooms").invalid ||
roomsValue.some((room) =>
room.childrenInRoom.some((child) => child.age === undefined)
)
const canAddRooms = rooms.length < MAX_ROOMS
return (
<>
<section className={styles.contentWrapper}>
<header className={styles.header}>
<button type="button" className={styles.close} onClick={onClose}>
<MaterialIcon icon="close" />
</button>
</header>
<div className={styles.contentContainer}>
{rooms.map((room, index) => (
<GuestsRoom
key={index}
room={room}
index={index}
onRemove={handleRemoveRoom}
/>
))}
{addRoomDisabledTextForSpecialRate ? (
<div className={styles.addRoomMobileContainer}>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
disabled
>
<MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel}
</Button>
<div className={styles.errorContainer}>
<Typography
className={styles.error}
variant="Body/Supporting text (caption)/smRegular"
>
<span>
<MaterialIcon
icon="error"
size={20}
color="Icon/Feedback/Error"
isFilled
/>
{addRoomDisabledTextForSpecialRate}
</span>
</Typography>
</div>
</div>
) : (
canAddRooms && (
<div className={styles.addRoomMobileContainer}>
<Button
className={styles.addRoomBtn}
intent="text"
variant="icon"
wrapping
theme="base"
fullWidth
onPress={handleAddRoom}
>
<MaterialIcon icon="add" color="CurrentColor" />
{addRoomLabel}
</Button>
</div>
)
)}
</div>
</section>
<footer className={styles.footer}>
{addRoomDisabledTextForSpecialRate ? (
<div className={styles.hideOnMobile}>
<Tooltip
text={addRoomDisabledTextForSpecialRate}
position="bottom"
arrow="left"
>
<Button
intent="text"
variant="icon"
wrapping
theme="base"
disabled
onPress={handleAddRoom}
>
<MaterialIcon icon="add_circle" color="CurrentColor" />
{addRoomLabel}
</Button>
</Tooltip>
</div>
) : (
canAddRooms && (
<div className={styles.hideOnMobile}>
<Button
className={styles.addRoomBtn}
intent="text"
variant="icon"
wrapping
theme="base"
onPress={handleAddRoom}
>
<MaterialIcon icon="add_circle" color="CurrentColor" />
{addRoomLabel}
</Button>
</div>
)
)}
<Button
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnDesktop}
intent="tertiary"
theme="base"
size="large"
>
{doneLabel}
</Button>
<Button
onPress={handleClose}
disabled={isInvalid}
className={styles.hideOnMobile}
intent="tertiary"
theme="base"
size="small"
>
{doneLabel}
</Button>
</footer>
</>
)
}

View File

@@ -0,0 +1,72 @@
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import AdultSelector from "../AdultSelector"
import ChildSelector from "../ChildSelector"
import styles from "../guests-rooms-picker.module.css"
import type { GuestsRoom } from "../.."
export function GuestsRoom({
room,
index,
onRemove,
}: {
room: GuestsRoom
index: number
onRemove: (index: number) => void
}) {
const intl = useIntl()
const roomLabel = intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: index + 1,
}
)
const childrenInAdultsBed = room.childrenInRoom.filter(
(child) => child.bed === ChildBedMapEnum.IN_ADULTS_BED
).length
return (
<div className={styles.roomContainer}>
<section className={styles.roomDetailsContainer}>
<Subtitle type="two" className={styles.roomHeading}>
{roomLabel}
</Subtitle>
<AdultSelector roomIndex={index} currentAdults={room.adults} />
<ChildSelector
roomIndex={index}
currentAdults={room.adults}
currentChildren={room.childrenInRoom}
childrenInAdultsBed={childrenInAdultsBed}
/>
{index !== 0 && (
<Button
intent="text"
variant="icon"
wrapping
theme="secondaryLight"
onPress={() => onRemove(index)}
size="small"
className={styles.roomActionsButton}
>
<MaterialIcon icon="delete" color="CurrentColor" />
{intl.formatMessage({
defaultMessage: "Remove room",
})}
</Button>
)}
</section>
<Divider color="Border/Divider/Subtle" />
</div>
)
}

View File

@@ -0,0 +1,246 @@
.triggerDesktop {
display: none;
}
.errorContainer {
display: flex;
padding: var(--Space-x2);
}
.error {
display: flex;
gap: var(--Space-x1);
color: var(--UI-Text-Error);
}
.pickerContainerMobile {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
bottom: 0;
left: 0;
position: fixed;
right: 0;
top: 20px;
transition: top 300ms ease;
z-index: 100;
}
.contentWrapper {
display: grid;
grid-template-areas:
"header"
"content";
grid-template-rows: var(--header-height) calc(100dvh - var(--header-height));
}
.pickerContainerDesktop {
display: none;
}
.roomContainer {
display: grid;
gap: var(--Spacing-x2);
}
.roomDetailsContainer {
display: grid;
gap: var(--Spacing-x2);
padding-bottom: var(--Spacing-x1);
}
.roomHeading {
margin-bottom: var(--Spacing-x1);
}
.btn {
background: none;
border: none;
color: var(--Text-Default);
cursor: pointer;
outline: none;
padding: 0;
width: 100%;
text-align: left;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
padding: 20px var(--Spacing-x-one-and-half) 0;
}
.guestsAndRooms {
color: var(--Text-Default);
}
.footer {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.roomContainer {
padding: var(--Spacing-x2);
}
.roomContainer:last-of-type {
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.roomActionsButton {
margin-left: auto;
color: var(--Base-Text-Accent);
}
.footer button {
width: 100%;
}
.contentWrapper
.addRoomMobileContainer
.addRoomBtn:is(:focus, :focus-visible, :focus-within),
.footer .hideOnMobile .addRoomBtn:is(:focus, :focus-visible, :focus-within),
.roomActionsButton:is(:focus, :focus-visible, :focus-within) {
outline: -webkit-focus-ring-color auto 1px;
text-decoration: none;
}
@media screen and (max-width: 1366px) {
.contentContainer {
grid-area: content;
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.header {
display: grid;
grid-area: header;
padding: var(--Spacing-x3) var(--Spacing-x2) 0;
}
.close {
background: none;
border: none;
cursor: pointer;
display: flex;
justify-self: flex-end;
padding: 0;
}
.footer {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 7.5%,
#ffffff 82.5%
);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x7);
position: sticky;
bottom: 0;
width: 100%;
}
.footer .hideOnMobile {
display: none;
}
.addRoomMobileContainer {
display: grid;
padding-bottom: calc(var(--sticky-button-height) + 20px);
}
.addRoomMobileContainer button {
width: 150px;
margin: 0 auto;
}
.addRoomMobileContainer .addRoomMobileDisabledText {
padding: var(--Spacing-x1) var(--Spacing-x2);
background-color: var(--Background-Primary);
margin: 0 var(--Spacing-x2);
border-radius: var(--Corner-radius-md);
display: flex;
gap: var(--Spacing-x1);
}
}
@media screen and (min-width: 1367px) {
.container {
height: 24px;
}
.pickerContainerMobile {
display: none;
}
.contentWrapper {
grid-template-rows: auto;
}
.roomContainer {
padding: var(--Spacing-x2) 0 0 0;
}
.roomContainer:first-of-type {
padding-top: 0;
}
.roomContainer:last-of-type {
padding-bottom: 0;
}
.contentContainer {
overflow-y: visible;
}
.triggerMobile {
display: none;
}
.triggerDesktop {
display: block;
}
.triggerDesktop > span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.pickerContainerDesktop {
--header-height: 72px;
--sticky-button-height: 140px;
background-color: var(--Main-Grey-White);
display: grid;
border-radius: var(--Corner-radius-lg);
box-shadow: var(--popup-box-shadow);
max-width: calc(100vw - 20px);
padding: var(--Spacing-x2) var(--Spacing-x3);
width: 360px;
}
.pickerContainerDesktop:focus-visible {
outline: none;
}
.header {
display: none;
}
.footer {
grid-template-columns: auto auto;
padding-top: var(--Spacing-x2);
}
.footer button {
margin-left: auto;
width: 125px;
}
.footer .hideOnDesktop,
.addRoomMobileContainer {
display: none;
}
}

View File

@@ -0,0 +1,190 @@
"use client"
import { useCallback, useEffect, useId, useState } from "react"
import {
Button,
Dialog,
DialogTrigger,
Modal,
Popover,
} from "react-aria-components"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
import { Typography } from "@scandic-hotels/design-system/Typography"
import PickerForm from "./Form"
import styles from "./guests-rooms-picker.module.css"
import type { GuestsRoom } from ".."
import type { BookingWidgetSchema } from "../Client"
export default function GuestsRoomsPickerForm({
ariaLabelledBy,
}: {
ariaLabelledBy?: string
}) {
const { trigger } = useFormContext<BookingWidgetSchema>()
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const popoverId = useId()
const checkIsDesktop = useMediaQuery("(min-width: 1367px)")
const [isDesktop, setIsDesktop] = useState(true)
const [isOpen, setIsOpen] = useState(false)
const [containerHeight, setContainerHeight] = useState(0)
const childCount = rooms[0]?.childrenInRoom.length ?? 0 // ToDo Update for multiroom later
//isOpen is the 'old state', so isOpen === true means "The modal is open and WILL be closed".
async function setOverflowClip(isOpen: boolean) {
const bodyElement = document.body
if (bodyElement) {
if (isOpen) {
bodyElement.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.
bodyElement.style.overflow = "clip !important"
}
}
if (!isOpen) {
const state = await trigger("rooms")
if (state) {
setIsOpen(isOpen)
}
}
}
useEffect(() => {
setIsDesktop(checkIsDesktop)
}, [checkIsDesktop])
const updateHeight = useCallback(() => {
// Get available space for picker to show without going beyond screen
const bookingWidget = document.getElementById("booking-widget")
let maxHeight =
window.innerHeight -
(bookingWidget?.getBoundingClientRect().bottom ?? 0) -
50
const innerContainerHeight = document
.getElementsByClassName(popoverId)[0]
?.getBoundingClientRect().height
if (
maxHeight != containerHeight &&
innerContainerHeight &&
maxHeight <= innerContainerHeight
) {
setContainerHeight(maxHeight)
} else if (
containerHeight &&
innerContainerHeight &&
maxHeight > innerContainerHeight
) {
setContainerHeight(0)
}
}, [containerHeight, popoverId])
useEffect(() => {
if (isDesktop && rooms.length > 0) {
updateHeight()
}
}, [childCount, isDesktop, updateHeight, rooms])
return isDesktop ? (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerDesktop}
triggerFn={() => {
setIsOpen(true)
}}
/>
<Popover
className={popoverId}
placement="bottom start"
offset={36}
style={containerHeight ? { overflow: "auto" } : undefined}
>
<Dialog className={styles.pickerContainerDesktop}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Popover>
</DialogTrigger>
) : (
<DialogTrigger onOpenChange={setOverflowClip} isOpen={isOpen}>
<Trigger
rooms={rooms}
className={styles.triggerMobile}
triggerFn={() => {
setIsOpen(true)
}}
ariaLabelledBy={ariaLabelledBy}
/>
<Modal>
<Dialog className={styles.pickerContainerMobile}>
{({ close }) => <PickerForm rooms={rooms} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
)
}
function Trigger({
rooms,
className,
triggerFn,
ariaLabelledBy,
}: {
rooms: GuestsRoom[]
className: string
triggerFn?: () => void
ariaLabelledBy?: string
}) {
const intl = useIntl()
const parts = [
intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms: rooms.length }
),
intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: rooms.reduce((acc, room) => acc + room.adults, 0) }
),
]
if (rooms.some((room) => room.childrenInRoom.length > 0)) {
parts.push(
intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{
totalChildren: rooms.reduce(
(acc, room) => acc + room.childrenInRoom.length,
0
),
}
)
)
}
return (
<Button
className={`${className} ${styles.btn}`}
type="button"
onPress={triggerFn}
aria-labelledby={ariaLabelledBy}
>
<Typography variant="Body/Paragraph/mdRegular">
<span className={styles.guestsAndRooms}>{parts.join(", ")}</span>
</Typography>
</Button>
)
}

View File

@@ -0,0 +1,67 @@
.complete,
.partial {
border: none;
align-items: center;
box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.16);
cursor: pointer;
display: grid;
gap: var(--Spacing-x-one-and-half);
padding: var(--Spacing-x2);
z-index: 1;
background-color: var(--Base-Surface-Primary-light-Normal);
width: 100%;
/* In some cases the lingering pressend event will trigger the */
/* webkit tap styling (but not triggering the buttons press event) */
/* To avoid this "flash" the styling is set to transparent) */
/* It is a non-standard css proprty, so shouldn't have too much of an effect on accessibility. */
-webkit-tap-highlight-color: transparent;
border-radius: 10px;
}
.complete {
grid-template-columns: 1fr 36px;
}
.partial {
grid-template-columns:
minmax(auto, 120px) min-content 1fr
auto;
}
.block {
display: block;
}
.block > * {
display: block;
text-align: start;
}
.blockLabel {
color: var(--Scandic-Red-Default);
}
.locationAndDate {
color: var(--Scandic-Grey-100);
}
.placeholder {
color: var(--Text-Interactive-Placeholder);
}
.icon {
align-items: center;
background-color: var(--Base-Button-Primary-Fill-Normal);
border-radius: 50%;
display: flex;
height: 36px;
justify-content: center;
justify-self: flex-end;
width: 36px;
}
@media screen and (min-width: 768px) {
.complete,
.partial {
display: none;
}
}

View File

@@ -0,0 +1,212 @@
"use client"
import { Button } from "react-aria-components"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { shortDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { Divider } from "@scandic-hotels/design-system/Divider"
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 useLang from "../../../hooks/useLang"
import styles from "./button.module.css"
import type { BookingWidgetSchema } from "../Client"
type BookingWidgetToggleButtonProps = {
openMobileSearch: () => void
}
export default function MobileToggleButton({
openMobileSearch,
}: BookingWidgetToggleButtonProps) {
const intl = useIntl()
const lang = useLang()
const date = useWatch<BookingWidgetSchema, "date">({ name: "date" })
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const searchTerm = useWatch<BookingWidgetSchema, "search">({ name: "search" })
const selectedSearchTerm = useWatch<BookingWidgetSchema, "selectedSearch">({
name: "selectedSearch",
})
const selectedFromDate = dt(date.fromDate)
.locale(lang)
.format(shortDateFormat[lang])
const selectedToDate = dt(date.toDate)
.locale(lang)
.format(shortDateFormat[lang])
const locationAndDateIsSet = searchTerm && date
const totalNights = dt(date.toDate).diff(dt(date.fromDate), "days")
const totalRooms = rooms.length
const totalAdults = rooms.reduce((acc, room) => {
if (room.adults) {
acc = acc + room.adults
}
return acc
}, 0)
const totalChildren = rooms.reduce((acc, room) => {
if (room.childrenInRoom) {
acc = acc + room.childrenInRoom.length
}
return acc
}, 0)
const totalNightsMsg = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights }
)
const totalAdultsMsg = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults }
)
const totalChildrenMsg = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren }
)
const totalRoomsMsg = intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms }
)
const totalDetails = [totalAdultsMsg]
if (totalChildren > 0) {
totalDetails.push(totalChildrenMsg)
}
totalDetails.push(totalRoomsMsg)
return (
<Button
className={locationAndDateIsSet ? styles.complete : styles.partial}
onPress={openMobileSearch}
>
{!locationAndDateIsSet && (
<>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>
{intl.formatMessage({
defaultMessage: "Where to?",
})}
</span>
</Typography>
<Typography variant={"Body/Paragraph/mdRegular"}>
<span className={styles.placeholder}>
{searchTerm
? searchTerm
: intl.formatMessage({
defaultMessage: "Destination",
})}
</span>
</Typography>
</span>
{/* Button can't contain HR elements */}
<Divider color="Border/Divider/Subtle" variant="vertical" />
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>{totalNightsMsg}</span>
</Typography>
<Typography variant={"Body/Paragraph/mdRegular"}>
<span className={styles.placeholder}>
{intl.formatMessage(
{
defaultMessage: "{selectedFromDate} - {selectedToDate}",
},
{
selectedFromDate,
selectedToDate,
}
)}
</span>
</Typography>
</span>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" />
</span>
</>
)}
{locationAndDateIsSet && (
<>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.blockLabel}>{selectedSearchTerm}</span>
</Typography>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.locationAndDate}>
{intl.formatMessage(
{
defaultMessage:
"{selectedFromDate} - {selectedToDate} ({totalNights}) {details}",
},
{
selectedFromDate,
selectedToDate,
totalNights: totalNightsMsg,
details: totalDetails.join(", "),
}
)}
</span>
</Typography>
</span>
<span className={styles.icon}>
<MaterialIcon icon="edit_square" color="Icon/Inverted" />
</span>
</>
)}
</Button>
)
}
export function MobileToggleButtonSkeleton() {
const intl = useIntl()
return (
<div className={styles.partial}>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smBold"}>
<span className={styles.blockLabel}>
{intl.formatMessage({
defaultMessage: "Where to?",
})}
</span>
</Typography>
<SkeletonShimmer display={"block"} height="20px" width="11ch" />
</span>
<Divider color="Border/Divider/Subtle" variant="vertical" />
<span className={styles.block}>
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.blockLabel}>
{intl.formatMessage(
{
defaultMessage:
"{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: 0 }
)}
</span>
</Typography>
<SkeletonShimmer display={"block"} height="20px" width="13ch" />
</span>
<span className={styles.icon}>
<MaterialIcon icon="search" color="Icon/Inverted" />
</span>
</div>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import { BookingWidgetFormSkeleton } from "./BookingWidgetForm"
import { MobileToggleButtonSkeleton } from "./MobileToggleButton"
import { bookingWidgetContainerVariants } from "./variant"
import styles from "./bookingWidget.module.css"
import type { BookingWidgetClientProps } from "./Client"
export function BookingWidgetSkeleton({
type = "full",
}: {
type?: BookingWidgetClientProps["type"]
}) {
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<>
<section className={classNames} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton type={type} />
</div>
</section>
</>
)
}

View File

@@ -0,0 +1,87 @@
.wrapper {
position: sticky;
z-index: var(--booking-widget-z-index);
width: 100%;
}
/* Make sure Date Picker is placed on top of other sticky/fixed components */
.wrapper:has([data-isopen="true"]) {
z-index: 100;
}
.formContainer {
display: grid;
grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: 0;
gap: var(--Spacing-x3);
height: calc(100dvh - max(var(--sitewide-alert-height), 20px));
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x2) var(--Spacing-x7);
position: fixed;
left: 0;
bottom: -100%;
transition: bottom 300ms ease;
}
.compact {
.formContainer {
border-radius: var(--Corner-radius-lg);
}
}
@media screen and (max-width: 767px) {
.formContainer {
border-radius: var(--Corner-radius-lg) var(--Corner-radius-lg) 0 0;
}
}
.wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index);
}
.wrapper[data-open="true"] .formContainer {
left: 0;
bottom: 0;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.wrapper[data-open="true"] + .backdrop {
background-color: rgba(0, 0, 0, 0.4);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: calc(var(--booking-widget-open-z-index) - 1);
}
@media screen and (min-width: 768px) {
.wrapper {
top: 0;
}
.formContainer {
display: block;
background-color: var(--Base-Surface-Primary-light-Normal);
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
height: auto;
position: static;
padding: 0;
&.compactFormContainer {
box-shadow: none;
}
}
.close {
display: none;
}
}

View File

@@ -0,0 +1,73 @@
import { Suspense } from "react"
import {
getPageSettingsBookingCode,
isBookingWidgetHidden,
} from "../../trpc/memoizedRequests"
import BookingWidgetClient from "./Client"
import { BookingWidgetSkeleton } from "./Skeleton"
import type { Lang } from "@scandic-hotels/common/constants/language"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { VariantProps } from "class-variance-authority"
import type { BookingSearchType } from "../../misc/searchType"
import type { bookingWidgetVariants } from "./BookingWidgetForm/variants"
export type GuestsRoom = {
adults: number
childrenInRoom: Child[]
}
export type BookingWidgetSearchData = {
city?: string
hotelId?: string
fromDate?: string
toDate?: string
rooms?: GuestsRoom[]
bookingCode?: string
searchType?: BookingSearchType
}
export type BookingWidgetType = VariantProps<
typeof bookingWidgetVariants
>["type"]
export type BookingWidgetProps = {
type?: BookingWidgetType
booking: BookingWidgetSearchData
lang: Lang
}
export async function BookingWidget(props: BookingWidgetProps) {
return (
<Suspense fallback={<BookingWidgetSkeleton />}>
<InternalBookingWidget {...props} />
</Suspense>
)
}
async function InternalBookingWidget({
lang,
type,
booking,
}: BookingWidgetProps) {
const isHidden = await isBookingWidgetHidden(lang)
if (isHidden) {
return null
}
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!booking.bookingCode) {
pageSettingsBookingCodePromise = getPageSettingsBookingCode(lang)
}
return (
<BookingWidgetClient
type={type}
data={booking}
pageSettingsBookingCodePromise={pageSettingsBookingCodePromise}
/>
)
}

View File

@@ -0,0 +1,29 @@
import { cva } from "class-variance-authority"
import styles from "./bookingWidget.module.css"
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
variants: {
type: {
default: "",
full: "",
compact: styles.compact,
},
},
defaultVariants: {
type: "full",
},
})
export const formContainerVariants = cva(styles.formContainer, {
variants: {
type: {
default: "",
full: "",
compact: styles.compactFormContainer,
},
},
defaultVariants: {
type: "full",
},
})

View File

@@ -0,0 +1,69 @@
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import styles from "./modalContent.module.css"
import type { ReactNode } from "react"
interface ModalContentProps {
title?: string
content: ReactNode
primaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
isLoading?: boolean
disabled?: boolean
} | null
secondaryAction: {
label: string
onClick: () => void
intent?: "primary" | "secondary" | "text"
} | null
onClose?: () => void
}
export function ModalContentWithActions({
title,
content,
primaryAction,
secondaryAction,
onClose,
}: ModalContentProps) {
return (
<>
{title && (
<header className={styles.header}>
<Subtitle>{title}</Subtitle>
<button onClick={onClose} type="button" className={styles.close}>
<MaterialIcon icon="close" color="Icon/Interactive/Placeholder" />
</button>
</header>
)}
<div className={styles.content}>{content}</div>
<footer className={styles.footer}>
{secondaryAction && (
<Button
theme="base"
intent={secondaryAction.intent ?? "text"}
color="burgundy"
onClick={secondaryAction.onClick}
>
{secondaryAction.label}
</Button>
)}
{primaryAction && (
<Button
theme="base"
intent={primaryAction.intent ?? "secondary"}
onClick={primaryAction.onClick}
disabled={primaryAction.isLoading || primaryAction.disabled}
>
{primaryAction.label}
</Button>
)}
</footer>
</>
)
}

View File

@@ -0,0 +1,45 @@
.content {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
padding: var(--Spacing-x1) var(--Spacing-x3) var(--Spacing-x4);
max-height: 70vh;
overflow-y: auto;
width: 100%;
}
.header {
display: flex;
justify-content: space-between;
width: 100%;
padding: var(--Spacing-x3) var(--Spacing-x3) var(--Spacing-x1)
var(--Spacing-x3);
}
.footer {
display: flex;
justify-content: space-between;
width: 100%;
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x3);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
display: flex;
align-items: center;
padding: 0;
justify-content: center;
top: 20px;
right: 20px;
}
@media screen and (min-width: 768px) {
.content {
width: 640px;
max-width: 100%;
}
}

View File

@@ -0,0 +1,215 @@
"use client"
// TODO this is duplicated from scandic-web
import { cx } from "class-variance-authority"
import { AnimatePresence, motion } from "motion/react"
import { type PropsWithChildren, useEffect, useState } from "react"
import {
Dialog,
DialogTrigger,
Modal as AriaModal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Preamble from "@scandic-hotels/design-system/Preamble"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import {
type AnimationState,
AnimationStateEnum,
type InnerModalProps,
type ModalProps,
} from "./modal"
import { fade, slideInOut } from "./motionVariants"
import { modalContentVariants } from "./variants"
import styles from "./modal.module.css"
const MotionOverlay = motion.create(ModalOverlay)
const MotionModal = motion.create(AriaModal)
function InnerModal({
animation,
onAnimationComplete = () => undefined,
setAnimation,
onToggle,
isOpen,
children,
title,
subtitle,
withActions,
hideHeader,
className,
}: PropsWithChildren<InnerModalProps>) {
const intl = useIntl()
const contentClassNames = modalContentVariants({
withActions: withActions,
})
function modalStateHandler(newAnimationState: AnimationState) {
setAnimation((currentAnimationState) =>
newAnimationState === AnimationStateEnum.hidden &&
currentAnimationState === AnimationStateEnum.hidden
? AnimationStateEnum.unmounted
: currentAnimationState
)
if (newAnimationState === AnimationStateEnum.visible) {
onAnimationComplete()
}
}
function onOpenChange(state: boolean) {
onToggle!(state)
}
return (
<MotionOverlay
animate={animation}
className={styles.overlay}
initial="hidden"
isDismissable
// TODO: Enabling this causes the modal to never unmount.
// Seems to be an issue with react-aria-components, see https://github.com/adobe/react-spectrum/issues/7563.
// Exit animations can probably be fixed by rewriting to a solution similar to
// https://react-spectrum.adobe.com/react-aria/examples/framer-modal-sheet.html.
// isExiting={animation === AnimationStateEnum.hidden}
onAnimationComplete={modalStateHandler}
variants={fade}
isOpen={isOpen}
onOpenChange={onOpenChange}
>
<MotionModal
className={cx(styles.modal, className)}
variants={slideInOut}
animate={animation}
initial="hidden"
>
<Dialog
className={styles.dialog}
aria-label={intl.formatMessage({
defaultMessage: "Dialog",
})}
>
{({ close }) => (
<>
{!hideHeader && (
<header
className={`${styles.header} ${!subtitle ? styles.verticalCenter : ""}`}
>
<div>
{title && (
<Subtitle type="one" color="uiTextHighContrast">
{title}
</Subtitle>
)}
{subtitle && (
<Preamble asChild>
<span>{subtitle}</span>
</Preamble>
)}
</div>
<button
onClick={close}
type="button"
className={styles.close}
>
<MaterialIcon icon="close" color="Icon/Feedback/Neutral" />
</button>
</header>
)}
<div className={contentClassNames}>{children}</div>
</>
)}
</Dialog>
</MotionModal>
</MotionOverlay>
)
}
export default function Modal({
onAnimationComplete = () => undefined,
trigger,
isOpen,
onToggle,
onOpenChange,
title,
subtitle,
children,
withActions = false,
hideHeader = false,
className = "",
}: PropsWithChildren<ModalProps>) {
const [animation, setAnimation] = useState<AnimationState>(
AnimationStateEnum.visible
)
useEffect(() => {
if (typeof isOpen === "boolean") {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
}
if (isOpen === undefined) {
setAnimation(AnimationStateEnum.unmounted)
}
}, [isOpen])
const shouldRender = isOpen || animation !== AnimationStateEnum.unmounted
if (!trigger) {
return (
<AnimatePresence>
{shouldRender && (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
onToggle={onToggle}
isOpen={isOpen}
title={title}
subtitle={subtitle}
withActions={withActions}
hideHeader={hideHeader}
className={className}
>
{children}
</InnerModal>
)}
</AnimatePresence>
)
}
return (
<DialogTrigger
onOpenChange={(isOpen) => {
setAnimation(
isOpen ? AnimationStateEnum.visible : AnimationStateEnum.hidden
)
onOpenChange?.(isOpen)
}}
>
{trigger}
<AnimatePresence>
{shouldRender && (
<InnerModal
onAnimationComplete={onAnimationComplete}
animation={animation}
setAnimation={setAnimation}
title={title}
subtitle={subtitle}
withActions={withActions}
className={className}
>
{children}
</InnerModal>
)}
</AnimatePresence>
</DialogTrigger>
)
}

View File

@@ -0,0 +1,95 @@
.overlay {
background: rgba(0, 0, 0, 0.5);
height: var(--visual-viewport-height);
position: fixed;
top: 0;
left: 0;
width: 100vw;
z-index: var(--default-modal-overlay-z-index);
}
.modal {
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
box-shadow: var(--modal-box-shadow);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
z-index: var(--default-modal-z-index);
}
.dialog {
display: flex;
flex-direction: column;
/* For removing focus outline when modal opens first time */
outline: 0 none;
max-height: 100dvh;
}
.header {
--button-dimension: 32px;
box-sizing: content-box;
display: flex;
align-items: flex-start;
min-height: var(--button-dimension);
position: relative;
padding: var(--Spacing-x3) var(--Spacing-x3) 0;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x2);
overflow: auto;
}
.contentWithActions {
padding: 0;
}
.contentWithoutActions {
padding: 0 var(--Spacing-x3) var(--Spacing-x4);
}
.close {
background: none;
border: none;
cursor: pointer;
position: absolute;
right: var(--Spacing-x2);
width: var(--button-dimension);
height: var(--button-dimension);
display: flex;
align-items: center;
padding: 0;
justify-content: center;
}
.verticalCenter {
align-items: center;
}
@media screen and (min-width: 768px) {
.overlay {
display: flex;
justify-content: center;
align-items: center;
}
.modal {
left: auto;
bottom: auto;
width: auto;
border-radius: var(--Corner-radius-md);
max-width: var(--max-width-page);
}
.dialog {
max-height: 90dvh;
}
}

View File

@@ -0,0 +1,36 @@
import type { Dispatch, JSX, SetStateAction } from "react"
export enum AnimationStateEnum {
unmounted = "unmounted",
hidden = "hidden",
visible = "visible",
}
export type AnimationState = keyof typeof AnimationStateEnum
export type ModalProps = {
onAnimationComplete?: () => void
title?: string
subtitle?: string
withActions?: boolean
hideHeader?: boolean
className?: string
} & (
| {
trigger: JSX.Element
isOpen?: never
onToggle?: never
onOpenChange?: (open: boolean) => void
}
| {
trigger?: never
isOpen: boolean
onToggle: (open: boolean) => void
onOpenChange?: never
}
)
export type InnerModalProps = Omit<ModalProps, "trigger"> & {
animation: AnimationState
setAnimation: Dispatch<SetStateAction<AnimationState>>
}

View File

@@ -0,0 +1,23 @@
export const fade = {
hidden: {
opacity: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
transition: { duration: 0.4, ease: "easeInOut" },
},
}
export const slideInOut = {
hidden: {
opacity: 0,
y: 32,
transition: { duration: 0.4, ease: "easeInOut" },
},
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeInOut" },
},
}

View File

@@ -0,0 +1,17 @@
import { cva } from "class-variance-authority"
import styles from "./modal.module.css"
const config = {
variants: {
withActions: {
true: styles.contentWithActions,
false: styles.contentWithoutActions,
},
},
defaultVariants: {
withActions: false,
},
} as const
export const modalContentVariants = cva(styles.content, config)

View File

@@ -0,0 +1,17 @@
"use client"
import { useParams } from "next/navigation"
import { Lang } from "@scandic-hotels/common/constants/language"
import { languageSchema } from "@scandic-hotels/common/utils/languages"
/**
* A hook to get the current lang from the URL
*/
export default function useLang() {
const { lang } = useParams<{
lang: Lang
}>()
const parsedLang = languageSchema.safeParse(lang)
return parsedLang.success ? parsedLang.data : Lang.en
}

View File

@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from "react"
import { logger } from "@scandic-hotels/common/logger"
import {
type AutoCompleteLocation,
autoCompleteLocationSchema,
} from "@scandic-hotels/trpc/routers/autocomplete/schema"
import useLang from "./useLang"
export function useSearchHistory() {
const MAX_HISTORY_LENGTH = 5
const KEY = useSearchHistoryKey()
const getHistoryFromLocalStorage = useCallback((): AutoCompleteLocation[] => {
const stringifiedHistory = localStorage.getItem(KEY)
try {
const parsedHistory = JSON.parse(stringifiedHistory ?? "[]")
if (!Array.isArray(parsedHistory)) {
throw new Error("Invalid search history format")
}
const existingHistory = parsedHistory.map((item) =>
autoCompleteLocationSchema.parse(item)
)
return existingHistory
} catch (error) {
logger.error("Failed to parse search history:", error)
localStorage.removeItem(KEY)
return []
}
}, [KEY])
function updateSearchHistory(newItem: AutoCompleteLocation) {
const existingHistory = getHistoryFromLocalStorage()
if (!autoCompleteLocationSchema.safeParse(newItem).success) {
return existingHistory
}
const oldSearchHistoryWithoutTheNew = existingHistory.filter(
(h) => h.type !== newItem.type || h.id !== newItem.id
)
const updatedSearchHistory = [
newItem,
...oldSearchHistoryWithoutTheNew.slice(0, MAX_HISTORY_LENGTH - 1),
]
localStorage.setItem(KEY, JSON.stringify(updatedSearchHistory))
return updatedSearchHistory
}
const [searchHistory, setSearchHistory] = useState<AutoCompleteLocation[]>([])
useEffect(() => {
setSearchHistory(getHistoryFromLocalStorage())
}, [KEY, getHistoryFromLocalStorage])
function clearHistory() {
localStorage.removeItem(KEY)
setSearchHistory([])
}
function insertSearchHistoryItem(
newItem: AutoCompleteLocation
): AutoCompleteLocation[] {
const updatedHistory = updateSearchHistory(newItem)
setSearchHistory(updatedHistory)
return updatedHistory
}
return {
searchHistory,
insertSearchHistoryItem,
clearHistory,
}
}
function useSearchHistoryKey() {
const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
const lang = useLang()
return `${SEARCH_HISTORY_LOCALSTORAGE_KEY}-${lang}`.toLowerCase()
}

View File

@@ -1,28 +0,0 @@
import { Lang } from "@scandic-hotels/common/constants/language"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { env } from "../env/server"
import { serverClient } from "./trpc"
const tempEnv = env.FOO
export async function Temp() {
const caller = await serverClient()
const destinations = await caller.autocomplete.destinations({
lang: Lang.en,
includeTypes: ["hotels"],
query: "Stockholm",
})
const hotel = destinations.hits.hotels[0].name
return (
<div>
<Typography variant="Body/Lead text">
<p>Tjena {tempEnv}</p>
</Typography>
<Typography variant="Body/Lead text">
<p>Something fetched by tRPC: {hotel}</p>
</Typography>
</div>
)
}

View File

@@ -0,0 +1,4 @@
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
export const bookingSearchTypes = [SEARCH_TYPE_REDEMPTION] as const
export type BookingSearchType = (typeof bookingSearchTypes)[number]

View File

@@ -0,0 +1,19 @@
// TODO should probably not need to export this outside package after moving entire booking flow
import { create } from "zustand"
export enum BookingCodeFilterEnum {
Discounted = "Discounted",
All = "All",
}
interface BookingCodeFilterState {
activeCodeFilter: keyof typeof BookingCodeFilterEnum
setFilter: (filter: BookingCodeFilterEnum) => void
}
export const useBookingCodeFilterStore = create<BookingCodeFilterState>(
(set) => ({
activeCodeFilter: BookingCodeFilterEnum.Discounted,
setFilter: (filter) => set({ activeCodeFilter: filter }),
})
)

View File

@@ -0,0 +1,26 @@
"use client"
import { createContext, useContext } from "react"
export type TrackingFunctions = {
trackBookingSearchClick: (
searchTerm: string,
searchType: "hotel" | "destination"
) => void
}
export const TrackingContext = createContext<TrackingFunctions | undefined>(
undefined
)
export const useTrackingContext = (): TrackingFunctions => {
const context = useContext(TrackingContext)
if (!context) {
throw new Error(
"useTrackingContext must be used within a BookingFlowTrackingProvider. Did you forget to use the provider in the consuming app?"
)
}
return context
}

View File

@@ -0,0 +1,48 @@
import { cache } from "react"
import { serverClient } from "../trpc"
import type { Lang } from "@scandic-hotels/common/constants/language"
export const getSiteConfig = cache(async function getMemoizedSiteConfig(
lang: Lang
) {
const caller = await serverClient()
return caller.contentstack.base.siteConfig({ lang })
})
export const getPageSettings = cache(async function getMemoizedPageSettings(
lang: Lang
) {
const caller = await serverClient()
return caller.contentstack.pageSettings.get({ lang })
})
export const isBookingWidgetHidden = cache(
async function isMemoizedBookingWidgetHidden(lang: Lang) {
const [pageSettingsResult, siteConfigResults] = await Promise.allSettled([
getPageSettings(lang),
getSiteConfig(lang),
])
const pageSettings =
pageSettingsResult.status === "fulfilled"
? pageSettingsResult.value
: null
const siteConfig =
siteConfigResults.status === "fulfilled" ? siteConfigResults.value : null
const hideFromPageSettings =
pageSettings?.page.settings.hide_booking_widget ?? false
const hideFromSiteConfig = siteConfig?.bookingWidgetDisabled ?? false
return hideFromPageSettings || hideFromSiteConfig
}
)
export const getPageSettingsBookingCode = cache(
async function getMemoizedPageSettingsBookingCode(lang: Lang) {
const pageSettings = await getPageSettings(lang)
return pageSettings?.page.settings.booking_code ?? ""
}
)

View File

@@ -0,0 +1 @@
export type NextSearchParams = { [key: string]: string | string[] | undefined }

View File

@@ -0,0 +1,556 @@
import { describe, expect, test } from "vitest"
import { z } from "zod"
import { parseSearchParams, serializeSearchParams } from "./searchParams"
describe("Parse search params", () => {
test("with flat values", () => {
const searchParams = getSearchParams("city=stockholm&hotel=123")
const result = parseSearchParams(searchParams)
expect(result).toEqual({
city: "stockholm",
hotel: "123",
})
})
test("with comma separated array", () => {
const searchParams = getSearchParams(
"filter=1831,1383,971,1607&packages=ABC,XYZ"
)
const result = parseSearchParams(searchParams, {
typeHints: {
packages: "COMMA_SEPARATED_ARRAY",
filter: "COMMA_SEPARATED_ARRAY",
},
})
expect(result).toEqual({
filter: ["1831", "1383", "971", "1607"],
packages: ["ABC", "XYZ"],
})
})
test("with comma separated array with single value", () => {
const searchParams = getSearchParams(
"details.packages=ABC&filter=1831&rooms[0].packages=XYZ"
)
const result = parseSearchParams(searchParams, {
typeHints: {
filter: "COMMA_SEPARATED_ARRAY",
packages: "COMMA_SEPARATED_ARRAY",
},
})
expect(result).toEqual({
filter: ["1831"],
details: {
packages: ["ABC"],
},
rooms: [
{
packages: ["XYZ"],
},
],
})
})
test("with nested object", () => {
const searchParams = getSearchParams(
"room.details.adults=1&room.ratecode=ABC&room.details.children=2&room.filters=1,2,3,4"
)
const result = parseSearchParams(searchParams, {
typeHints: {
filters: "COMMA_SEPARATED_ARRAY",
},
})
expect(result).toEqual({
room: {
ratecode: "ABC",
filters: ["1", "2", "3", "4"],
details: {
adults: "1",
children: "2",
},
},
})
})
test("with array of objects", () => {
const searchParams = getSearchParams(
"room[0].adults=1&room[0].ratecode=ABC&room[1].adults=2&room[1].ratecode=DEF"
)
const result = parseSearchParams(searchParams)
expect(result).toEqual({
room: [
{
adults: "1",
ratecode: "ABC",
},
{
adults: "2",
ratecode: "DEF",
},
],
})
})
test("with array defined out of order", () => {
const searchParams = getSearchParams("room[1].adults=1&room[0].adults=2")
const result = parseSearchParams(searchParams)
expect(result).toEqual({
room: [
{
adults: "2",
},
{
adults: "1",
},
],
})
})
test("with nested array of objects", () => {
const searchParams = getSearchParams(
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
)
const result = parseSearchParams(searchParams)
expect(result).toEqual({
room: [
{
adults: "1",
child: [
{
age: "2",
},
],
},
{
adults: "2",
child: [
{
age: "3",
},
],
},
],
})
})
test("can handle array syntax with primitive values", () => {
const searchParams = getSearchParams("room[1]=1&room[0]=2")
const result = parseSearchParams(searchParams)
expect(result).toEqual({
room: ["2", "1"],
})
})
test("can rename search param keys", () => {
const searchParams = getSearchParams(
"city=stockholm&hotel=123&room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
)
const result = parseSearchParams(searchParams, {
keyRenameMap: {
hotel: "hotelId",
room: "rooms",
age: "childAge",
},
})
expect(result).toEqual({
city: "stockholm",
hotelId: "123",
rooms: [
{
adults: "1",
child: [
{
childAge: "2",
},
],
},
{
adults: "2",
child: [
{
childAge: "3",
},
],
},
],
})
})
test("with schema validation", () => {
const searchParams = getSearchParams(
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
)
const result = parseSearchParams(searchParams, {
schema: z.object({
room: z.array(
z.object({
adults: z.string(),
child: z.array(
z.object({
age: z.string(),
})
),
})
),
}),
})
expect(result).toEqual({
room: [
{
adults: "1",
child: [
{
age: "2",
},
],
},
{
adults: "2",
child: [
{
age: "3",
},
],
},
],
})
})
test("throws when schema validation fails", () => {
const searchParams = getSearchParams("city=stockholm")
expect(() =>
parseSearchParams(searchParams, {
schema: z.object({
city: z.string(),
hotel: z.string(),
}),
})
).toThrow()
})
test("with value coercion", () => {
const searchParams = getSearchParams(
"room[0].adults=1&room[0].enabled=true"
)
const result = parseSearchParams(searchParams, {
schema: z.object({
room: z.array(
z.object({
adults: z.coerce.number(),
enabled: z.coerce.boolean(),
})
),
}),
})
expect(result).toEqual({
room: [
{
adults: 1,
enabled: true,
},
],
})
})
})
describe("Serialize search params", () => {
test("with flat values", () => {
const obj = {
city: "stockholm",
hotel: "123",
}
const result = serializeSearchParams(obj)
expect(decodeURIComponent(result.toString())).toEqual(
"city=stockholm&hotel=123"
)
})
test("with comma separated array", () => {
const obj = {
filter: ["1831", "1383", "971", "1607"],
}
const result = serializeSearchParams(obj, {
typeHints: {
filter: "COMMA_SEPARATED_ARRAY",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"filter=1831,1383,971,1607"
)
})
test("with comma separated array with single value", () => {
const obj = {
details: {
packages: ["ABC"],
},
filter: ["1831"],
rooms: [
{
packages: ["XYZ"],
},
],
}
const result = serializeSearchParams(obj, {
typeHints: {
filter: "COMMA_SEPARATED_ARRAY",
packages: "COMMA_SEPARATED_ARRAY",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"details.packages=ABC&filter=1831&rooms[0].packages=XYZ"
)
})
test("with nested object", () => {
const obj = {
room: {
ratecode: "ABC",
filters: ["1", "2", "3", "4"],
details: {
adults: "1",
children: "2",
},
},
}
const result = serializeSearchParams(obj, {
typeHints: {
filters: "COMMA_SEPARATED_ARRAY",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"room.ratecode=ABC&room.filters=1,2,3,4&room.details.adults=1&room.details.children=2"
)
})
test("with array of objects", () => {
const obj = {
room: [
{
adults: "1",
ratecode: "ABC",
},
{
adults: "2",
ratecode: "DEF",
},
],
}
const result = serializeSearchParams(obj)
expect(decodeURIComponent(result.toString())).toEqual(
"room[0].adults=1&room[0].ratecode=ABC&room[1].adults=2&room[1].ratecode=DEF"
)
})
test("with nested array of objects", () => {
const obj = {
room: [
{
adults: "1",
child: [
{
age: "2",
},
],
},
{
adults: "2",
child: [
{
age: "3",
},
],
},
],
}
const result = serializeSearchParams(obj)
expect(decodeURIComponent(result.toString())).toEqual(
"room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
)
})
test("can handle array syntax with primitive values", () => {
const obj = {
room: ["2", "1"],
}
const result = serializeSearchParams(obj)
expect(decodeURIComponent(result.toString())).toEqual("room[0]=2&room[1]=1")
})
test("can rename search param keys", () => {
const obj = {
city: "stockholm",
hotelId: "123",
rooms: [
{
adults: "1",
child: [
{
childAge: "2",
},
],
},
{
adults: "2",
child: [
{
childAge: "3",
},
],
},
],
}
const result = serializeSearchParams(obj, {
keyRenameMap: {
hotelId: "hotel",
rooms: "room",
childAge: "age",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"city=stockholm&hotel=123&room[0].adults=1&room[0].child[0].age=2&room[1].adults=2&room[1].child[0].age=3"
)
})
test("with initial search params", () => {
const initialSearchParams = new URLSearchParams("city=stockholm&hotel=123")
const obj = {
hotel: "456",
filter: ["1831", "1383"],
packages: ["ABC"],
}
const result = serializeSearchParams(obj, {
initialSearchParams,
typeHints: {
packages: "COMMA_SEPARATED_ARRAY",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"city=stockholm&hotel=456&filter[0]=1831&filter[1]=1383&packages=ABC"
)
})
test("with initial search params and removing existing parameter", () => {
const initialSearchParams = new URLSearchParams(
"city=stockholm&hotel=123&filters=123,456,789"
)
const obj = {
hotel: null,
filters: ["123", "789"],
}
const result = serializeSearchParams(obj, {
initialSearchParams,
typeHints: {
filters: "COMMA_SEPARATED_ARRAY",
},
})
expect(decodeURIComponent(result.toString())).toEqual(
"city=stockholm&filters=123,789"
)
})
test("with initial search params and removing values in array", () => {
const initialSearchParams = new URLSearchParams(
"room[0].adults=1&room[0].rateCode=ABC&room[1].adults=2&room[2].adults=3&room[3].adults=4"
)
const obj = {
room: [
{
adults: 1,
rateCode: null,
},
{
adults: 3,
},
{
adults: 4,
},
],
}
const result = serializeSearchParams(obj, {
initialSearchParams,
})
expect(decodeURIComponent(result.toString())).toEqual(
"room[0].adults=1&room[1].adults=3&room[2].adults=4"
)
})
})
describe("Parse serialized search params", () => {
test("should return the same object", () => {
const obj = {
city: "stockholm",
hotelId: "123",
filter: ["1831", "1383", "971", "1607"],
details: {
packages: ["ABC"],
},
rooms: [
{
packages: ["XYZ"],
},
],
}
const searchParams = serializeSearchParams(obj, {
keyRenameMap: {
hotelId: "hotel",
rooms: "room",
},
typeHints: {
filter: "COMMA_SEPARATED_ARRAY",
packages: "COMMA_SEPARATED_ARRAY",
},
})
const searchParamsObj = searchParamsToObject(searchParams)
const result = parseSearchParams(searchParamsObj, {
keyRenameMap: {
hotel: "hotelId",
room: "rooms",
},
typeHints: {
filter: "COMMA_SEPARATED_ARRAY",
packages: "COMMA_SEPARATED_ARRAY",
},
})
expect(result).toEqual(obj)
})
})
// Simulates what Next does behind the scenes for search params
const getSearchParams = (input: string) => {
const searchParams = new URLSearchParams(input)
return searchParamsToObject(searchParams)
}
const searchParamsToObject = (searchParams: URLSearchParams) => {
const obj: Record<string, any> = {}
for (const [key, value] of searchParams.entries()) {
obj[key] = value
}
return obj
}

View File

@@ -0,0 +1,230 @@
import type { z } from "zod"
import type { NextSearchParams } from "../types"
type ParseOptions<T extends z.ZodRawShape> = {
keyRenameMap?: Record<string, string>
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
schema?: z.ZodObject<T>
}
type ParseOptionsWithSchema<T extends z.ZodRawShape> = ParseOptions<T> & {
schema: z.ZodObject<T>
}
// This ensures that the return type is correct when a schema is provided
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options: ParseOptionsWithSchema<T>
): z.infer<typeof options.schema>
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options?: ParseOptions<T>
): Record<string, any>
/**
* Parses URL search parameters into a structured object.
* This function can handle nested objects, arrays, and type validation/transformation using Zod schema.
*
* @param searchParams - The object to parse
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
* @param options.typeHints - Optional type hints to force certain keys to be treated as arrays
* @param options.schema - Pass a Zod schema to validate and transform the parsed search parameters and get a typed return value
*
* Supported formats:
* - Objects: `user.name=John&user.age=30`
* - Arrays: `tags[0]=javascript&tags[1]=typescript`
* - Arrays of objects: `tags[0].name=javascript&tags[0].age=30`
* - Nested arrays: `tags[0].languages[0]=javascript&tags[0].languages[1]=typescript`
* - Comma-separated arrays: `tags=javascript,typescript`
*
* For comma-separated arrays you must use the `typeHints`
* option to inform the parser that the key should be treated as an array.
*/
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options?: ParseOptions<T>
) {
const entries = Object.entries(searchParams)
const buildObject = getBuilder(options || {})
const resultObject: Record<string, any> = {}
for (const [key, value] of entries) {
const paths = key.split(".")
if (Array.isArray(value)) {
throw new Error(
`Arrays from duplicate keys (?a=1&a=2) are not yet supported.`
)
}
if (!value) {
continue
}
buildObject(resultObject, paths, value)
}
if (options?.schema) {
return options.schema.parse(resultObject)
}
return resultObject
}
// Use a higher-order function to avoid passing the options
// object every time we recursively call the builder
function getBuilder<T extends z.ZodRawShape>(options: ParseOptions<T>) {
const keyRenameMap = options.keyRenameMap || {}
const typeHints = options.typeHints || {}
return function buildNestedObject(
obj: Record<string, any>,
paths: string[],
value: string
) {
if (paths.length === 0) return
const path = paths[0]
const remainingPaths = paths.slice(1)
// Extract the key name and optional array index
const match = path.match(/^([^\[]+)(?:\[(\d+)\])?$/)
if (!match) return
const key = keyRenameMap[match[1]] || match[1]
const index = match[2] ? parseInt(match[2]) : null
const forceCommaSeparatedArray = typeHints[key] === "COMMA_SEPARATED_ARRAY"
const hasIndex = index !== null
// If we've reached the last path, set the value
if (remainingPaths.length === 0) {
// This is either an array or a value that is
// forced to be an array by the typeHints
if (hasIndex || forceCommaSeparatedArray) {
if (isNotArray(obj[key])) obj[key] = []
if (!hasIndex || forceCommaSeparatedArray) {
obj[key] = value.split(",")
return
}
obj[key][index] = value
return
}
obj[key] = value
return
}
if (hasIndex) {
// If the key is an array, ensure array and element at index exists
if (isNotArray(obj[key])) obj[key] = []
if (!obj[key][index]) obj[key][index] = {}
buildNestedObject(obj[key][index], remainingPaths, value)
return
}
// Otherwise, it should be an object
if (!obj[key]) obj[key] = {}
buildNestedObject(obj[key], remainingPaths, value)
}
}
function isNotArray(value: any) {
return !value || typeof value !== "object" || !Array.isArray(value)
}
type SerializeOptions = {
keyRenameMap?: Record<string, string>
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
initialSearchParams?: URLSearchParams
}
/**
* Serializes an object into URL search parameters.
*
* @param obj - The object to serialize
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
* @param options.typeHints - Optional type hints to force certain keys to be treated as comma separated arrays
* @param options.initialSearchParams - Optional initial URL search parameters to merge with the serialized object
* @returns URLSearchParams - The serialized URL search parameters
*
* To force a key to be removed when merging with initialSearchParams, set its value to `null` in the object.
* Arrays are not merged, they will always replace existing values.
*/
export function serializeSearchParams(
obj: Record<string, any>,
options?: SerializeOptions
): URLSearchParams {
const params = new URLSearchParams(options?.initialSearchParams)
const keyRenameMap = options?.keyRenameMap || {}
const typeHints = options?.typeHints || {}
function buildParams(obj: unknown, prefix: string) {
if (obj === null || obj === undefined) return
if (!isRecord(obj)) {
params.set(prefix, String(obj))
return
}
for (const key in obj) {
const value = obj[key]
const renamedKey = keyRenameMap[key] || key
const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
if (value === null) {
params.delete(paramKey)
continue
}
if (Array.isArray(value)) {
if (typeHints[key] === "COMMA_SEPARATED_ARRAY") {
params.set(paramKey, value.join(","))
continue
}
// If an array value already exists (from initialSearchParams),
// we need to first remove it since it can't be merged.
deleteAllKeysStartingWith(params, renamedKey)
value.forEach((item, index) => {
const indexedKey = `${renamedKey}[${index}]`
const arrayKey = prefix ? `${prefix}.${indexedKey}` : indexedKey
buildParams(item, arrayKey)
})
continue
}
if (typeof value === "object" && value !== null) {
buildParams(value, paramKey)
continue
}
params.set(paramKey, String(value))
}
}
buildParams(obj, "")
return params
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function deleteAllKeysStartingWith(searchParams: URLSearchParams, key: string) {
const keysToDelete = Array.from(searchParams.keys()).filter(
(k) => k.startsWith(key) || k === key
)
for (const k of keysToDelete) {
searchParams.delete(k)
}
}

View File

@@ -0,0 +1,287 @@
import { z } from "zod"
import { logger } from "@scandic-hotels/common/logger"
import { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import { type BookingWidgetSearchData } from "../components/BookingWidget"
import { type BookingSearchType, bookingSearchTypes } from "../misc/searchType"
import { parseSearchParams, serializeSearchParams } from "./searchParams"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
import type { NextSearchParams } from "../types"
type PartialRoom = { rooms?: Partial<Room>[] }
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
hotelId: string
} & PartialRoom
export function searchParamsToRecord(searchParams: URLSearchParams) {
return Object.fromEntries(searchParams.entries())
}
const keyRenameMap = {
room: "rooms",
ratecode: "rateCode",
counterratecode: "counterRateCode",
roomtype: "roomTypeCode",
fromdate: "fromDate",
todate: "toDate",
hotel: "hotelId",
child: "childrenInRoom",
searchtype: "searchType",
}
const typeHints = {
filters: "COMMA_SEPARATED_ARRAY",
packages: "COMMA_SEPARATED_ARRAY",
} as const
const adultsSchema = z.coerce.number().min(1).max(6).catch(0)
const childAgeSchema = z.coerce.number().catch(-1)
const childBedSchema = z.coerce.number().catch(-1)
const searchTypeSchema = z.enum(bookingSearchTypes).optional().catch(undefined)
export function parseBookingWidgetSearchParams(
searchParams: NextSearchParams
): BookingWidgetSearchData {
try {
const result = parseSearchParams(searchParams, {
keyRenameMap,
typeHints,
schema: z.object({
city: z.string().optional(),
hotelId: z.string().optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
bookingCode: z.string().optional(),
searchType: searchTypeSchema,
rooms: z
.array(
z.object({
adults: adultsSchema,
childrenInRoom: z
.array(
z.object({
bed: childBedSchema,
age: childAgeSchema,
})
)
.optional()
.default([]),
})
)
.optional(),
}),
})
return result
} catch (error) {
logger.error("[URL] Error parsing search params for booking widget:", error)
return {}
}
}
export function parseSelectHotelSearchParams(
searchParams: NextSearchParams
): SelectHotelBooking | null {
try {
const result = parseSearchParams(searchParams, {
keyRenameMap,
typeHints,
schema: z.object({
city: z.string().optional(),
hotelId: z.string().optional(),
fromDate: z.string(),
toDate: z.string(),
bookingCode: z.string().optional(),
searchType: searchTypeSchema,
rooms: z.array(
z.object({
adults: adultsSchema,
childrenInRoom: z
.array(
z.object({
bed: childBedSchema,
age: childAgeSchema,
})
)
.optional(),
})
),
}),
})
return result
} catch (error) {
logger.error("[URL] Error parsing search params for select hotel:", error)
return null
}
}
export function parseSelectRateSearchParams(
searchParams: NextSearchParams
): SelectRateBooking | null {
try {
const result = parseSearchParams(searchParams, {
keyRenameMap,
typeHints,
schema: z.object({
city: z.string().optional(),
hotelId: z.string(),
fromDate: z.string(),
toDate: z.string(),
searchType: searchTypeSchema,
bookingCode: z.string().optional(),
rooms: z.array(
z.object({
adults: adultsSchema,
bookingCode: z.string().optional(),
counterRateCode: z.string().optional(),
rateCode: z.string().optional(),
roomTypeCode: z.string().optional(),
packages: z
.array(
z.nativeEnum({
...BreakfastPackageEnum,
...RoomPackageCodeEnum,
})
)
.optional(),
childrenInRoom: z
.array(
z.object({
bed: childBedSchema,
age: childAgeSchema,
})
)
.optional(),
})
),
}),
})
return result
} catch (error) {
logger.error("[URL] Error parsing search params for select rate:", error)
return null
}
}
export function parseDetailsSearchParams(
searchParams: NextSearchParams
): DetailsBooking | null {
const packageEnum = {
...BreakfastPackageEnum,
...RoomPackageCodeEnum,
} as const
try {
const result = parseSearchParams(searchParams, {
keyRenameMap,
typeHints,
schema: z.object({
city: z.string().optional(),
hotelId: z.string(),
fromDate: z.string(),
toDate: z.string(),
searchType: searchTypeSchema,
bookingCode: z.string().optional(),
rooms: z.array(
z.object({
adults: adultsSchema,
bookingCode: z.string().optional(),
counterRateCode: z.string().optional(),
rateCode: z.string(),
roomTypeCode: z.string(),
packages: z.array(z.nativeEnum(packageEnum)).optional(),
childrenInRoom: z
.array(
z.object({
bed: childBedSchema,
age: childAgeSchema,
})
)
.optional(),
})
),
}),
})
return result
} catch (error) {
logger.error("[URL] Error parsing search params for details:", error)
return null
}
}
const reversedKeyRenameMap = Object.fromEntries(
Object.entries(keyRenameMap).map(([key, value]) => [value, key])
)
export function serializeBookingSearchParams(
obj:
| BookingWidgetSearchData
| SelectHotelBooking
| SelectRateBooking
| DetailsBooking,
{ initialSearchParams }: { initialSearchParams?: URLSearchParams } = {}
) {
return serializeSearchParams(obj, {
keyRenameMap: reversedKeyRenameMap,
initialSearchParams,
typeHints,
})
}
// TODO duplicated until full booking flow is migrated to booking-flow package
export type DetailsBooking = {
hotelId: string
fromDate: string
toDate: string
city?: string
bookingCode?: string
searchType?: BookingSearchType
rooms: {
adults: number
rateCode: string
roomTypeCode: string
bookingCode?: string
childrenInRoom?: Child[]
counterRateCode?: string
packages?: PackageEnum[]
}[]
}
export type SelectHotelBooking = {
hotelId?: string
city?: string
fromDate: string
toDate: string
rooms: {
adults: number
childrenInRoom?: Child[]
}[]
bookingCode?: string
searchType?: BookingSearchType
}
export type SelectRateBooking = {
bookingCode?: string
city?: string
fromDate: string
hotelId: string
rooms: Room[]
searchType?: BookingSearchType
toDate: string
}
export interface Room {
adults: number
childrenInRoom?: Child[]
bookingCode?: string | null
counterRateCode?: string | null
packages?: PackageEnum[] | null
rateCode?: string | null
roomTypeCode?: string | null
}

View File

@@ -11,11 +11,30 @@
"test:watch": "vitest"
},
"exports": {
"./test-entry": "./lib/index.tsx",
"./BookingWidget": "./lib/components/BookingWidget/index.tsx",
"./BookingWidget/FloatingBookingWidget": "./lib/components/BookingWidget/FloatingBookingWidget/index.tsx",
"./BookingWidget/Skeleton": "./lib/components/BookingWidget/Skeleton.tsx",
"./BookingWidget/BookingWidgetForm/FormContent/Search": "./lib/components/BookingWidget/BookingWidgetForm/FormContent/Search/index.tsx",
"./BookingFlowTrackingProvider": "./lib/components/BookingFlowTrackingProvider.tsx",
"./utils/url": "./lib/utils/url.ts",
"./hooks/useSearchHistory": "./lib/hooks/useSearchHistory.ts",
"./searchType": "./lib/misc/searchType.ts",
"./stores/bookingCode-filter": "./lib/stores/bookingCode-filter.ts",
"./components/TripAdvisorChip": "./lib/components/TripAdvisorChip/index.tsx"
},
"dependencies": {
"@scandic-hotels/common": "workspace:*"
"@hookform/resolvers": "^5.0.1",
"@scandic-hotels/common": "workspace:*",
"@scandic-hotels/design-system": "workspace:*",
"@scandic-hotels/trpc": "workspace:*",
"class-variance-authority": "^0.7.1",
"downshift": "^9.0.9",
"motion": "^12.10.0",
"react-aria-components": "^1.8.0",
"react-day-picker": "^9.6.7",
"react-hook-form": "^7.56.2",
"react-intl": "^7.1.11",
"usehooks-ts": "3.1.1"
},
"peerDependencies": {
"next": "^15",

View File

@@ -0,0 +1,37 @@
import { Lang } from "./language"
export const longDateFormat = {
[Lang.en]: "ddd, D MMM",
[Lang.sv]: "ddd D MMM",
[Lang.no]: "ddd D. MMM",
[Lang.da]: "ddd D. MMM",
[Lang.de]: "ddd D. MMM",
[Lang.fi]: "ddd D.M",
} as const
export const longDateWithYearFormat = {
[Lang.en]: "ddd, D MMM YYYY",
[Lang.sv]: "ddd D MMM YYYY",
[Lang.no]: "ddd D. MMM YYYY",
[Lang.da]: "ddd D. MMM YYYY",
[Lang.de]: "ddd D. MMM YYYY",
[Lang.fi]: "ddd D.M. YYYY",
} as const
export const shortDateFormat = {
[Lang.en]: "D MMM",
[Lang.sv]: "D MMM",
[Lang.no]: "D. MMM",
[Lang.da]: "D. MMM",
[Lang.de]: "D. MMM",
[Lang.fi]: "D.MM.",
} as const
export const changeOrCancelDateFormat = {
[Lang.sv]: "dddd D MMM",
[Lang.en]: "dddd D MMM",
[Lang.no]: "dddd D. MMM",
[Lang.da]: "dddd D. MMM",
[Lang.de]: "dddd D. MMM",
[Lang.fi]: "dd D MMMM",
} as const

View File

@@ -3,3 +3,44 @@ import type { Lang } from "../language"
export function selectRate(lang: Lang) {
return `/${lang}/hotelreservation/select-rate`
}
export function hotelreservation(lang: Lang) {
return `/${lang}/hotelreservation`
}
export function bookingConfirmation(lang: Lang) {
return `${hotelreservation(lang)}/booking-confirmation`
}
export function details(lang: Lang) {
return `${hotelreservation(lang)}/details`
}
export function selectHotel(lang: Lang) {
return `${hotelreservation(lang)}/select-hotel`
}
export function selectHotelMap(lang: Lang) {
return `${hotelreservation(lang)}/select-hotel/map`
}
export function selectRateWithParams(
lang: Lang,
hotelId: string,
fromdate: string,
todate: string
) {
return `${hotelreservation(lang)}/select-rate?room%5B0%5D.adults=1&fromdate=${fromdate}&todate=${todate}&hotel=${hotelId}`
}
export function alternativeHotels(lang: Lang) {
return `${hotelreservation(lang)}/alternative-hotels`
}
export function alternativeHotelsMap(lang: Lang) {
return `${hotelreservation(lang)}/alternative-hotels/map`
}
export function guaranteeCallback(lang: Lang) {
return `${hotelreservation(lang)}/gla-payment-callback`
}

View File

@@ -0,0 +1,148 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import useStickyPositionStore, {
type StickyElement,
type StickyElementNameEnum,
} from "../stores/sticky-position"
import { debounce } from "../utils/debounce"
interface UseStickyPositionProps {
ref?: React.RefObject<HTMLElement | null>
name?: StickyElementNameEnum
group?: string
}
// Global singleton ResizeObserver to observe the body only once
let resizeObserver: ResizeObserver | null = null
/**
* Custom hook to manage sticky positioning of elements within a page.
* This hook registers an element as sticky, calculates its top offset based on
* other registered sticky elements, and updates the element's position dynamically.
*
* @param {UseStickyPositionProps} [props] - The properties for configuring the hook.
* @param {React.RefObject<HTMLElement>} [props.ref] - A reference to the HTML element that should be sticky. Is optional to allow for other components to only get the height of the sticky elements.
* @param {StickyElementNameEnum} [props.name] - A unique name for the sticky element, used for tracking.
* @param {string} [props.group] - An optional group identifier to make multiple elements share the same top offset. Defaults to the name if not provided.
*
* @returns {Object} An object containing information about the sticky elements.
* @returns {number | null} [returns.currentHeight] - The current height of the registered sticky element, or `null` if not available.
* @returns {Array<StickyElement>} [returns.allElements] - An array containing the heights, names, and groups of all registered sticky elements.
*/
export default function useStickyPosition({
ref,
name,
group,
}: UseStickyPositionProps = {}) {
const {
registerSticky,
unregisterSticky,
stickyElements,
updateHeights,
getAllElements,
} = useStickyPositionStore()
/* Used for Current mobile header since that doesn't use this hook.
*
* Instead, calculate if the mobile header is shown and add the height of
* that "manually" to all offsets using this hook.
*
* TODO: Remove this and just use 0 when the current header has been removed.
*/
const [baseTopOffset, setBaseTopOffset] = useState(0)
useEffect(() => {
if (ref && name) {
// Register the sticky element with the given ref, name, and group.
// If the group is not provided, it defaults to the value of the name.
// This registration keeps track of the sticky element and its height.
registerSticky(ref, name, group || name)
// Update the heights of all registered sticky elements.
// This ensures that the height information is accurate and up-to-date.
updateHeights()
return () => {
unregisterSticky(ref)
}
}
}, [ref, name, group, registerSticky, unregisterSticky, updateHeights])
/**
* Get the top position of element at index `index`
*
* Calculates the total height of all sticky elements _before_ the element
* at position `index`. If `index` is not provided all elements are included
* in the calculation. Takes grouping into consideration (only counts one
* element per group)
*/
const getTopOffset = useCallback(
(index?: number) => {
// Get the group name of the current sticky element.
// This will be used to only count one element per group.
const elementGroup = index ? stickyElements[index].group : undefined
return stickyElements
.slice(0, index)
.reduce<StickyElement[]>((acc, curr) => {
if (
(elementGroup && curr.group === elementGroup) ||
acc.some((elem: StickyElement) => elem.group === curr.group)
) {
return acc
}
return [...acc, curr]
}, [])
.reduce((acc, el) => acc + el.height, baseTopOffset)
},
[baseTopOffset, stickyElements]
)
useEffect(() => {
if (ref) {
// Find the index of the current sticky element in the array of stickyElements.
// This helps us determine its position relative to other sticky elements.
const index = stickyElements.findIndex((el) => el.ref === ref)
if (index !== -1 && ref.current) {
const topOffset = getTopOffset(index)
// Apply the calculated top offset to the current element's style.
// This positions the element at the correct location within the document.
ref.current.style.top = `${topOffset}px`
}
}
}, [baseTopOffset, stickyElements, ref, getTopOffset])
useEffect(() => {
if (!resizeObserver) {
const debouncedResizeHandler = debounce(() => {
updateHeights()
// Only do this special handling if we have the current header
if (document.body.clientWidth > 950) {
setBaseTopOffset(0)
} else {
setBaseTopOffset(52.41) // The height of current mobile header
}
}, 100)
resizeObserver = new ResizeObserver(debouncedResizeHandler)
}
resizeObserver.observe(document.body)
return () => {
if (resizeObserver) {
resizeObserver.unobserve(document.body)
}
}
}, [updateHeights])
return {
currentHeight: ref?.current?.offsetHeight || null,
allElements: getAllElements(),
getTopOffset,
}
}

View File

@@ -28,10 +28,15 @@
"./utils/dateFormatting": "./utils/dateFormatting.ts",
"./utils/rangeArray": "./utils/rangeArray.ts",
"./utils/zod/*": "./utils/zod/*.ts",
"./utils/debounce": "./utils/debounce.ts",
"./utils/isValidJson": "./utils/isValidJson.ts",
"./hooks/*": "./hooks/*.ts",
"./stores/*": "./stores/*.ts",
"./constants/language": "./constants/language.ts",
"./constants/membershipLevels": "./constants/membershipLevels.ts",
"./constants/paymentMethod": "./constants/paymentMethod.ts",
"./constants/currency": "./constants/currency.ts",
"./constants/dateFormats": "./constants/dateFormats.ts",
"./constants/routes/*": "./constants/routes/*.ts"
},
"dependencies": {
@@ -41,7 +46,11 @@
"deepmerge": "^4.3.1",
"flat": "^6.0.1",
"lodash-es": "^4.17.21",
"zod": "^3.24.4"
"zod": "^3.24.4",
"zustand": "^4.5.2"
},
"peerDependencies": {
"react": "^19"
},
"devDependencies": {
"@eslint/compat": "^1.2.9",

View File

@@ -0,0 +1,84 @@
import { create } from "zustand"
export enum StickyElementNameEnum {
SITEWIDE_ALERT = "SITEWIDE_ALERT",
BOOKING_WIDGET = "BOOKING_WIDGET",
MEETING_PACKAGE_WIDGET = "MEETING_PACKAGE_WIDGET",
HOTEL_TAB_NAVIGATION = "HOTEL_TAB_NAVIGATION",
HOTEL_STATIC_MAP = "HOTEL_STATIC_MAP",
DESTINATION_SIDEBAR = "DESTINATION_SIDEBAR",
}
export interface StickyElement {
height: number
ref: React.RefObject<HTMLElement | null>
group: string
priority: number
name: StickyElementNameEnum
}
interface StickyStore {
stickyElements: StickyElement[]
registerSticky: (
ref: React.RefObject<HTMLElement | null>,
name: StickyElementNameEnum,
group: string
) => void
unregisterSticky: (ref: React.RefObject<HTMLElement | null>) => void
updateHeights: () => void
getAllElements: () => Array<StickyElement>
}
// Map to define priorities based on StickyElementNameEnum
const priorityMap: Record<StickyElementNameEnum, number> = {
[StickyElementNameEnum.SITEWIDE_ALERT]: 1,
[StickyElementNameEnum.BOOKING_WIDGET]: 2,
[StickyElementNameEnum.HOTEL_TAB_NAVIGATION]: 3,
[StickyElementNameEnum.HOTEL_STATIC_MAP]: 3,
[StickyElementNameEnum.MEETING_PACKAGE_WIDGET]: 3,
[StickyElementNameEnum.DESTINATION_SIDEBAR]: 3,
}
const useStickyPositionStore = create<StickyStore>((set, get) => ({
stickyElements: [],
registerSticky: (ref, name, group) => {
const priority = priorityMap[name] || 0
set((state) => {
const newStickyElement: StickyElement = {
height: ref.current?.offsetHeight || 0,
ref,
group,
priority,
name,
}
const updatedStickyElements = [
...state.stickyElements,
newStickyElement,
].sort((a, b) => a.priority - b.priority)
return {
stickyElements: updatedStickyElements,
}
})
},
unregisterSticky: (ref) => {
set((state) => ({
stickyElements: state.stickyElements.filter((el) => el.ref !== ref),
}))
},
updateHeights: () => {
set((state) => ({
stickyElements: state.stickyElements.map((el) => ({
...el,
height: el.ref.current?.offsetHeight || el.height,
})),
}))
},
getAllElements: () => get().stickyElements,
}))
export default useStickyPositionStore

View File

@@ -0,0 +1,12 @@
export function debounce<Params extends any[]>(
func: (...args: Params) => any,
delay = 300
) {
let debounceTimer: ReturnType<typeof setTimeout>
return function <U>(this: U, ...args: Parameters<typeof func>) {
const context = this
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => func.apply(context, args), delay)
}
}

View File

@@ -0,0 +1,9 @@
export default function isValidJson(value: string | null | undefined): boolean {
if (!value || value === "undefined") return false
try {
JSON.parse(value)
return true
} catch {
return false
}
}

View File

@@ -28,7 +28,7 @@ export function email(str: string) {
if (domainParts.length > 1) {
const domainTLD = domainParts.pop()
const domainPartsMasked = domainParts
.map((domainPart, i) => {
.map((domainPart) => {
return maskAllButFirstChar(domainPart)
})
.join(".")

View File

@@ -1,20 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import type { ButtonProps as ReactAriaButtonProps } from 'react-aria-components'
import type { buttonVariants } from './variants'
export interface ButtonPropsRAC
extends Omit<ReactAriaButtonProps, 'isDisabled' | 'onClick'>,
VariantProps<typeof buttonVariants> {
asChild?: false | undefined | never
disabled?: ReactAriaButtonProps['isDisabled']
onClick?: ReactAriaButtonProps['onPress']
}
export interface ButtonPropsSlot
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild: true
}
export type ButtonProps = ButtonPropsSlot | ButtonPropsRAC

View File

@@ -5,7 +5,24 @@ import { Button as ButtonRAC } from 'react-aria-components'
import { buttonVariants } from './variants'
import type { ButtonProps } from './button'
import type { VariantProps } from 'class-variance-authority'
import type { ButtonProps as ReactAriaButtonProps } from 'react-aria-components'
export interface ButtonPropsRAC
extends Omit<ReactAriaButtonProps, 'isDisabled' | 'onClick'>,
VariantProps<typeof buttonVariants> {
asChild?: false | undefined | never
disabled?: ReactAriaButtonProps['isDisabled']
onClick?: ReactAriaButtonProps['onPress']
}
export interface ButtonPropsSlot
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild: true
}
export type ButtonProps = ButtonPropsSlot | ButtonPropsRAC
/**
* @deprecated Use `@scandic-hotels/design-system/Button` instead.
@@ -51,5 +68,3 @@ export function OldDSButton(props: ButtonProps) {
/>
)
}
export type { ButtonPropsRAC, ButtonProps } from './button'

View File

@@ -1,6 +1,5 @@
import { generateTag, generateTagsFromSystem } from "../../../utils/generateTag"
import { CampaignOverviewPageEnum } from "../../../types/campaignOverviewPageEnum"
import { generateTag, generateTagsFromSystem } from "../../../utils/generateTag"
import type { Lang } from "@scandic-hotels/common/constants/language"