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:
committed by
Linus Flood
parent
667cab6fb6
commit
80100e7631
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./button.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const buttonVariants = cva(styles.button, config)
|
||||
@@ -0,0 +1,45 @@
|
||||
.dialog {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./dialog.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
default: styles.default,
|
||||
error: styles.error,
|
||||
search: styles.search,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const dialogVariants = cva(styles.dialog, config)
|
||||
@@ -0,0 +1,13 @@
|
||||
import Footnote from "@/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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./listItem.module.css"
|
||||
|
||||
const config = {
|
||||
variants: {
|
||||
variant: {
|
||||
active: styles.active,
|
||||
default: styles.default,
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
} as const
|
||||
|
||||
export const listItemVariants = cva(styles.listItem, config)
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 var(--Spacing-x1);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user