Merged in monorepo-step-1 (pull request #1080)

Migrate to a monorepo setup - step 1

* Move web to subfolder /apps/scandic-web

* Yarn + transitive deps

- Move to yarn
- design-system package removed for now since yarn doesn't
support the parameter for token (ie project currently broken)
- Add missing transitive dependencies as Yarn otherwise
prevents these imports
- VS Code doesn't pick up TS path aliases unless you open
/apps/scandic-web instead of root (will be fixed with monorepo)

* Pin framer-motion to temporarily fix typing issue

https://github.com/adobe/react-spectrum/issues/7494

* Pin zod to avoid typ error

There seems to have been a breaking change in the types
returned by zod where error is now returned as undefined
instead of missing in the type. We should just handle this
but to avoid merge conflicts just pin the dependency for
now.

* Pin react-intl version

Pin version of react-intl to avoid tiny type issue where formatMessage
does not accept a generic any more. This will be fixed in a future
commit, but to avoid merge conflicts just pin for now.

* Pin typescript version

Temporarily pin version as newer versions as stricter and results in
a type error. Will be fixed in future commit after merge.

* Setup workspaces

* Add design-system as a monorepo package

* Remove unused env var DESIGN_SYSTEM_ACCESS_TOKEN

* Fix husky for monorepo setup

* Update netlify.toml

* Add lint script to root package.json

* Add stub readme

* Fix react-intl formatMessage types

* Test netlify.toml in root

* Remove root toml

* Update netlify.toml publish path

* Remove package-lock.json

* Update build for branch/preview builds


Approved-by: Linus Flood
This commit is contained in:
Anton Gunnarsson
2025-02-26 10:36:17 +00:00
committed by Linus Flood
parent 667cab6fb6
commit 80100e7631
2731 changed files with 30986 additions and 23708 deletions

View File

@@ -0,0 +1,24 @@
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import Input from "@/components/TempDesignSystem/Form/Input"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
export default function TabletCodeInput({
updateValue,
}: {
updateValue: (val: string) => void
}) {
const intl = useIntl()
const { register } = useFormContext<BookingWidgetSchema>()
return (
<Input
label={intl.formatMessage({ id: "Add code" })}
{...register("bookingCode.value", {
onChange: (e) => updateValue(e.target.value),
})}
autoComplete="off"
/>
)
}

View File

@@ -0,0 +1,77 @@
.container {
position: relative;
}
.bookingCodeLabel {
display: flex;
align-items: center;
gap: var(--Spacing-x-half);
position: relative;
}
.error {
display: flex;
gap: var(--Spacing-x-half);
}
.errorIcon {
min-width: 20px;
}
.bookingCodeRemember,
.bookingCodeRememberVisible {
display: none;
gap: var(--Spacing-x1);
}
.bookingCodeRememberVisible {
display: flex;
position: absolute;
top: calc(100% + 16px);
width: 100%;
}
@media screen and (max-width: 767px) {
.hideOnMobile {
display: none;
}
}
@media screen and (min-width: 768px) {
.bookingCodeRememberVisible {
align-items: center;
background: var(--Base-Surface-Primary-light-Normal);
justify-content: space-between;
border-radius: var(--Spacing-x-one-and-half);
}
}
@media screen and (min-width: 768px) and (max-width: 1367px) {
.container {
display: flex;
gap: var(--Spacing-x1);
}
.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);
}
.bookingCodeRememberVisible {
position: static;
}
}
@media screen and (min-width: 1367px) {
.bookingCodeRememberVisible {
padding: var(--Spacing-x2);
width: 320px;
top: calc(100% + 24px);
left: calc(0% - var(--Spacing-x-one-and-half));
}
}

View File

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

View File

@@ -0,0 +1,19 @@
import React, { forwardRef, type InputHTMLAttributes } from "react"
import { Input as InputRAC } from "react-aria-components"
import Body from "@/components/TempDesignSystem/Text/Body"
import styles from "./input.module.css"
const Input = forwardRef<
HTMLInputElement,
InputHTMLAttributes<HTMLInputElement>
>(function InputComponent(props, ref) {
return (
<Body asChild color="uiTextHighContrast">
<InputRAC {...props} ref={ref} className={styles.input} />
</Body>
)
})
export default Input

