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:
Joakim Jäderberg
2025-04-09 10:43:08 +00:00
parent 7e6abe1f03
commit da07e8a458
40 changed files with 1024 additions and 666 deletions

View File

@@ -34,7 +34,6 @@ import type {
BookingWidgetSchema,
BookingWidgetSearchData,
} from "@/types/components/bookingWidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function BookingWidgetClient({
type,
@@ -44,23 +43,29 @@ export default function BookingWidgetClient({
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const {
data: locations,
isLoading,
isSuccess,
} = trpc.hotel.locations.get.useQuery({
lang,
})
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
bookingWidgetSearchParams
)
const shouldFetchAutoComplete = !!params.hotelId || !!params.city
const { data, isPending } = trpc.autocomplete.destinations.useQuery(
{
lang,
query: "",
selectedHotelId: params.hotelId,
selectedCity: params.city,
},
{ enabled: shouldFetchAutoComplete }
)
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
bookingWidgetSearchParams
)
const now = dt()
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
// this is fine as isDateParamValid will catch this and default the values accordingly.
@@ -78,13 +83,8 @@ export default function BookingWidgetClient({
toDate = now.add(1, "day")
}
let selectedLocation: Location | null = null
if (params.hotelId) {
selectedLocation = getLocationObj(locations ?? [], params.hotelId)
} else if (params.city) {
selectedLocation = getLocationObj(locations ?? [], params.city)
}
let selectedLocation =
data?.currentSelection.hotel ?? data?.currentSelection.city
// if bookingCode is not provided in the search params,
// we will fetch it from the page settings stored in Contentstack.
@@ -105,11 +105,12 @@ export default function BookingWidgetClient({
childrenInRoom: [],
},
]
const hotelId = isNaN(+params.hotelId) ? undefined : +params.hotelId
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? "",
location: selectedLocation ? JSON.stringify(selectedLocation) : undefined,
// Only used for displaying the selected location for mobile, not for actual form input
selectedSearch: selectedLocation?.name ?? "",
date: {
fromDate: fromDate.format("YYYY-MM-DD"),
toDate: toDate.format("YYYY-MM-DD"),
@@ -120,6 +121,8 @@ export default function BookingWidgetClient({
},
redemption: params?.searchType === REDEMPTION,
rooms: defaultRoomsData,
city: params.city || undefined,
hotel: hotelId,
},
shouldFocusError: false,
mode: "onSubmit",
@@ -135,7 +138,7 @@ export default function BookingWidgetClient({
we need to update the default values when data is available
*/
methods.setValue("search", selectedLocation.name)
methods.setValue("location", JSON.stringify(selectedLocation))
methods.setValue("selectedSearch", selectedLocation.name)
}, [selectedLocation, methods])
function closeMobileSearch() {
@@ -164,27 +167,6 @@ export default function BookingWidgetClient({
}
}, [])
useEffect(() => {
if (typeof window !== "undefined" && !selectedLocation) {
const sessionStorageSearchData = sessionStorage.getItem("searchData")
const initialSelectedLocation: Location | undefined =
sessionStorageSearchData && isValidJson(sessionStorageSearchData)
? JSON.parse(sessionStorageSearchData)
: undefined
if (initialSelectedLocation?.name) {
methods.setValue("search", initialSelectedLocation.name)
}
if (sessionStorageSearchData) {
methods.setValue(
"location",
encodeURIComponent(sessionStorageSearchData)
)
}
}
}, [methods, selectedLocation])
useEffect(() => {
if (!window?.sessionStorage || !window?.localStorage) return
@@ -200,13 +182,16 @@ export default function BookingWidgetClient({
}
}, [methods, selectedBookingCode])
if (isLoading) {
return <BookingWidgetSkeleton type={type} />
}
useEffect(() => {
if (!selectedLocation) {
return
}
if (!isSuccess || !locations) {
// TODO: handle error cases
return null
methods.setValue("search", selectedLocation.name)
}, [selectedLocation, methods])
if (shouldShowSkeleton) {
return <BookingWidgetSkeleton type={type} />
}
const classNames = bookingWidgetContainerVariants({
@@ -225,7 +210,7 @@ export default function BookingWidgetClient({
>
<MaterialIcon icon="close" />
</button>
<Form locations={locations} type={type} onClose={closeMobileSearch} />
<Form type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
@@ -254,30 +239,11 @@ export function BookingWidgetSkeleton({
)
}
function getLocationObj(locations: Location[], destination: string) {
try {
const location = locations.find((location) => {
if (location.type === "hotels") {
return location.operaId === destination
} else if (location.type === "cities") {
return location.name.toLowerCase() === destination.toLowerCase()
}
})
if (location) {
return location
}
} catch (_) {
// ignore any errors
}
return null
}
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
variants: {
type: {
default: styles.default,
full: styles.full,
default: "",
full: "",
compact: styles.compact,
},
},

View File

@@ -22,6 +22,7 @@
left: 0;
right: 0;
z-index: 1000;
margin-top: var(--sitewide-alert-sticky-height);
}
}
}

View File

@@ -1,4 +1,7 @@
import { getPageSettingsBookingCode } from "@/lib/trpc/memoizedRequests"
import {
getPageSettingsBookingCode,
isBookingWidgetHidden,
} from "@/lib/trpc/memoizedRequests"
import { FloatingBookingWidgetClient } from "./FloatingBookingWidgetClient"
@@ -7,7 +10,11 @@ import type { BookingWidgetProps } from "@/types/components/bookingWidget"
export async function FloatingBookingWidget({
bookingWidgetSearchParams,
}: Omit<BookingWidgetProps, "type">) {
console.log("DEBUG: FloatingBookingWidget", bookingWidgetSearchParams)
const isHidden = await isBookingWidgetHidden()
if (isHidden) {
return null
}
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!bookingWidgetSearchParams.bookingCode) {

View File

@@ -12,7 +12,6 @@ import { dt } from "@/lib/dt"
import SkeletonShimmer from "@/components/SkeletonShimmer"
import Divider from "@/components/TempDesignSystem/Divider"
import useLang from "@/hooks/useLang"
import isValidJson from "@/utils/isValidJson"
import styles from "./button.module.css"
@@ -20,7 +19,6 @@ import type {
BookingWidgetSchema,
BookingWidgetToggleButtonProps,
} from "@/types/components/bookingWidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function MobileToggleButton({
openMobileSearch,
@@ -28,20 +26,16 @@ export default function MobileToggleButton({
const intl = useIntl()
const lang = useLang()
const date = useWatch<BookingWidgetSchema, "date">({ name: "date" })
const location = useWatch<BookingWidgetSchema, "location">({
name: "location",
})
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
const parsedLocation: Location | null =
location && isValidJson(location)
? JSON.parse(decodeURIComponent(location))
: null
const searchTerm = useWatch<BookingWidgetSchema, "search">({ name: "search" })
const selectedSearchTerm = useWatch<BookingWidgetSchema, "selectedSearch">({
name: "selectedSearch",
})
const selectedFromDate = dt(date.fromDate).locale(lang).format("D MMM")
const selectedToDate = dt(date.toDate).locale(lang).format("D MMM")
const locationAndDateIsSet = parsedLocation && date
const locationAndDateIsSet = searchTerm && date
const totalNights = dt(date.toDate).diff(dt(date.fromDate), "days")
const totalRooms = rooms.length
@@ -99,8 +93,8 @@ export default function MobileToggleButton({
</Typography>
<Typography variant={"Body/Paragraph/mdRegular"}>
<span className={styles.placeholder}>
{parsedLocation
? parsedLocation.name
{searchTerm
? searchTerm
: intl.formatMessage({ id: "Destination" })}
</span>
</Typography>
@@ -133,7 +127,7 @@ export default function MobileToggleButton({
<>
<span className={styles.block}>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.blockLabel}>{parsedLocation?.name}</span>
<span className={styles.blockLabel}>{selectedSearchTerm}</span>
</Typography>
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
<span className={styles.locationAndDate}>

View File

@@ -1,22 +1,18 @@
"use client"
import { use } from "react"
import { useLocalStorage } from "usehooks-ts"
import { localStorageKey } from "@/components/Forms/BookingWidget/FormContent/Search/reducer"
import { useSearchHistory } from "@/components/Forms/BookingWidget/FormContent/Search/useSearchHistory"
import { JumpToClient } from "../Client"
import type { JumpToHistory } from "@/types/components/destinationOverviewPage/jumpTo"
import type { JumpToResolverProps } from "@/types/components/destinationOverviewPage/jumpTo/resolver"
export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
const data = use(dataPromise)
const [history, setHistory, clearHistory] = useLocalStorage<JumpToHistory>(
localStorageKey,
[]
)
const { searchHistory, insertSearchHistoryItem, clearHistory } =
useSearchHistory()
if (!data) {
return null
@@ -25,18 +21,20 @@ export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
return (
<JumpToClient
data={data}
history={history}
history={searchHistory}
onAction={(key) => {
const existsInHistory = history.find((h) => h.id === key)
if (!existsInHistory) {
const item = data.find((d) => d.id === key)
if (item) {
const { id, type } = item
// latest added should be shown first
const newHistory = [{ id, type }, ...history]
setHistory(newHistory)
}
const item = data.find((d) => d.id === key)
if (!item) {
return
}
insertSearchHistoryItem({
id: item.id,
name: item.displayName,
type: item.type,
searchTokens: [],
destination: item.description,
})
}}
onClearHistory={() => {
clearHistory()

View File

@@ -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,

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,3 +1,7 @@
.searchError {
white-space: normal;
}
@keyframes fade-out {
0% {
opacity: 1;

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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;
}
}

View File

@@ -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,
}
}

View File

@@ -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);

View File

@@ -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">

View File

@@ -7,6 +7,7 @@
.form {
display: grid;
height: 100%;
}
@media screen and (max-width: 767px) {

View File

@@ -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}

View File

@@ -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(),
})

View File

@@ -20,7 +20,7 @@ import MyPagesMenuContent, { useMyPagesNavigation } from "../MyPagesMenuContent"
import styles from "./myPagesMenu.module.css"
import { DropdownTypeEnum } from "@/types/components/dropdown/dropdown"
import type { FriendsMembership,User } from "@/types/user"
import type { FriendsMembership, User } from "@/types/user"
import type { LoyaltyLevel } from "@/server/routers/contentstack/loyaltyLevel/output"
export type MyPagesMenuProps = {

View File

@@ -34,12 +34,18 @@ export default function SiteWideAlert() {
const updateHeight = useCallback(() => {
if (alertRef.current) {
const height = alertRef.current.offsetHeight
document.documentElement.style.setProperty(
"--sitewide-alert-height",
`${height}px`
)
document.documentElement.style.setProperty(
"--sitewide-alert-sticky-height",
isAlarm ? `${height}px` : "0px"
)
}
}, [])
}, [isAlarm])
useEffect(() => {
const alertElement = alertRef.current
@@ -66,6 +72,7 @@ export default function SiteWideAlert() {
return (
<div
id="sitewide-alert"
ref={alertRef}
className={`${styles.sitewideAlert} ${isAlarm ? styles.alarm : ""}`}
>

View File

@@ -8,9 +8,14 @@ const variants = cva(styles.shimmer, {
light: styles.light,
dark: styles.dark,
},
display: {
block: styles.block,
"inline-block": styles.inlineBlock,
},
},
defaultVariants: {
contrast: "light",
display: "inline-block",
},
})
@@ -19,22 +24,21 @@ export default function SkeletonShimmer({
height,
width,
contrast = "light",
display = "initial",
display = "inline-block",
}: {
className?: string
height?: string
width?: string
contrast?: "light" | "dark"
display?: "block" | "inline-block" | "initial"
display?: "block" | "inline-block"
}) {
return (
<span
className={cx(className, variants({ contrast }))}
className={cx(className, variants({ contrast, display }))}
style={{
height: height,
width: width,
maxWidth: "100%",
display: display,
}}
>
{/* zero width space, allows for font styles to affect height */}

View File

@@ -45,3 +45,10 @@
transform: translateX(100%);
}
}
.block {
display: block;
}
.inlineBlock {
display: inline-block;
}