Files
web/components/Forms/BookingWidget/FormContent/Search/index.tsx
2024-12-17 14:21:48 +01:00

275 lines
7.9 KiB
TypeScript

"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 } 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, handlePressEnter }: SearchProps) {
const { register, setValue, unregister, getValues } =
useFormContext<BookingWidgetSchema>()
const intl = useIntl()
const value = useWatch({ name })
const locationString = getValues("location")
const location = locationString
? JSON.parse(decodeURIComponent(locationString))
: null
const [state, dispatch] = useReducer(
reducer,
{ defaultLocations: locations },
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 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)
}
}
useEffect(() => {
const searchData =
typeof window !== "undefined"
? sessionStorage.getItem(sessionStorageKey)
: undefined
const searchHistory =
typeof window !== "undefined"
? localStorage.getItem(localStorageKey)
: null
if (searchData || searchHistory) {
dispatch({
payload: {
searchData:
isValidJson(searchData) && searchData
? JSON.parse(searchData)
: undefined,
searchHistory:
isValidJson(searchHistory) && searchHistory
? JSON.parse(searchHistory)
: null,
},
type: ActionType.SET_STORAGE_DATA,
})
}
}, [dispatch])
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(() => {
sessionStorage.setItem(sessionStorageKey, locationString)
}, [locationString])
function getLocationLabel(): string {
if (location?.type === "hotels") {
return location?.relationships?.city?.name || ""
}
if (state.searchData?.type === "hotels") {
return state.searchData?.relationships?.city?.name || ""
}
return intl.formatMessage({ id: "Where to" })
}
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: "Destinations & hotels",
}),
...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 className={styles.input}>
<SkeletonShimmer />
</div>
</div>
)
}