View File

@@ -0,0 +1,22 @@
.input {
background-color: transparent;
border: none;
height: 24px;
outline: none;
position: relative;
width: 100%;
z-index: 2;
}
.input::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
background-image: url("/_static/icons/close.svg");
height: 20px;
width: 20px;
}
.input:disabled,
.input:disabled::placeholder {
color: var(--Base-Text-Disabled);
}

View File

@@ -0,0 +1,19 @@
.button {
align-items: center;
border: none;
border-radius: var(--Corner-radius-Medium);
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(--Base-Surface-Primary-light-Hover-alt);
}

View File

@@ -0,0 +1,41 @@
"use client"
import { useIntl } from "react-intl"
import { DeleteIcon } from "@/components/Icons"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { buttonVariants } from "./variants"
import type { ClearSearchButtonProps } from "@/types/components/search"
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"
>
<DeleteIcon color="burgundy" height={20} width={20} />
<Caption color="burgundy" type="bold" asChild>
<span>{intl.formatMessage({ id: "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-Large);
display: flex;
flex-direction: column;
left: 0;
list-style: none;
overflow-y: auto;
padding: var(--Spacing-x2) var(--Spacing-x3);
position: fixed;
top: 170px;
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: 0 0 14px 6px rgba(0, 0, 0, 0.1);
max-height: 380px;
height: auto;
}
}

View File

@@ -0,0 +1,13 @@
import { dialogVariants } from "./variants"
import type { DialogProps } from "@/types/components/search"
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 "@/components/TempDesignSystem/Text/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,40 @@
import Body from "@/components/TempDesignSystem/Text/Body"
import { listItemVariants } from "./variants"
import type { ListItemProps } from "@/types/components/search"
export default function ListItem({
getItemProps,
highlightedIndex,
index,
location,
}: ListItemProps) {
const classNames = listItemVariants({
variant: index === highlightedIndex ? "active" : "default",
})
const isCity = location.type === "cities"
const isHotelLocation = location.type === "hotels"
return (
<li
{...getItemProps({
className: classNames,
index,
item: location,
})}
>
<Body color="black" textTransform="bold">
{location.name}
</Body>
{isCity && location?.country ? (
<Body color="uiTextPlaceholder">{location.country}</Body>
) : null}
{isHotelLocation && location.relationships.city?.name ? (
<Body color="uiTextPlaceholder">
{location.relationships.city.name}
</Body>
) : null}
</li>
)
}

View File

@@ -0,0 +1,13 @@
.listItem {
border-radius: var(--Corner-radius-Medium);
cursor: pointer;
padding: var(--Spacing-x1);
}
.default {
background-color: transparent;
}
.active {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}

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,32 @@
import Label from "./Label"
import ListItem from "./ListItem"
import styles from "./list.module.css"
import type { ListProps } from "@/types/components/search"
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>
)
}

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,195 @@
"use client"
import { useEffect, useState } from "react"
import { useFormContext } from "react-hook-form"
import { useIntl } from "react-intl"
import { ErrorCircleIcon } from "@/components/Icons"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import ClearSearchButton from "./ClearSearchButton"
import Dialog from "./Dialog"
import List from "./List"
import styles from "./searchList.module.css"
import type { SearchListProps } from "@/types/components/search"
export default function SearchList({
getItemProps,
getMenuProps,
handleClearSearchHistory,
highlightedIndex,
isOpen,
locations,
search,
searchHistory,
}: SearchListProps) {
const intl = useIntl()
const [hasMounted, setHasMounted] = useState(false)
const {
clearErrors,
formState: { errors, isSubmitted },
} = useFormContext()
const searchError = errors["search"]
useEffect(() => {
let timeoutID: ReturnType<typeof setTimeout> | null = null
if (searchError) {
timeoutID = setTimeout(() => {
clearErrors("search")
// magic number originates from animation
// 5000ms delay + 120ms exectuion
}, 5120)
}
return () => {
if (timeoutID) {
clearTimeout(timeoutID)
}
}
}, [clearErrors, searchError])
useEffect(() => {
setHasMounted(true)
}, [setHasMounted])
if (!hasMounted) {
return null
}
if (searchError && isSubmitted) {
if (typeof searchError.message === "string") {
if (!isOpen) {
if (searchError.message === "Required") {
return (
<Dialog
className={styles.fadeOut}
getMenuProps={getMenuProps}
variant="error"
>
<Caption className={styles.heading} color="red">
<ErrorCircleIcon color="red" />
{intl.formatMessage({ id: "Enter destination or hotel" })}
</Caption>
<Body>
{intl.formatMessage({
id: "A destination or hotel name is needed to be able to search for a hotel room.",
})}
</Body>
</Dialog>
)
} else if (searchError.type === "custom") {
return (
<Dialog
className={styles.fadeOut}
getMenuProps={getMenuProps}
variant="error"
>
<Caption className={styles.heading} color="red">
<ErrorCircleIcon color="red" />
{intl.formatMessage({ id: "No results" })}
</Caption>
<Body>
{intl.formatMessage({
id: "We couldn't find a matching location for your search.",
})}
</Body>
</Dialog>
)
}
}
}
}
if (!isOpen) {
return null
}
if (locations.length) {
const cities = locations.filter((location) => location.type === "cities")
const hotels = locations.filter((location) => location.type === "hotels")
return (
<Dialog getMenuProps={getMenuProps} variant="search">
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
label={intl.formatMessage({ id: "Cities" })}
locations={cities}
/>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
initialIndex={cities.length}
label={intl.formatMessage({ id: "Hotels" })}
locations={hotels}
/>
</Dialog>
)
}
if (!search && searchHistory?.length) {
return (
<Dialog getMenuProps={getMenuProps}>
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
{intl.formatMessage({ id: "Latest searches" })}
</Footnote>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
locations={searchHistory}
/>
<Divider className={styles.divider} color="beige" />
<ClearSearchButton
getItemProps={getItemProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
index={searchHistory.length}
/>
</Dialog>
)
}
if (search) {
return (
<Dialog getMenuProps={getMenuProps} variant="error">
<Body className={styles.text} textTransform="bold">
{intl.formatMessage({ id: "No results" })}
</Body>
<Body className={styles.text} color="uiTextPlaceholder">
{intl.formatMessage({
id: "We couldn't find a matching location for your search.",
})}
</Body>
{searchHistory ? (
<>
<Divider className={styles.noResultsDivider} color="beige" />
<Footnote
className={styles.text}
color="uiTextPlaceholder"
textTransform="uppercase"
>
{intl.formatMessage({ id: "Latest searches" })}
</Footnote>
<List
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
locations={searchHistory}
/>
<Divider className={styles.divider} color="beige" />
<ClearSearchButton
getItemProps={getItemProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
index={searchHistory.length}
/>
</>
) : null}
</Dialog>
)
}
return null
}

View File

@@ -0,0 +1,31 @@
@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,296 @@
"use client"
import Downshift from "downshift"
import {
type ChangeEvent,
type FocusEvent,
type FormEvent,
useCallback,
useEffect,
useReducer,
} from "react"
import { useFormContext, useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import isValidJson from "@/utils/isValidJson"
import Input from "../Input"
import { init, localStorageKey, reducer, sessionStorageKey } from "./reducer"
import SearchList from "./SearchList"
import styles from "./search.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import {
ActionType,
type SetStorageData,
} from "@/types/components/form/bookingwidget"
import type { SearchHistoryItem, SearchProps } from "@/types/components/search"
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
const name = "search"
export default function Search({ locations, handlePressEnter }: SearchProps) {
const { register, setValue, unregister, getValues } =
useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const locationString = getValues("location")
const location =
locationString && isValidJson(decodeURIComponent(locationString))
? JSON.parse(decodeURIComponent(locationString))
: null
const [state, dispatch] = useReducer(
reducer,
{ defaultLocations: locations, initialValue: value },
init
)
const handleMatchLocations = useCallback(
function (searchValue: string) {
return locations.filter((location) => {
return location.name.toLowerCase().includes(searchValue.toLowerCase())
})
},
[locations]
)
function handleClearSearchHistory() {
localStorage.removeItem(localStorageKey)
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
}
function dispatchInputValue(inputValue: string) {
if (inputValue) {
dispatch({
payload: { search: inputValue },
type: ActionType.SEARCH_LOCATIONS,
})
} else {
dispatch({ type: ActionType.CLEAR_SEARCH_LOCATIONS })
}
}
function handleOnChange(
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
) {
const newValue = evt.currentTarget.value
setValue(name, newValue)
dispatchInputValue(value)
}
function handleOnFocus(evt: FocusEvent<HTMLInputElement>) {
const searchValue = evt.currentTarget.value
if (searchValue) {
const matchingLocations = handleMatchLocations(searchValue)
if (matchingLocations.length) {
dispatch({
payload: { search: searchValue },
type: ActionType.SEARCH_LOCATIONS,
})
}
}
}
function handleOnSelect(selectedItem: Location | null) {
if (selectedItem) {
const stringified = JSON.stringify(selectedItem)
setValue("location", encodeURIComponent(stringified))
sessionStorage.setItem(sessionStorageKey, stringified)
setValue(name, selectedItem.name)
const newHistoryItem: SearchHistoryItem = {
type: selectedItem.type,
id: selectedItem.id,
}
const oldSearchHistoryWithoutTheNew =
state.searchHistory?.filter(
(h) => h.type !== newHistoryItem.type || h.id !== newHistoryItem.id
) ?? []
const oldHistoryItems = oldSearchHistoryWithoutTheNew.map((h) => ({
id: h.id,
type: h.type,
}))
const searchHistory = [newHistoryItem, ...oldHistoryItems]
localStorage.setItem(localStorageKey, JSON.stringify(searchHistory))
const enhancedSearchHistory: Locations = [
...getEnhancedSearchHistory([newHistoryItem], locations),
...oldSearchHistoryWithoutTheNew,
]
dispatch({
payload: {
location: selectedItem,
searchHistory: enhancedSearchHistory,
},
type: ActionType.SELECT_ITEM,
})
} else {
sessionStorage.removeItem(sessionStorageKey)
}
}
useEffect(() => {
const searchData = sessionStorage.getItem(sessionStorageKey)
const searchHistory = localStorage.getItem(localStorageKey)
const payload: SetStorageData["payload"] = {}
if (searchData) {
payload.searchData = JSON.parse(searchData)
}
if (searchHistory) {
payload.searchHistory = getEnhancedSearchHistory(
JSON.parse(searchHistory),
locations
)
}
dispatch({
payload,
type: ActionType.SET_STORAGE_DATA,
})
}, [dispatch, locations])
const stayType = state.searchData?.type === "cities" ? "city" : "hotel"
const stayValue =
(value === state.searchData?.name &&
((state.searchData?.type === "cities" && state.searchData?.name) ||
state.searchData?.id)) ||
""
useEffect(() => {
if (stayType === "city") {
unregister("hotel")
setValue(stayType, stayValue, {
shouldValidate: true,
})
} else {
unregister("city")
setValue(stayType, Number(stayValue), {
shouldValidate: true,
})
}
}, [stayType, stayValue, unregister, setValue])
useEffect(() => {
if (location) {
sessionStorage.setItem(sessionStorageKey, JSON.stringify(location))
}
}, [location])
function getLocationLabel(): string {
const fallbackLabel = intl.formatMessage({ id: "Where to?" })
if (location?.type === "hotels") {
return location?.relationships?.city?.name || fallbackLabel
}
if (state.searchData?.type === "hotels") {
return state.searchData?.relationships?.city?.name || fallbackLabel
}
return fallbackLabel
}
return (
<Downshift
initialSelectedItem={state.searchData}
inputValue={value}
itemToString={(value) => (value ? value.name : "")}
onSelect={handleOnSelect}
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
defaultHighlightedIndex={0}
>
{({
getInputProps,
getItemProps,
getLabelProps,
getMenuProps,
getRootProps,
highlightedIndex,
isOpen,
openMenu,
}) => (
<div className={styles.container}>
{value ? (
// Adding hidden input to define hotel or city based on destination selection for basic form submit.
<input type="hidden" {...register(stayType)} />
) : null}
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
<Caption
type="bold"
color={isOpen ? "uiTextActive" : "red"}
asChild
>
<span>{getLocationLabel()}</span>
</Caption>
</label>
<div {...getRootProps({}, { suppressRefError: true })}>
<label className={styles.searchInput}>
<Input
{...getInputProps({
id: name,
onFocus(evt) {
handleOnFocus(evt)
openMenu()
},
placeholder: intl.formatMessage({
id: "Hotels & Destinations",
}),
...register(name, {
onChange: handleOnChange,
}),
onKeyDown: (e) => {
if (e.key === "Enter" && !isOpen) {
handlePressEnter()
}
},
type: "search",
})}
/>
</label>
</div>
<SearchList
getItemProps={getItemProps}
getMenuProps={getMenuProps}
handleClearSearchHistory={handleClearSearchHistory}
highlightedIndex={highlightedIndex}
isOpen={isOpen}
locations={state.locations}
search={state.search}
searchHistory={state.searchHistory}
/>
</div>
)}
</Downshift>
)
}
export function SearchSkeleton() {
const intl = useIntl()
return (
<div className={styles.container}>
<div className={styles.label}>
<Caption type="bold" color="red" asChild>
<span>{intl.formatMessage({ id: "Where to?" })}</span>
</Caption>
</div>
<div>
<SkeletonShimmer width={"100%"} />
</div>
</div>
)
}
/**
* Takes a stored search history and returns the same history, but with the same
* data and the same format as the complete location objects
*/
function getEnhancedSearchHistory(
searchHistory: SearchHistoryItem[],
locations: Locations
): Locations {
return searchHistory
.map((historyItem) =>
locations.find(
(location) =>
location.type === historyItem.type && location.id === historyItem.id
)
)
.filter((r) => !!r) as Locations
}

View File

@@ -0,0 +1,98 @@
import {
type Action,
ActionType,
type InitState,
type State,
} from "@/types/components/form/bookingwidget"
import type { Locations } from "@/types/trpc/routers/hotel/locations"
export const localStorageKey = "searchHistory"
export const sessionStorageKey = "searchData"
export function init(initState: InitState): State {
const locations = []
if (initState.initialValue) {
const location = initState.defaultLocations.find(
(loc) => loc.name.toLowerCase() === initState.initialValue!.toLowerCase()
)
if (location) {
locations.push(location)
}
}
return {
defaultLocations: initState.defaultLocations,
locations,
search: locations.length ? locations[0].name : "",
searchData: locations.length ? locations[0] : undefined,
searchHistory: null,
}
}
export function reducer(state: State, action: Action) {
const type = action.type
switch (type) {
case ActionType.CLEAR_HISTORY_LOCATIONS: {
return {
...state,
locations: [],
search: "",
searchHistory: null,
}
}
case ActionType.CLEAR_SEARCH_LOCATIONS:
return {
...state,
locations: [],
search: "",
}
case ActionType.SEARCH_LOCATIONS: {
const matchesMap = new Map()
const search = action.payload.search.toLowerCase()
state.defaultLocations.forEach((location) => {
const locationName = location.name.toLowerCase()
const keyWords = location.keyWords?.flatMap((l) =>
l.toLowerCase().split(" ")
)
if (locationName.includes(search.trim())) {
matchesMap.set(location.name, location)
}
if (keyWords?.find((keyWord) => keyWord.startsWith(search.trim()))) {
matchesMap.set(location.name, location)
}
})
const matches: Locations = []
matchesMap.forEach((value) => {
matches.push(value)
})
return {
...state,
locations: matches,
search: action.payload.search,
}
}
case ActionType.SELECT_ITEM: {
return {
...state,
searchData: action.payload.location,
searchHistory: action.payload.searchHistory,
}
}
case ActionType.SET_STORAGE_DATA: {
return {
...state,
searchData: action.payload.searchData
? action.payload.searchData
: state.searchData,
searchHistory: action.payload.searchHistory
? action.payload.searchHistory
: state.searchHistory,
}
}
default:
const unhandledActionType: never = type
console.info(`Unhandled type: ${unhandledActionType}`)
return state
}
}

View File

@@ -0,0 +1,39 @@
.container {
border-color: transparent;
border-style: solid;
border-width: 1px;
border-radius: var(--Corner-radius-Small);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
position: relative;
height: 60px;
}
.container:hover,
.container:has(input:active, input:focus, input:focus-within) {
background-color: var(--Base-Background-Primary-Normal);
}
.container:has(input:active, input:focus, input:focus-within) {
border-color: 1px solid var(--UI-Input-Controls-Border-Focus);
}
.label:has(
~ .inputContainer input:active,
~ .inputContainer input:focus,
~ .inputContainer input:focus-within
)
p {
color: var(--UI-Text-Active);
}
.searchInput {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
height: 100%;
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
align-items: center;
display: grid;
}

View File

@@ -0,0 +1,108 @@
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { useIntl } from "react-intl"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { Tooltip } from "@/components/TempDesignSystem/Tooltip"
import BookingCode from "../BookingCode"
import styles from "./voucher.module.css"
export default function Voucher() {
const intl = useIntl()
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
const reward = intl.formatMessage({ id: "Book Reward Night" })
// ToDo: Remove this when all three options are enabled
const disabledBookingOptionsHeader = intl.formatMessage({
id: "We're sorry",
})
const disabledBookingOptionsText = intl.formatMessage({
id: "Codes, cheques and reward nights aren't available on the new website yet.",
})
return (
<div className={styles.optionsContainer}>
<div className={styles.vouchers}>
<BookingCode />
</div>
<div className={styles.options}>
<div className={styles.option}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="bottom"
arrow="left"
>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</Tooltip>
</div>
<div className={styles.option}>
<Tooltip
heading={disabledBookingOptionsHeader}
text={disabledBookingOptionsText}
position="bottom"
arrow="left"
>
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{reward}</span>
</Caption>
</Checkbox>
{/* <InfoCircleIcon color="white" className={styles.infoIcon} /> Out of scope for this release */}
</Tooltip>
</div>
</div>
</div>
)
}
export function VoucherSkeleton() {
const intl = useIntl()
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
const reward = intl.formatMessage({ id: "Book Reward Night" })
const form = useForm()
return (
<FormProvider {...form}>
<div className={styles.optionsContainer}>
<div className={styles.vouchers}>
<label>
<Caption type="bold" color="red" asChild>
<span>{vouchers}</span>
</Caption>
</label>
<SkeletonShimmer width={"100%"} />
</div>
<div className={styles.options}>
<div className={styles.option}>
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{bonus}</span>
</Caption>
</Checkbox>
</div>
<div className={styles.option}>
<Checkbox name="useReward" registerOptions={{ disabled: true }}>
<Caption color="disabled" asChild>
<span>{reward}</span>
</Caption>
</Checkbox>
</div>
</div>
</div>
</FormProvider>
)
}

View File

@@ -0,0 +1,78 @@
.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;
}
.vouchers {
width: 100%;
display: block;
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
border-radius: var(--Corner-radius-Small);
}
.optionsContainer {
display: flex;
flex-direction: column;
}
.checkbox {
width: 24px;
height: 24px;
}
@media screen and (max-width: 767px) {
.vouchers {
margin-bottom: var(--Spacing-x5);
}
}
@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);
}
.vouchers:hover,
.vouchers:focus-within,
.vouchers:has([data-focused="true"], [data-pressed="true"]) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
}
@media screen and (max-width: 1366px) {
.vouchers {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
}
}
@media screen and (min-width: 1367px) {
.vouchers {
display: block;
max-width: 200px;
}
.options {
flex-direction: column;
max-width: 190px;
gap: var(--Spacing-x-half);
}
.option:hover {
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,145 @@
.vouchersHeader {
display: flex;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
}
.icon {
display: none;
}
.rooms,
.when {
position: relative;
}
.showOnTablet {
display: none;
}
@media screen and (max-width: 767px) {
.voucherContainer {
padding: var(--Spacing-x2) 0 var(--Spacing-x4);
}
}
@media screen and (max-width: 1367px) {
.inputContainer {
display: grid;
gap: var(--Spacing-x2);
}
.rooms,
.vouchers,
.when,
.where {
background-color: var(--Base-Background-Primary-Normal);
border-radius: var(--Corner-radius-Medium);
}
.rooms,
.vouchers,
.when {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.button {
align-self: flex-end;
justify-content: center;
width: 100%;
}
.rooms {
height: 60px;
}
}
@media screen and (min-width: 768px) {
.input {
display: flex;
align-items: center;
}
.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-Small);
}
.when:hover,
.rooms:hover,
.when:has([data-isopen="true"]),
.rooms:has([data-focused="true"], [data-pressed="true"]) {
background-color: var(--Base-Surface-Primary-light-Hover-alt);
}
.where {
position: relative;
}
.button {
justify-content: center;
width: 118px;
}
}
@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);
}
.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;
}
}

