Merged in feature/autocomplete-search (pull request #1725)
Feature/autocomplete search * wip autocomplete search * add skeletons to loading * Using aumlauts/accents when searching will still give results remove unused reducer sort autocomplete results * remove testcode * Add tests for autocomplete * cleanup tests * use node@20 * use node 22 * use node22 * merge fix: search button outside of viewport * merge * remove more unused code * fix: error message when empty search field in booking widget * fix: don't display empty white box when search field is empty and no searchHistory is present * merge * fix: set height of shimmer for search skeleton * rename autocomplete trpc -> destinationsAutocomplete * more accute cache key naming * fix: able to control wether bookingwidget is visible on startPage fix: sticky booking widget under alert * remove unused code * fix: skeletons fix: error overlay on search startpage * remove extra .nvmrc * merge Approved-by: Linus Flood
This commit is contained in:
@@ -6,14 +6,19 @@
|
||||
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;
|
||||
&:placeholder-shown::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
}
|
||||
|
||||
&:not(:placeholder-shown)::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("/_static/icons/close.svg");
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
|
||||
import { listItemVariants } from "./variants"
|
||||
|
||||
import type { ListItemProps } from "@/types/components/search"
|
||||
import type { SearchListProps } from "@/types/components/search"
|
||||
import type { AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||
|
||||
export interface ListItemProps
|
||||
extends Pick<SearchListProps, "getItemProps" | "highlightedIndex"> {
|
||||
index: number
|
||||
location: AutoCompleteLocation
|
||||
}
|
||||
|
||||
export default function ListItem({
|
||||
getItemProps,
|
||||
@@ -14,8 +22,6 @@ export default function ListItem({
|
||||
variant: index === highlightedIndex ? "active" : "default",
|
||||
})
|
||||
|
||||
const isCity = location.type === "cities"
|
||||
const isHotelLocation = location.type === "hotels"
|
||||
return (
|
||||
<li
|
||||
{...getItemProps({
|
||||
@@ -27,14 +33,35 @@ export default function ListItem({
|
||||
<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}
|
||||
{location.destination && (
|
||||
<Body color="uiTextPlaceholder">{location.destination}</Body>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListItemSkeleton() {
|
||||
const classNames = listItemVariants({
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
>
|
||||
<SkeletonShimmer width={"200px"} height="18px" display="block" />
|
||||
</Body>
|
||||
|
||||
<Body
|
||||
color="black"
|
||||
textTransform="bold"
|
||||
style={{ marginBottom: "0.25rem" }}
|
||||
>
|
||||
<SkeletonShimmer width={"70px"} height="18px" display="block" />
|
||||
</Body>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
|
||||
import Label from "./Label"
|
||||
import ListItem from "./ListItem"
|
||||
import ListItem, { ListItemSkeleton } from "./ListItem"
|
||||
|
||||
import styles from "./list.module.css"
|
||||
|
||||
import type { ListProps } from "@/types/components/search"
|
||||
import type { SearchListProps } from "@/types/components/search"
|
||||
import type { AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||
|
||||
interface ListProps
|
||||
extends Pick<SearchListProps, "getItemProps" | "highlightedIndex"> {
|
||||
initialIndex?: number
|
||||
label?: string
|
||||
locations: AutoCompleteLocation[]
|
||||
}
|
||||
|
||||
export default function List({
|
||||
getItemProps,
|
||||
@@ -30,3 +40,16 @@ export default function List({
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListSkeleton() {
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
<Label>
|
||||
<SkeletonShimmer width="50px" height="15px" display="block" />
|
||||
</Label>
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<ListItemSkeleton key={index} />
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useFormContext } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
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 useLang from "@/hooks/useLang"
|
||||
|
||||
import ClearSearchButton from "./ClearSearchButton"
|
||||
import Dialog from "./Dialog"
|
||||
import List from "./List"
|
||||
import List, { ListSkeleton } from "./List"
|
||||
|
||||
import styles from "./searchList.module.css"
|
||||
|
||||
@@ -24,10 +28,10 @@ export default function SearchList({
|
||||
handleClearSearchHistory,
|
||||
highlightedIndex,
|
||||
isOpen,
|
||||
locations,
|
||||
search,
|
||||
searchHistory,
|
||||
}: SearchListProps) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [hasMounted, setHasMounted] = useState(false)
|
||||
const {
|
||||
@@ -36,6 +40,26 @@ export default function SearchList({
|
||||
} = useFormContext()
|
||||
const searchError = errors["search"]
|
||||
|
||||
const [debouncedSearch, setDebouncedSearch] = useDebounceValue(search, 250)
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearch(search)
|
||||
}, [search, setDebouncedSearch])
|
||||
|
||||
const autocompleteQueryEnabled = !!debouncedSearch
|
||||
const {
|
||||
data: autocompleteData,
|
||||
isPending,
|
||||
isError,
|
||||
} = trpc.autocomplete.destinations.useQuery(
|
||||
{ query: debouncedSearch, lang },
|
||||
{ enabled: autocompleteQueryEnabled }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
clearErrors("search")
|
||||
}, [search, clearErrors])
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||
if (searchError) {
|
||||
@@ -61,77 +85,106 @@ export default function SearchList({
|
||||
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">
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{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">
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{intl.formatMessage({ id: "No results" })}
|
||||
</Caption>
|
||||
<Body>
|
||||
{intl.formatMessage({
|
||||
id: "We couldn't find a matching location for your search.",
|
||||
})}
|
||||
</Body>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
||||
if (searchError.message === "Required") {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({ id: "Enter destination or hotel" })}
|
||||
body={intl.formatMessage({
|
||||
id: "A destination or hotel name is needed to be able to search for a hotel room.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (searchError.type === "custom") {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({ id: "No results" })}
|
||||
body={intl.formatMessage({
|
||||
id: "We couldn't find a matching location for your search.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<SearchListError
|
||||
getMenuProps={getMenuProps}
|
||||
caption={intl.formatMessage({ id: "Unable to search" })}
|
||||
body={intl.formatMessage({
|
||||
id: "An error occurred while searching, please try again.",
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (locations.length) {
|
||||
const cities = locations.filter((location) => location.type === "cities")
|
||||
const hotels = locations.filter((location) => location.type === "hotels")
|
||||
if (
|
||||
(autocompleteQueryEnabled && isPending) ||
|
||||
(search !== debouncedSearch && search)
|
||||
) {
|
||||
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 getMenuProps={getMenuProps}>
|
||||
<ListSkeleton />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
if (!search && searchHistory?.length) {
|
||||
const hasAutocompleteItems =
|
||||
!!autocompleteData &&
|
||||
(autocompleteData.hits.cities.length > 0 ||
|
||||
autocompleteData.hits.hotels.length > 0)
|
||||
|
||||
if (!hasAutocompleteItems && debouncedSearch) {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const displaySearchHistory = !debouncedSearch && searchHistory?.length
|
||||
if (displaySearchHistory) {
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps}>
|
||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||
@@ -153,44 +206,49 @@ export default function SearchList({
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
if (!search) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
return (
|
||||
<Dialog getMenuProps={getMenuProps} variant="search">
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
label={intl.formatMessage({ id: "Cities" })}
|
||||
locations={autocompleteData?.hits.cities ?? []}
|
||||
/>
|
||||
<List
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
initialIndex={autocompleteData?.hits.cities.length}
|
||||
label={intl.formatMessage({ id: "Hotels" })}
|
||||
locations={autocompleteData?.hits.hotels ?? []}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function SearchListError({
|
||||
caption,
|
||||
body,
|
||||
getMenuProps,
|
||||
}: {
|
||||
caption: string
|
||||
body: string
|
||||
getMenuProps: SearchListProps["getMenuProps"]
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
className={`${styles.fadeOut} ${styles.searchError}`}
|
||||
getMenuProps={getMenuProps}
|
||||
variant="error"
|
||||
>
|
||||
<Caption className={styles.heading} color="red">
|
||||
<MaterialIcon icon="error" color="Icon/Interactive/Accent" />
|
||||
{caption}
|
||||
</Caption>
|
||||
<Body>{body}</Body>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.searchError {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
|
||||
@@ -1,199 +1,72 @@
|
||||
"use client"
|
||||
import Downshift from "downshift"
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from "react"
|
||||
import { type ChangeEvent, type FormEvent } from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { type AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||
|
||||
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 { useSearchHistory } from "./useSearchHistory"
|
||||
|
||||
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 } from "@/types/trpc/routers/hotel/locations"
|
||||
import type { SearchProps } from "@/types/components/search"
|
||||
|
||||
export default function Search({ locations, handlePressEnter }: SearchProps) {
|
||||
const { register, setValue, unregister, getValues } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
const SEARCH_TERM_NAME = "search"
|
||||
|
||||
export default function Search({ handlePressEnter }: SearchProps) {
|
||||
const { register, setValue } = useFormContext<BookingWidgetSchema>()
|
||||
const intl = useIntl()
|
||||
const value = useWatch<BookingWidgetSchema, "search">({ name: "search" })
|
||||
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 })
|
||||
}
|
||||
}
|
||||
const searchTerm = useWatch({ name: SEARCH_TERM_NAME })
|
||||
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||
useSearchHistory()
|
||||
|
||||
function handleOnChange(
|
||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||
) {
|
||||
const newValue = evt.currentTarget.value
|
||||
setValue("search", newValue)
|
||||
dispatchInputValue(value)
|
||||
setValue(SEARCH_TERM_NAME, newValue)
|
||||
}
|
||||
|
||||
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: AutoCompleteLocation | null) {
|
||||
if (!selectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
setValue("selectedSearch", selectedItem.name)
|
||||
setValue(SEARCH_TERM_NAME, selectedItem.name)
|
||||
insertSearchHistoryItem(selectedItem)
|
||||
|
||||
switch (selectedItem.type) {
|
||||
case "cities":
|
||||
setValue("hotel", undefined)
|
||||
setValue("city", selectedItem.name)
|
||||
break
|
||||
case "hotels":
|
||||
setValue("hotel", +selectedItem.id)
|
||||
setValue("city", undefined)
|
||||
break
|
||||
default:
|
||||
console.error("Unhandled type:", selectedItem.type)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnSelect(selectedItem: Location | null) {
|
||||
if (selectedItem) {
|
||||
const stringified = JSON.stringify(selectedItem)
|
||||
setValue("location", encodeURIComponent(stringified))
|
||||
sessionStorage.setItem(sessionStorageKey, stringified)
|
||||
setValue("search", 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: Location[] = [
|
||||
...getEnhancedSearchHistory([newHistoryItem], locations),
|
||||
...oldSearchHistoryWithoutTheNew,
|
||||
]
|
||||
dispatch({
|
||||
payload: {
|
||||
location: selectedItem,
|
||||
searchHistory: enhancedSearchHistory,
|
||||
},
|
||||
type: ActionType.SELECT_ITEM,
|
||||
})
|
||||
} else {
|
||||
sessionStorage.removeItem(sessionStorageKey)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!window?.sessionStorage || !window?.localStorage) return
|
||||
|
||||
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
|
||||
function handleClearSearchHistory() {
|
||||
clearHistory()
|
||||
}
|
||||
|
||||
return (
|
||||
<Downshift
|
||||
initialSelectedItem={state.searchData}
|
||||
inputValue={value}
|
||||
inputValue={searchTerm}
|
||||
itemToString={(value) => (value ? value.name : "")}
|
||||
onSelect={handleOnSelect}
|
||||
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
|
||||
defaultHighlightedIndex={0}
|
||||
>
|
||||
{({
|
||||
@@ -207,12 +80,8 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
||||
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: "search" })}
|
||||
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
||||
className={styles.label}
|
||||
>
|
||||
<Caption
|
||||
@@ -220,22 +89,22 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
||||
color={isOpen ? "uiTextActive" : "red"}
|
||||
asChild
|
||||
>
|
||||
<span>{getLocationLabel()}</span>
|
||||
<span>{intl.formatMessage({ id: "Where to?" })}</span>
|
||||
</Caption>
|
||||
</label>
|
||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||
<label className={styles.searchInput}>
|
||||
<Input
|
||||
{...getInputProps({
|
||||
id: "search",
|
||||
onFocus(evt) {
|
||||
handleOnFocus(evt)
|
||||
id: SEARCH_TERM_NAME,
|
||||
onFocus() {
|
||||
openMenu()
|
||||
},
|
||||
placeholder: intl.formatMessage({
|
||||
id: "Hotels & Destinations",
|
||||
}),
|
||||
...register("search", {
|
||||
value: searchTerm,
|
||||
...register(SEARCH_TERM_NAME, {
|
||||
onChange: handleOnChange,
|
||||
}),
|
||||
onKeyDown: (e) => {
|
||||
@@ -254,9 +123,8 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
||||
handleClearSearchHistory={handleClearSearchHistory}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isOpen={isOpen}
|
||||
locations={state.locations}
|
||||
search={state.search}
|
||||
searchHistory={state.searchHistory}
|
||||
search={searchTerm}
|
||||
searchHistory={searchHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -274,26 +142,8 @@ export function SearchSkeleton() {
|
||||
</Caption>
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
||||
</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: Location[]
|
||||
): Location[] {
|
||||
return searchHistory
|
||||
.map((historyItem) =>
|
||||
locations.find(
|
||||
(location) =>
|
||||
location.type === historyItem.type && location.id === historyItem.id
|
||||
)
|
||||
)
|
||||
.filter((r): r is Location => !!r)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import {
|
||||
type Action,
|
||||
ActionType,
|
||||
type InitState,
|
||||
type State,
|
||||
} from "@/types/components/form/bookingwidget"
|
||||
import type { Location } 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: Location[] = []
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -36,4 +36,10 @@
|
||||
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
||||
align-items: center;
|
||||
display: grid;
|
||||
|
||||
& input[type="search"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import {
|
||||
type AutoCompleteLocation,
|
||||
autoCompleteLocationSchema,
|
||||
} from "@/server/routers/autocomplete/schema"
|
||||
|
||||
export const SEARCH_HISTORY_LOCALSTORAGE_KEY = "searchHistory"
|
||||
export function useSearchHistory() {
|
||||
const MAX_HISTORY_LENGTH = 5
|
||||
|
||||
function getHistoryFromLocalStorage(): AutoCompleteLocation[] {
|
||||
const stringifiedHistory = localStorage.getItem(
|
||||
SEARCH_HISTORY_LOCALSTORAGE_KEY
|
||||
)
|
||||
|
||||
try {
|
||||
const parsedHistory = JSON.parse(stringifiedHistory ?? "[]")
|
||||
if (!Array.isArray(parsedHistory)) {
|
||||
throw new Error("Invalid search history format")
|
||||
}
|
||||
const existingHistory = parsedHistory.map((item) =>
|
||||
autoCompleteLocationSchema.parse(item)
|
||||
)
|
||||
|
||||
return existingHistory
|
||||
} catch (error) {
|
||||
console.error("Failed to parse search history:", error)
|
||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function updateSearchHistory(newItem: AutoCompleteLocation) {
|
||||
const existingHistory = getHistoryFromLocalStorage()
|
||||
const oldSearchHistoryWithoutTheNew = existingHistory.filter(
|
||||
(h) => h.type !== newItem.type || h.id !== newItem.id
|
||||
)
|
||||
|
||||
const updatedSearchHistory = [
|
||||
newItem,
|
||||
...oldSearchHistoryWithoutTheNew.slice(0, MAX_HISTORY_LENGTH - 1),
|
||||
]
|
||||
localStorage.setItem(
|
||||
SEARCH_HISTORY_LOCALSTORAGE_KEY,
|
||||
JSON.stringify(updatedSearchHistory)
|
||||
)
|
||||
|
||||
return updatedSearchHistory
|
||||
}
|
||||
|
||||
const [searchHistory, setSearchHistory] = useState<AutoCompleteLocation[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setSearchHistory(getHistoryFromLocalStorage())
|
||||
}, [])
|
||||
|
||||
function clearHistory() {
|
||||
localStorage.removeItem(SEARCH_HISTORY_LOCALSTORAGE_KEY)
|
||||
setSearchHistory([])
|
||||
}
|
||||
function insertSearchHistoryItem(
|
||||
newItem: AutoCompleteLocation
|
||||
): AutoCompleteLocation[] {
|
||||
const updatedHistory = updateSearchHistory(newItem)
|
||||
setSearchHistory(updatedHistory)
|
||||
return updatedHistory
|
||||
}
|
||||
|
||||
return {
|
||||
searchHistory,
|
||||
insertSearchHistoryItem,
|
||||
clearHistory,
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,19 @@
|
||||
}
|
||||
}
|
||||
.voucherContainer {
|
||||
height: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
.inputContainer {
|
||||
display: flex;
|
||||
@@ -115,6 +121,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
margin-top: auto;
|
||||
@media screen and (min-width: 768px) {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||
.input {
|
||||
flex-wrap: wrap;
|
||||
@@ -126,6 +139,7 @@
|
||||
}
|
||||
.buttonContainer {
|
||||
padding-right: var(--Layout-Tablet-Margin-Margin-min);
|
||||
margin: 0;
|
||||
}
|
||||
.input .buttonContainer .button {
|
||||
padding: var(--Spacing-x1);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
@@ -22,7 +23,6 @@ import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
|
||||
|
||||
export default function FormContent({
|
||||
locations,
|
||||
formId,
|
||||
onSubmit,
|
||||
isSearching,
|
||||
@@ -41,7 +41,7 @@ export default function FormContent({
|
||||
<div className={styles.input}>
|
||||
<div className={styles.inputContainer}>
|
||||
<div className={styles.where}>
|
||||
<Search locations={locations} handlePressEnter={onSubmit} />
|
||||
<Search handlePressEnter={onSubmit} />
|
||||
</div>
|
||||
<div className={styles.when}>
|
||||
<Caption color="red" type="bold">
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
|
||||
@@ -20,15 +20,10 @@ import type {
|
||||
BookingWidgetType,
|
||||
} from "@/types/components/bookingWidget"
|
||||
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
const formId = "booking-widget"
|
||||
|
||||
export default function Form({
|
||||
locations,
|
||||
type,
|
||||
onClose,
|
||||
}: BookingWidgetFormProps) {
|
||||
export default function Form({ type, onClose }: BookingWidgetFormProps) {
|
||||
const router = useRouter()
|
||||
const lang = useLang()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
@@ -37,19 +32,17 @@ export default function Form({
|
||||
type,
|
||||
})
|
||||
|
||||
const { handleSubmit, register, setValue } =
|
||||
useFormContext<BookingWidgetSchema>()
|
||||
const { handleSubmit, setValue } = useFormContext<BookingWidgetSchema>()
|
||||
|
||||
function onSubmit(data: BookingWidgetSchema) {
|
||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
||||
const type = data.city?.length ? "city" : "hotel"
|
||||
|
||||
const bookingFlowPage =
|
||||
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
||||
type === "city" ? selectHotel(lang) : selectRate(lang)
|
||||
const bookingWidgetParams = convertObjToSearchParams({
|
||||
rooms: data.rooms,
|
||||
...data.date,
|
||||
...(locationData.type == "cities"
|
||||
? { city: locationData.name }
|
||||
: { hotel: locationData.operaId || "" }),
|
||||
...(type === "city" ? { city: data.city } : { hotel: data.hotel }),
|
||||
...(data.bookingCode?.value
|
||||
? { bookingCode: data.bookingCode.value }
|
||||
: {}),
|
||||
@@ -76,9 +69,7 @@ export default function Form({
|
||||
className={styles.form}
|
||||
id={formId}
|
||||
>
|
||||
<input {...register("location")} type="hidden" />
|
||||
<FormContent
|
||||
locations={locations}
|
||||
formId={formId}
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
isSearching={isPending}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod"
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export const guestRoomSchema = z
|
||||
.object({
|
||||
@@ -70,29 +69,10 @@ export const bookingWidgetSchema = z
|
||||
fromDate: z.string(),
|
||||
toDate: z.string(),
|
||||
}),
|
||||
location: z.string().refine(
|
||||
(value) => {
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const parsedValue: Location = JSON.parse(decodeURIComponent(value))
|
||||
switch (parsedValue?.type) {
|
||||
case "cities":
|
||||
case "hotels":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ message: "Required" }
|
||||
),
|
||||
redemption: z.boolean().default(false),
|
||||
rooms: guestRoomsSchema,
|
||||
search: z.string({ coerce: true }).min(1, "Required"),
|
||||
selectedSearch: z.string().optional(),
|
||||
hotel: z.number().optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user