276 lines
7.9 KiB
TypeScript
276 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 && isValidJson(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>
|
|
)
|
|
}
|