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:
@@ -55,3 +55,5 @@ SHOW_SITE_WIDE_ALERT="false"
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT="test"
|
||||
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0"
|
||||
SITEMAP_SYNC_SECRET="test"
|
||||
|
||||
BRANCH="test"
|
||||
@@ -16,30 +16,49 @@ export default async function BookingWidgetPage({
|
||||
return null
|
||||
}
|
||||
|
||||
if (params.contentType === PageContentTypeEnum.startPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (params.contentType === PageContentTypeEnum.hotelPage) {
|
||||
const hotelPageData = await getHotelPage()
|
||||
|
||||
const hotelData = await getHotel({
|
||||
hotelId: hotelPageData?.hotel_page_id || "",
|
||||
language: getLang(),
|
||||
isCardOnlyPayment: false,
|
||||
})
|
||||
|
||||
const isMeetingSubpage =
|
||||
hotelData?.additionalData.meetingRooms.nameInUrl === searchParams.subpage
|
||||
|
||||
if (isMeetingSubpage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hotelPageParams = {
|
||||
bookingCode: searchParams.bookingCode ?? "",
|
||||
hotel: hotelData?.hotel.id ?? "",
|
||||
city: hotelData?.hotel.cityName ?? "",
|
||||
}
|
||||
|
||||
return <BookingWidget bookingWidgetSearchParams={hotelPageParams} />
|
||||
return (
|
||||
<BookingWidgetOnHotelPage
|
||||
bookingCode={searchParams.bookingCode}
|
||||
subpage={searchParams.subpage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <BookingWidget bookingWidgetSearchParams={searchParams} />
|
||||
}
|
||||
|
||||
async function BookingWidgetOnHotelPage({
|
||||
bookingCode,
|
||||
subpage,
|
||||
}: {
|
||||
bookingCode?: string
|
||||
subpage: string
|
||||
}) {
|
||||
const hotelPageData = await getHotelPage()
|
||||
|
||||
const hotelData = await getHotel({
|
||||
hotelId: hotelPageData?.hotel_page_id || "",
|
||||
language: getLang(),
|
||||
isCardOnlyPayment: false,
|
||||
})
|
||||
|
||||
const isMeetingSubpage =
|
||||
hotelData?.additionalData.meetingRooms.nameInUrl === subpage
|
||||
|
||||
if (isMeetingSubpage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hotelPageParams = {
|
||||
bookingCode: bookingCode ?? "",
|
||||
hotel: hotelData?.hotel.id ?? "",
|
||||
city: hotelData?.hotel.cityName ?? "",
|
||||
}
|
||||
|
||||
return <BookingWidget bookingWidgetSearchParams={hotelPageParams} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
margin-top: var(--sitewide-alert-sticky-height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 : ""}`}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -45,3 +45,10 @@
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.inlineBlock {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Routers */
|
||||
import { autocompleteRouter } from "./routers/autocomplete"
|
||||
import { bookingRouter } from "./routers/booking"
|
||||
import { contentstackRouter } from "./routers/contentstack"
|
||||
import { hotelsRouter } from "./routers/hotels"
|
||||
@@ -14,6 +15,7 @@ export const appRouter = router({
|
||||
user: userRouter,
|
||||
partner: partnerRouter,
|
||||
navigation: navitaionRouter,
|
||||
autocomplete: autocompleteRouter,
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
|
||||
122
apps/scandic-web/server/routers/autocomplete/destinations.ts
Normal file
122
apps/scandic-web/server/routers/autocomplete/destinations.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
import { safeProtectedServiceProcedure } from "@/server/trpc"
|
||||
|
||||
import { getCacheClient } from "@/services/dataCache"
|
||||
|
||||
import { getCitiesByCountry, getCountries, getLocations } from "../hotels/utils"
|
||||
import { filterLocationByQuery } from "./util/filterLocationByQuery"
|
||||
import { mapLocationToAutoCompleteLocation } from "./util/mapLocationToAutoCompleteLocation"
|
||||
import { sortAutocompleteLocations } from "./util/sortAutocompleteLocations"
|
||||
|
||||
import type { AutoCompleteLocation } from "./schema"
|
||||
|
||||
const destinationsAutoCompleteInputSchema = z.object({
|
||||
query: z.string(),
|
||||
selectedHotelId: z.string().optional(),
|
||||
selectedCity: z.string().optional(),
|
||||
lang: z.nativeEnum(Lang),
|
||||
})
|
||||
|
||||
type DestinationsAutoCompleteOutput = {
|
||||
hits: {
|
||||
hotels: AutoCompleteLocation[]
|
||||
cities: AutoCompleteLocation[]
|
||||
}
|
||||
currentSelection: {
|
||||
hotel: (AutoCompleteLocation & { type: "hotels" }) | null
|
||||
city: (AutoCompleteLocation & { type: "cities" }) | null
|
||||
}
|
||||
}
|
||||
|
||||
export const getDestinationsAutoCompleteRoute = safeProtectedServiceProcedure
|
||||
.input(destinationsAutoCompleteInputSchema)
|
||||
.query(async ({ ctx, input }): Promise<DestinationsAutoCompleteOutput> => {
|
||||
const cacheClient = await getCacheClient()
|
||||
|
||||
const locations: AutoCompleteLocation[] = await cacheClient.cacheOrGet(
|
||||
`autocomplete:destinations:locations:${input.lang}`,
|
||||
async () => {
|
||||
const lang = input.lang || ctx.lang
|
||||
const countries = await getCountries({
|
||||
lang: lang,
|
||||
serviceToken: ctx.serviceToken,
|
||||
})
|
||||
|
||||
if (!countries) {
|
||||
throw new Error("Unable to fetch countries")
|
||||
}
|
||||
const countryNames = countries.data.map((country) => country.name)
|
||||
const citiesByCountry = await getCitiesByCountry({
|
||||
countries: countryNames,
|
||||
serviceToken: ctx.serviceToken,
|
||||
lang,
|
||||
})
|
||||
|
||||
const locations = await getLocations({
|
||||
lang: lang,
|
||||
serviceToken: ctx.serviceToken,
|
||||
citiesByCountry: citiesByCountry,
|
||||
})
|
||||
|
||||
return locations
|
||||
.map(mapLocationToAutoCompleteLocation)
|
||||
.filter(isDefined)
|
||||
},
|
||||
"1d"
|
||||
)
|
||||
|
||||
const filteredLocations = locations.filter((location) =>
|
||||
filterLocationByQuery({ location, query: input.query })
|
||||
)
|
||||
|
||||
const selectedHotel = locations.find(
|
||||
(location) =>
|
||||
location.type === "hotels" && location.id === input.selectedHotelId
|
||||
)
|
||||
|
||||
const selectedCity = locations.find(
|
||||
(location) =>
|
||||
location.type === "cities" && location.name === input.selectedCity
|
||||
)
|
||||
|
||||
const sortedCities = sortAutocompleteLocations(
|
||||
filteredLocations.filter(isCity),
|
||||
input.query
|
||||
)
|
||||
|
||||
const sortedHotels = sortAutocompleteLocations(
|
||||
filteredLocations.filter(isHotel),
|
||||
input.query
|
||||
)
|
||||
|
||||
return {
|
||||
hits: {
|
||||
cities: sortedCities,
|
||||
hotels: sortedHotels,
|
||||
},
|
||||
currentSelection: {
|
||||
city: isCity(selectedCity) ? selectedCity : null,
|
||||
hotel: isHotel(selectedHotel) ? selectedHotel : null,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
function isHotel(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "hotels" } {
|
||||
return !!location && location.type === "hotels"
|
||||
}
|
||||
|
||||
function isCity(
|
||||
location: AutoCompleteLocation | null | undefined
|
||||
): location is AutoCompleteLocation & { type: "cities" } {
|
||||
return !!location && location.type === "cities"
|
||||
}
|
||||
|
||||
function isDefined(
|
||||
value: AutoCompleteLocation | null | undefined
|
||||
): value is AutoCompleteLocation {
|
||||
return !!value
|
||||
}
|
||||
7
apps/scandic-web/server/routers/autocomplete/index.ts
Normal file
7
apps/scandic-web/server/routers/autocomplete/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { router } from "@/server/trpc"
|
||||
|
||||
import { getDestinationsAutoCompleteRoute } from "./destinations"
|
||||
|
||||
export const autocompleteRouter = router({
|
||||
destinations: getDestinationsAutoCompleteRoute,
|
||||
})
|
||||
10
apps/scandic-web/server/routers/autocomplete/schema.ts
Normal file
10
apps/scandic-web/server/routers/autocomplete/schema.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const autoCompleteLocationSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["cities", "hotels"]),
|
||||
searchTokens: z.array(z.string()),
|
||||
destination: z.string(),
|
||||
})
|
||||
export type AutoCompleteLocation = z.infer<typeof autoCompleteLocationSchema>
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "@jest/globals"
|
||||
|
||||
import { filterLocationByQuery } from "./filterLocationByQuery"
|
||||
|
||||
import type { DeepPartial } from "@/types/DeepPartial"
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
describe("filterLocationByQuery", () => {
|
||||
it("should return false if the query is too short", () => {
|
||||
const location: DeepPartial<AutoCompleteLocation> = {
|
||||
searchTokens: ["beach", "luxury"],
|
||||
}
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: " a ",
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: " ",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true if one of the search tokens includes part of a valid query token", () => {
|
||||
const location: DeepPartial<AutoCompleteLocation> = {
|
||||
searchTokens: ["beach", "grand hotel", "stockholm"],
|
||||
}
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: "Bea",
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: "hotel",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false if none of the search tokens include a valid query token", () => {
|
||||
const location: DeepPartial<AutoCompleteLocation> = {
|
||||
searchTokens: ["beach", "grand hotel", "stockholm"],
|
||||
}
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: "xyz",
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: "garbage",
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it("should correctly handle queries with punctuation and extra spaces", () => {
|
||||
const location: DeepPartial<AutoCompleteLocation> = {
|
||||
searchTokens: ["grand hotel", "stockholm"],
|
||||
}
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: " Grand Hotel! ",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should work with queries containing multiple valid tokens", () => {
|
||||
const location: DeepPartial<AutoCompleteLocation> = {
|
||||
searchTokens: ["beach", "luxury", "grand hotel", "stockholm"],
|
||||
}
|
||||
|
||||
expect(
|
||||
filterLocationByQuery({
|
||||
location: location as AutoCompleteLocation,
|
||||
query: "luxury beach",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
export function filterLocationByQuery({
|
||||
location,
|
||||
query,
|
||||
}: {
|
||||
location: AutoCompleteLocation
|
||||
query: string
|
||||
}) {
|
||||
const queryable = query
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-zÀ-ÖØ-öø-ÿ0-9\s]/g, "") // Only keep alphanumeric characters and it's accents
|
||||
.substring(0, 30)
|
||||
.split(/\s+/)
|
||||
.filter((s) => s.length > 2)
|
||||
|
||||
if (queryable.length === 0) return false
|
||||
|
||||
return location.searchTokens?.some((token) =>
|
||||
queryable.some((q) => token.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "@jest/globals"
|
||||
|
||||
import { getSearchTokens } from "./getSearchTokens"
|
||||
|
||||
import type { DeepPartial } from "@/types/DeepPartial"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
describe("getSearchTokens", () => {
|
||||
it("should return lowercased tokens for a hotel location", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["Beach", "Luxury"],
|
||||
name: "Grand Hotel",
|
||||
type: "hotels",
|
||||
relationships: { city: { name: "Stockholm" } },
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result).toEqual(["beach", "luxury", "grand hotel", "stockholm"])
|
||||
})
|
||||
|
||||
it("should generate additional tokens for diacritics replacement on a non-hotel location", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["Ångström", "Café"],
|
||||
name: "München",
|
||||
country: "Frånce",
|
||||
type: "cities",
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result).toEqual([
|
||||
"ångström",
|
||||
"café",
|
||||
"münchen",
|
||||
"frånce",
|
||||
"angstrom",
|
||||
"cafe",
|
||||
"munchen",
|
||||
"france",
|
||||
])
|
||||
})
|
||||
|
||||
it("should filter out empty or falsey tokens", () => {
|
||||
const location: DeepPartial<Location> = {
|
||||
keyWords: ["", "Valid"],
|
||||
name: "",
|
||||
type: "hotels",
|
||||
relationships: { city: { name: "" } },
|
||||
}
|
||||
|
||||
const result = getSearchTokens(location as Location)
|
||||
expect(result).toEqual(["valid"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export function getSearchTokens(location: Location) {
|
||||
const tokens = [
|
||||
...(location.keyWords?.map((x) => x.toLocaleLowerCase()) ?? []),
|
||||
location.name,
|
||||
location.type === "hotels"
|
||||
? location.relationships.city.name
|
||||
: location.country,
|
||||
]
|
||||
.filter(hasValue)
|
||||
.map((x) => x.toLocaleLowerCase())
|
||||
|
||||
const additionalTokens: string[] = []
|
||||
|
||||
tokens.forEach((token) => {
|
||||
const replaced = token
|
||||
.replace(/å/g, "a")
|
||||
.replace(/ä/g, "a")
|
||||
.replace(/ö/g, "o")
|
||||
.replace(/æ/g, "a")
|
||||
.replace(/ø/g, "o")
|
||||
.replace(/é/g, "e")
|
||||
.replace(/ü/g, "u")
|
||||
if (replaced !== token) {
|
||||
additionalTokens.push(replaced)
|
||||
}
|
||||
})
|
||||
|
||||
const allTokens = [...new Set([...tokens, ...additionalTokens])]
|
||||
|
||||
return allTokens
|
||||
}
|
||||
|
||||
function hasValue(value: string | null | undefined): value is string {
|
||||
return !!value && value.length > 0
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getSearchTokens } from "./getSearchTokens"
|
||||
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
export function mapLocationToAutoCompleteLocation(
|
||||
location: Location | null | undefined
|
||||
): AutoCompleteLocation | null {
|
||||
if (!location) return null
|
||||
|
||||
return {
|
||||
id: location.id,
|
||||
name: location.name,
|
||||
type: location.type,
|
||||
searchTokens: getSearchTokens(location),
|
||||
destination:
|
||||
location.type === "hotels"
|
||||
? location.relationships.city.name
|
||||
: location.country,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@jest/globals"
|
||||
|
||||
import { sortAutocompleteLocations } from "./sortAutocompleteLocations"
|
||||
|
||||
import type { DeepPartial } from "@/types/DeepPartial"
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
describe("sortAutocompleteLocations", () => {
|
||||
it("should put locations with names starting with the query at the top", () => {
|
||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
||||
{ name: "Paris Hotel" },
|
||||
{ name: "London Inn" },
|
||||
{ name: "paradise Resort" },
|
||||
{ name: "Berlin Lodge" },
|
||||
]
|
||||
const query = "par"
|
||||
|
||||
const sorted = sortAutocompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
||||
"paradise Resort",
|
||||
"Paris Hotel",
|
||||
"Berlin Lodge",
|
||||
"London Inn",
|
||||
])
|
||||
})
|
||||
|
||||
it("should sort locations alphabetically if both start with the query", () => {
|
||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
||||
{ name: "Alpha Place" },
|
||||
{ name: "alphabet City" },
|
||||
]
|
||||
const query = "al"
|
||||
const sorted = sortAutocompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
||||
"Alpha Place",
|
||||
"alphabet City",
|
||||
])
|
||||
})
|
||||
|
||||
it("should sort locations alphabetically if neither name starts with the query", () => {
|
||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
||||
{ name: "Zenith" },
|
||||
{ name: "apple orchard" },
|
||||
{ name: "Mountain Retreat" },
|
||||
]
|
||||
const query = "xyz"
|
||||
const sorted = sortAutocompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
||||
"apple orchard",
|
||||
"Mountain Retreat",
|
||||
"Zenith",
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle an empty query by sorting alphabetically", () => {
|
||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
||||
{ name: "Delta" },
|
||||
{ name: "Alpha" },
|
||||
{ name: "Charlie" },
|
||||
{ name: "Bravo" },
|
||||
]
|
||||
const query = ""
|
||||
const sorted = sortAutocompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(sorted.map((loc) => loc.name)).toEqual([
|
||||
"Alpha",
|
||||
"Bravo",
|
||||
"Charlie",
|
||||
"Delta",
|
||||
])
|
||||
})
|
||||
|
||||
it("should be case-insensitive when sorting names", () => {
|
||||
const locations: DeepPartial<AutoCompleteLocation>[] = [
|
||||
{ name: "Mountain Cabin" },
|
||||
{ name: "Beachside Villa" },
|
||||
{ name: "beach House" },
|
||||
]
|
||||
const query = "beach"
|
||||
const sorted = sortAutocompleteLocations(
|
||||
locations as AutoCompleteLocation[],
|
||||
query
|
||||
)
|
||||
|
||||
expect(sorted.map((x) => x.name)).toEqual([
|
||||
"beach House",
|
||||
"Beachside Villa",
|
||||
"Mountain Cabin",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AutoCompleteLocation } from "../schema"
|
||||
|
||||
export function sortAutocompleteLocations<T extends AutoCompleteLocation>(
|
||||
locations: T[],
|
||||
query: string
|
||||
) {
|
||||
return locations.toSorted((a, b) => {
|
||||
const queryLower = query.toLowerCase()
|
||||
const aStarts = a.name.toLowerCase().startsWith(queryLower)
|
||||
const bStarts = b.name.toLowerCase().startsWith(queryLower)
|
||||
|
||||
if (aStarts && !bStarts) return -1
|
||||
if (!aStarts && bStarts) return 1
|
||||
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
@@ -195,8 +195,6 @@ export const protectedServerActionProcedure = serverActionProcedure.use(
|
||||
}
|
||||
)
|
||||
|
||||
// NOTE: This is actually safe to use, just the implementation could change
|
||||
// in minor version bumps. Please read: https://trpc.io/docs/faq#unstable
|
||||
export const contentStackUidWithServiceProcedure =
|
||||
contentstackExtendedProcedureUID.concat(serviceProcedure)
|
||||
|
||||
|
||||
5
apps/scandic-web/types/DeepPartial.ts
Normal file
5
apps/scandic-web/types/DeepPartial.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>
|
||||
}
|
||||
: T
|
||||
@@ -1,73 +1,12 @@
|
||||
import type { BookingWidgetType } from "@/types/components/bookingWidget"
|
||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
||||
|
||||
export interface BookingWidgetFormProps {
|
||||
locations: Location[]
|
||||
type?: BookingWidgetType
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export interface BookingWidgetFormContentProps {
|
||||
locations: Location[]
|
||||
formId: string
|
||||
onSubmit: () => void
|
||||
isSearching: boolean
|
||||
}
|
||||
|
||||
export enum ActionType {
|
||||
CLEAR_HISTORY_LOCATIONS = "CLEAR_HISTORY_LOCATIONS",
|
||||
CLEAR_SEARCH_LOCATIONS = "CLEAR_SEARCH_LOCATIONS",
|
||||
SEARCH_LOCATIONS = "SEARCH_LOCATIONS",
|
||||
SELECT_ITEM = "SELECT_ITEM",
|
||||
SET_STORAGE_DATA = "SET_STORAGE_DATA",
|
||||
}
|
||||
|
||||
interface ClearHistoryLocationsAction {
|
||||
type: ActionType.CLEAR_HISTORY_LOCATIONS
|
||||
}
|
||||
|
||||
interface ClearSearchLocationsAction {
|
||||
type: ActionType.CLEAR_SEARCH_LOCATIONS
|
||||
}
|
||||
|
||||
interface SearchLocationsAction {
|
||||
payload: {
|
||||
search: string
|
||||
}
|
||||
type: ActionType.SEARCH_LOCATIONS
|
||||
}
|
||||
|
||||
interface SetItemAction {
|
||||
payload: {
|
||||
location: Location
|
||||
searchHistory: Location[]
|
||||
}
|
||||
type: ActionType.SELECT_ITEM
|
||||
}
|
||||
|
||||
export interface SetStorageData {
|
||||
payload: {
|
||||
searchData?: Location
|
||||
searchHistory?: Location[]
|
||||
}
|
||||
type: ActionType.SET_STORAGE_DATA
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| ClearHistoryLocationsAction
|
||||
| ClearSearchLocationsAction
|
||||
| SearchLocationsAction
|
||||
| SetItemAction
|
||||
| SetStorageData
|
||||
|
||||
export interface State {
|
||||
defaultLocations: Location[]
|
||||
locations: Location[]
|
||||
search: string
|
||||
searchData: Location | undefined
|
||||
searchHistory: Location[] | null
|
||||
}
|
||||
|
||||
export interface InitState extends Pick<State, "defaultLocations"> {
|
||||
initialValue?: string
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { VariantProps } from "class-variance-authority"
|
||||
import type { PropGetters } from "downshift"
|
||||
|
||||
import type { dialogVariants } from "@/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/variants"
|
||||
import type { Location } from "../trpc/routers/hotel/locations"
|
||||
import type { AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||
|
||||
export interface SearchProps {
|
||||
locations: Location[]
|
||||
handlePressEnter: () => void
|
||||
}
|
||||
|
||||
@@ -22,24 +21,8 @@ export interface SearchListProps {
|
||||
isOpen: boolean
|
||||
handleClearSearchHistory: () => void
|
||||
highlightedIndex: HighlightedIndex
|
||||
locations: Location[]
|
||||
search: string
|
||||
searchHistory: Location[] | null
|
||||
}
|
||||
|
||||
export interface ListProps
|
||||
extends Pick<
|
||||
SearchListProps,
|
||||
"getItemProps" | "highlightedIndex" | "locations"
|
||||
> {
|
||||
initialIndex?: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface ListItemProps
|
||||
extends Pick<SearchListProps, "getItemProps" | "highlightedIndex"> {
|
||||
index: number
|
||||
location: Location
|
||||
searchHistory: AutoCompleteLocation[] | null
|
||||
}
|
||||
|
||||
export interface DialogProps
|
||||
|
||||
Reference in New Issue
Block a user