Files
web/components/Forms/BookingWidget/FormContent/Search/index.tsx
Michael Zetterberg 1d822dad5a Merged in fix/labels-english (pull request #1249)
fix(i18n): validated English messages

Approved-by: Bianca Widstam
Approved-by: Christian Andolf
2025-02-06 20:54:25 +00:00

297 lines
8.7 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,
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
}