View File

@@ -0,0 +1,159 @@
"use client"
import { useWatch } from "react-hook-form"
import { useIntl } from "react-intl"
import { dt } from "@/lib/dt"
import DatePicker from "@/components/DatePicker"
import GuestsRoomsPickerForm from "@/components/GuestsRoomsPicker"
import { SearchIcon } from "@/components/Icons"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Search, { SearchSkeleton } from "./Search"
import Voucher, { VoucherSkeleton } from "./Voucher"
import styles from "./formContent.module.css"
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
export default function FormContent({
locations,
formId,
onSubmit,
isSearching,
}: BookingWidgetFormContentProps) {
const intl = useIntl()
const selectedDate = useWatch({ name: "date" })
const roomsLabel = intl.formatMessage({ id: "Rooms & Guests" })
const nights = dt(selectedDate.toDate).diff(dt(selectedDate.fromDate), "days")
return (
<>
<div className={styles.input}>
<div className={styles.inputContainer}>
<div className={styles.where}>
<Search locations={locations} handlePressEnter={onSubmit} />
</div>
<div className={styles.when}>
<Caption color="red" type="bold">
{nights > 0
? intl.formatMessage(
{
id: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
: intl.formatMessage({
id: "Check in",
})}
</Caption>
<DatePicker />
</div>
<div className={styles.rooms}>
<label>
<Caption color="red" type="bold" asChild>
<span>{roomsLabel}</span>
</Caption>
</label>
<GuestsRoomsPickerForm />
</div>
</div>
<div className={`${styles.buttonContainer} ${styles.showOnTablet}`}>
<Button
className={styles.button}
form={formId}
intent="primary"
theme="base"
type="submit"
>
<span className={styles.icon}>
<SearchIcon color="white" width={28} height={28} />
</span>
</Button>
</div>
<div className={`${styles.voucherContainer} ${styles.voucherRow}`}>
<Voucher />
</div>
<div className={`${styles.buttonContainer} ${styles.hideOnTablet}`}>
<Button
className={styles.button}
form={formId}
intent="primary"
theme="base"
type="submit"
disabled={isSearching}
>
<Caption
color="white"
type="bold"
className={styles.buttonText}
asChild
>
<span>{intl.formatMessage({ id: "Search" })}</span>
</Caption>
<span className={styles.icon}>
<SearchIcon color="white" width={28} height={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}>
<Caption color="red" type="bold">
{intl.formatMessage(
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
{ totalNights: 0 }
)}
</Caption>
<SkeletonShimmer width={"100%"} />
</div>
<div className={styles.rooms}>
<Caption color="red" type="bold" asChild>
<span>{intl.formatMessage({ id: "Rooms & Guests" })}</span>
</Caption>
<SkeletonShimmer width={"100%"} />
</div>
</div>
<div className={styles.voucherContainer}>
<VoucherSkeleton />
</div>
<div className={styles.buttonContainer}>
<Button
className={styles.button}
intent="primary"
theme="base"
type="submit"
disabled
>
<Caption
color="white"
type="bold"
className={styles.buttonText}
asChild
>
<span>{intl.formatMessage({ id: "Search" })}</span>
</Caption>
<span className={styles.icon}>
<SearchIcon color="white" width={28} height={28} />
</span>
</Button>
</div>
</div>
)
}