feat(SW-66, SW-348): search functionality and ui
This commit is contained in:
@@ -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,47 @@
|
||||
"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,
|
||||
highlightedIndex,
|
||||
index,
|
||||
}: ClearSearchButtonProps) {
|
||||
const intl = useIntl()
|
||||
const classNames = buttonVariants({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
// noop
|
||||
// the click bubbles to handleOnSelect
|
||||
// where selectedItem = "clear-search"
|
||||
// which is the value for item below
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...getItemProps({
|
||||
className: classNames,
|
||||
id: "clear-search",
|
||||
index,
|
||||
item: "clear-search",
|
||||
role: "button",
|
||||
})}
|
||||
onClick={handleClick}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<DeleteIcon color="burgundy" height={20} width={20} />
|
||||
<Caption color="burgundy" textTransform="bold">
|
||||
{intl.formatMessage({ id: "Clear searches" })}
|
||||
</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,33 @@
|
||||
.dialog {
|
||||
background-color: var(--Base-Surface-Primary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
box-shadow: 0 0 14px 6px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
left: 0;
|
||||
list-style: none;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
padding: var(--Spacing-x2) var(--Spacing-x3);
|
||||
position: absolute;
|
||||
/**
|
||||
* 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));
|
||||
width: 360px;
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.default {
|
||||
gap: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.error {
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.search {
|
||||
gap: var(--Spacing-x3);
|
||||
}
|
||||
@@ -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}
|
||||
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,174 @@
|
||||
"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,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
locations,
|
||||
search,
|
||||
searchHistory,
|
||||
}: SearchListProps) {
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError && searchError.message === "Required") {
|
||||
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) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
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}
|
||||
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);
|
||||
}
|
||||
180
components/Forms/BookingWidget/FormContent/Search/index.tsx
Normal file
180
components/Forms/BookingWidget/FormContent/Search/index.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
import Downshift from "downshift"
|
||||
import {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useReducer,
|
||||
} from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
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 } from "@/types/components/form/bookingwidget"
|
||||
import type { SearchProps } from "@/types/components/search"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const name = "search"
|
||||
export default function Search({ locations }: SearchProps) {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
{ defaultLocations: locations },
|
||||
init
|
||||
)
|
||||
const { register, setValue, trigger } = useFormContext<BookingWidgetSchema>()
|
||||
const intl = useIntl()
|
||||
const value = useWatch({ name })
|
||||
|
||||
const handleMatchLocations = useCallback(
|
||||
function (searchValue: string) {
|
||||
return locations.filter((location) => {
|
||||
return location.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
})
|
||||
},
|
||||
[locations]
|
||||
)
|
||||
|
||||
function handleOnBlur() {
|
||||
if (!value && state.searchData?.name) {
|
||||
setValue(name, state.searchData.name)
|
||||
// Always need to manually trigger
|
||||
// revalidation when setting value r-h-f
|
||||
trigger()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const value = evt.currentTarget.value
|
||||
if (value) {
|
||||
dispatch({
|
||||
payload: { search: value },
|
||||
type: ActionType.SEARCH_LOCATIONS,
|
||||
})
|
||||
} else {
|
||||
dispatch({ type: ActionType.CLEAR_SEARCH_LOCATIONS })
|
||||
}
|
||||
}
|
||||
|
||||
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 | "clear-search") {
|
||||
if (selectedItem === "clear-search") {
|
||||
localStorage.removeItem(localStorageKey)
|
||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
||||
} else if (selectedItem) {
|
||||
const stringified = JSON.stringify(selectedItem)
|
||||
setValue("location", encodeURIComponent(stringified))
|
||||
sessionStorage.setItem(sessionStorageKey, stringified)
|
||||
setValue(name, selectedItem.name)
|
||||
trigger()
|
||||
|
||||
const searchHistoryMap = new Map()
|
||||
searchHistoryMap.set(selectedItem.name, selectedItem)
|
||||
if (state.searchHistory) {
|
||||
state.searchHistory.forEach((location) => {
|
||||
searchHistoryMap.set(location.name, location)
|
||||
})
|
||||
}
|
||||
|
||||
const searchHistory: Location[] = []
|
||||
searchHistoryMap.forEach((location) => {
|
||||
searchHistory.push(location)
|
||||
})
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(searchHistory))
|
||||
dispatch({
|
||||
payload: {
|
||||
location: selectedItem,
|
||||
searchHistory,
|
||||
},
|
||||
type: ActionType.SELECT_ITEM,
|
||||
})
|
||||
} else {
|
||||
sessionStorage.removeItem(sessionStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
initialSelectedItem={state.searchData}
|
||||
inputValue={value}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
>
|
||||
{({
|
||||
closeMenu,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getLabelProps,
|
||||
getMenuProps,
|
||||
getRootProps,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
openMenu,
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
<label {...getLabelProps({ htmlFor: name })} className={styles.label}>
|
||||
<Caption color={isOpen ? "uiTextActive" : "red"}>
|
||||
{intl.formatMessage({ id: "Where to" })}
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<Body asChild>
|
||||
<input
|
||||
{...getInputProps({
|
||||
className: styles.input,
|
||||
id: name,
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Destinations & hotels",
|
||||
}),
|
||||
...register(name, {
|
||||
onBlur: function () {
|
||||
handleOnBlur()
|
||||
closeMenu()
|
||||
},
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
type: "search",
|
||||
})}
|
||||
/>
|
||||
</Body>
|
||||
</div>
|
||||
<SearchList
|
||||
getItemProps={getItemProps}
|
||||
getMenuProps={getMenuProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isOpen={isOpen}
|
||||
locations={state.locations}
|
||||
search={state.search}
|
||||
searchHistory={state.searchHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Downshift>
|
||||
)
|
||||
}
|
||||
104
components/Forms/BookingWidget/FormContent/Search/reducer.ts
Normal file
104
components/Forms/BookingWidget/FormContent/Search/reducer.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
type Action,
|
||||
ActionType,
|
||||
type InitState,
|
||||
type State,
|
||||
} from "@/types/components/form/bookingwidget"
|
||||
import type { Location, Locations } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const localStorageKey = "searchHistory"
|
||||
export function getSearchHistoryFromLocalStorage() {
|
||||
if (typeof window !== "undefined") {
|
||||
const storageSearchHistory = window.localStorage.getItem(localStorageKey)
|
||||
if (storageSearchHistory) {
|
||||
const parsedStorageSearchHistory: Locations =
|
||||
JSON.parse(storageSearchHistory)
|
||||
if (parsedStorageSearchHistory?.length) {
|
||||
return parsedStorageSearchHistory
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const sessionStorageKey = "searchData"
|
||||
export function getSearchDataFromSessionStorage() {
|
||||
if (typeof window !== "undefined") {
|
||||
const storageSearchData = window.sessionStorage.getItem(sessionStorageKey)
|
||||
if (storageSearchData) {
|
||||
const parsedStorageSearchData: Location = JSON.parse(storageSearchData)
|
||||
if (parsedStorageSearchData) {
|
||||
return parsedStorageSearchData
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function init(initState: InitState): State {
|
||||
const searchHistory = getSearchHistoryFromLocalStorage()
|
||||
const searchData = getSearchDataFromSessionStorage()
|
||||
return {
|
||||
defaultLocations: initState.defaultLocations,
|
||||
locations: [],
|
||||
search: "",
|
||||
searchData,
|
||||
searchHistory,
|
||||
}
|
||||
}
|
||||
|
||||
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?.map((l) => l.toLowerCase())
|
||||
if (locationName.includes(search.trim())) {
|
||||
matchesMap.set(location.name, location)
|
||||
}
|
||||
if (keyWords?.find((keyWord) => keyWord.includes(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,
|
||||
}
|
||||
}
|
||||
default:
|
||||
const unhandledActionType: never = type
|
||||
console.info(`Unhandled type: ${unhandledActionType}`)
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:has(.input:active, .input:focus, .input:focus-within) {
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.input {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
height: 24px;
|
||||
outline: none;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.container:hover:has(.input:not(:active, :focus, :focus-within))
|
||||
.input::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
@@ -5,26 +5,31 @@
|
||||
|
||||
.input input[type="text"] {
|
||||
border: none;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x0);
|
||||
}
|
||||
|
||||
.where {
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.vouchers,
|
||||
.when,
|
||||
.rooms {
|
||||
width: 100%;
|
||||
max-width: 240px;
|
||||
.where {
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rooms,
|
||||
.when {
|
||||
max-width: 240px;
|
||||
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
|
||||
}
|
||||
|
||||
.vouchers {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
border-right: 1px solid var(--Base-Surface-Subtle-Normal);
|
||||
padding: var(--Spacing-x1) 0;
|
||||
}
|
||||
|
||||
.where {
|
||||
max-width: 280px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.options {
|
||||
|
||||
@@ -3,23 +3,25 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import Search from "./Search"
|
||||
|
||||
import styles from "./formContent.module.css"
|
||||
|
||||
export default function FormContent() {
|
||||
const intl = useIntl()
|
||||
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
|
||||
|
||||
const where = intl.formatMessage({ id: "Where to" })
|
||||
export default function FormContent({
|
||||
locations,
|
||||
}: BookingWidgetFormContentProps) {
|
||||
const intl = useIntl()
|
||||
const when = intl.formatMessage({ id: "When" })
|
||||
const rooms = intl.formatMessage({ id: "Rooms & Guests" })
|
||||
const vouchers = intl.formatMessage({ id: "Booking codes and vouchers" })
|
||||
const bonus = intl.formatMessage({ id: "Use bonus cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book reward night" })
|
||||
|
||||
return (
|
||||
<div className={styles.input}>
|
||||
<div className={styles.where}>
|
||||
<Caption color="red">{where}</Caption>
|
||||
<input type="text" placeholder={where} />
|
||||
<Search locations={locations} />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" textTransform="bold">
|
||||
|
||||
Reference in New Issue
Block a user