From da07e8a458c71bafa113c2db0fd31ea3b8040f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20J=C3=A4derberg?= Date: Wed, 9 Apr 2025 10:43:08 +0000 Subject: [PATCH] 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 --- apps/scandic-web/.env.test | 2 + .../[contentType]/[uid]/page.tsx | 63 +++-- .../components/BookingWidget/Client.tsx | 108 +++----- .../FloatingBookingWidget.module.css | 1 + .../FloatingBookingWidget/index.tsx | 11 +- .../MobileToggleButton/index.tsx | 22 +- .../JumpTo/Resolver/index.tsx | 32 +-- .../FormContent/Input/input.module.css | 19 +- .../Search/SearchList/List/ListItem/index.tsx | 49 +++- .../Search/SearchList/List/index.tsx | 27 +- .../FormContent/Search/SearchList/index.tsx | 256 +++++++++++------- .../Search/SearchList/searchList.module.css | 4 + .../FormContent/Search/index.tsx | 242 ++++------------- .../FormContent/Search/reducer.ts | 98 ------- .../FormContent/Search/search.module.css | 6 + .../FormContent/Search/useSearchHistory.ts | 76 ++++++ .../FormContent/formContent.module.css | 16 +- .../Forms/BookingWidget/FormContent/index.tsx | 4 +- .../Forms/BookingWidget/form.module.css | 1 + .../components/Forms/BookingWidget/index.tsx | 21 +- .../components/Forms/BookingWidget/schema.ts | 22 +- .../Header/MainMenu/MyPagesMenu/index.tsx | 2 +- .../components/SitewideAlert/index.tsx | 9 +- .../components/SkeletonShimmer/index.tsx | 12 +- .../SkeletonShimmer/skeleton.module.css | 7 + apps/scandic-web/server/index.ts | 2 + .../routers/autocomplete/destinations.ts | 122 +++++++++ .../server/routers/autocomplete/index.ts | 7 + .../server/routers/autocomplete/schema.ts | 10 + .../util/filterLocationByQuery.test.ts | 93 +++++++ .../util/filterLocationByQuery.ts | 23 ++ .../autocomplete/util/getSearchTokens.test.ts | 53 ++++ .../autocomplete/util/getSearchTokens.ts | 37 +++ .../util/mapLocationToAutoCompleteLocation.ts | 21 ++ .../util/sortAutoCompleteLocations.test.ts | 106 ++++++++ .../util/sortAutocompleteLocations.ts | 17 ++ apps/scandic-web/server/trpc.ts | 2 - apps/scandic-web/types/DeepPartial.ts | 5 + .../types/components/form/bookingwidget.ts | 61 ----- apps/scandic-web/types/components/search.ts | 21 +- 40 files changed, 1024 insertions(+), 666 deletions(-) delete mode 100644 apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/reducer.ts create mode 100644 apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/useSearchHistory.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/destinations.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/index.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/schema.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.test.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.test.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/mapLocationToAutoCompleteLocation.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/sortAutoCompleteLocations.test.ts create mode 100644 apps/scandic-web/server/routers/autocomplete/util/sortAutocompleteLocations.ts create mode 100644 apps/scandic-web/types/DeepPartial.ts diff --git a/apps/scandic-web/.env.test b/apps/scandic-web/.env.test index aecfa89f9..4b34ca794 100644 --- a/apps/scandic-web/.env.test +++ b/apps/scandic-web/.env.test @@ -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" \ No newline at end of file diff --git a/apps/scandic-web/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx b/apps/scandic-web/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx index d34d7e826..446c8d5f2 100644 --- a/apps/scandic-web/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx +++ b/apps/scandic-web/app/[lang]/(live)/@bookingwidget/[contentType]/[uid]/page.tsx @@ -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 + return ( + + ) } return } + +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 +} diff --git a/apps/scandic-web/components/BookingWidget/Client.tsx b/apps/scandic-web/components/BookingWidget/Client.tsx index 48d7aa5df..68405a9fb 100644 --- a/apps/scandic-web/components/BookingWidget/Client.tsx +++ b/apps/scandic-web/components/BookingWidget/Client.tsx @@ -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( + 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( - 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({ 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 - } + useEffect(() => { + if (!selectedLocation) { + return + } - if (!isSuccess || !locations) { - // TODO: handle error cases - return null + methods.setValue("search", selectedLocation.name) + }, [selectedLocation, methods]) + + if (shouldShowSkeleton) { + return } const classNames = bookingWidgetContainerVariants({ @@ -225,7 +210,7 @@ export default function BookingWidgetClient({ > -
+
@@ -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, }, }, diff --git a/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/FloatingBookingWidget.module.css b/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/FloatingBookingWidget.module.css index 0986a105f..89fb44543 100644 --- a/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/FloatingBookingWidget.module.css +++ b/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/FloatingBookingWidget.module.css @@ -22,6 +22,7 @@ left: 0; right: 0; z-index: 1000; + margin-top: var(--sitewide-alert-sticky-height); } } } diff --git a/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/index.tsx b/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/index.tsx index 58e2c539a..0e13c2bba 100644 --- a/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/index.tsx +++ b/apps/scandic-web/components/BookingWidget/FloatingBookingWidget/index.tsx @@ -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) { - console.log("DEBUG: FloatingBookingWidget", bookingWidgetSearchParams) + const isHidden = await isBookingWidgetHidden() + + if (isHidden) { + return null + } let pageSettingsBookingCodePromise: Promise | null = null if (!bookingWidgetSearchParams.bookingCode) { diff --git a/apps/scandic-web/components/BookingWidget/MobileToggleButton/index.tsx b/apps/scandic-web/components/BookingWidget/MobileToggleButton/index.tsx index 5b06c2753..6b586b4be 100644 --- a/apps/scandic-web/components/BookingWidget/MobileToggleButton/index.tsx +++ b/apps/scandic-web/components/BookingWidget/MobileToggleButton/index.tsx @@ -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({ name: "date" }) - const location = useWatch({ - name: "location", - }) const rooms = useWatch({ name: "rooms" }) - - const parsedLocation: Location | null = - location && isValidJson(location) - ? JSON.parse(decodeURIComponent(location)) - : null + const searchTerm = useWatch({ name: "search" }) + const selectedSearchTerm = useWatch({ + 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({ - {parsedLocation - ? parsedLocation.name + {searchTerm + ? searchTerm : intl.formatMessage({ id: "Destination" })} @@ -133,7 +127,7 @@ export default function MobileToggleButton({ <> - {parsedLocation?.name} + {selectedSearchTerm} diff --git a/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver/index.tsx b/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver/index.tsx index 72d450afc..e948dec98 100644 --- a/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver/index.tsx +++ b/apps/scandic-web/components/ContentType/DestinationPage/DestinationOverviewPage/JumpTo/Resolver/index.tsx @@ -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( - localStorageKey, - [] - ) + const { searchHistory, insertSearchHistoryItem, clearHistory } = + useSearchHistory() if (!data) { return null @@ -25,18 +21,20 @@ export function JumpToResolver({ dataPromise }: JumpToResolverProps) { return ( { - 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() diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Input/input.module.css b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Input/input.module.css index 7a4f7b998..ad59c2449 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Input/input.module.css +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Input/input.module.css @@ -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, diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/ListItem/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/ListItem/index.tsx index 706f0d105..fea9c7633 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/ListItem/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/ListItem/index.tsx @@ -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 { + 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 (
  • {location.name} - {isCity && location?.country ? ( - {location.country} - ) : null} - {isHotelLocation && location.relationships.city?.name ? ( - - {location.relationships.city.name} - - ) : null} + {location.destination && ( + {location.destination} + )} +
  • + ) +} + +export function ListItemSkeleton() { + const classNames = listItemVariants({ + variant: "default", + }) + + return ( +
  • + + + + + + +
  • ) } diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/index.tsx index 4ed6998bb..4d85273b2 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/List/index.tsx @@ -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 { + initialIndex?: number + label?: string + locations: AutoCompleteLocation[] +} export default function List({ getItemProps, @@ -30,3 +40,16 @@ export default function List({ ) } + +export function ListSkeleton() { + return ( +
      + + {Array.from({ length: 2 }, (_, index) => ( + + ))} +
    + ) +} diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/index.tsx index b49aa0b06..85f45aa76 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/index.tsx @@ -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 | 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 ( - - - - {intl.formatMessage({ id: "Enter destination or hotel" })} - - - {intl.formatMessage({ - id: "A destination or hotel name is needed to be able to search for a hotel room.", - })} - - - ) - } else if (searchError.type === "custom") { - return ( - - - - {intl.formatMessage({ id: "No results" })} - - - {intl.formatMessage({ - id: "We couldn't find a matching location for your search.", - })} - - - ) - } - } + if (searchError && isSubmitted && typeof searchError.message === "string") { + if (searchError.message === "Required") { + return ( + + ) } + + if (searchError.type === "custom") { + return ( + + ) + } + } + + if (isError) { + return ( + + ) } 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 ( - - - + + ) } - if (!search && searchHistory?.length) { + const hasAutocompleteItems = + !!autocompleteData && + (autocompleteData.hits.cities.length > 0 || + autocompleteData.hits.hotels.length > 0) + + if (!hasAutocompleteItems && debouncedSearch) { + return ( + + + {intl.formatMessage({ id: "No results" })} + + + {intl.formatMessage({ + id: "We couldn't find a matching location for your search.", + })} + + {searchHistory && ( + <> + + + {intl.formatMessage({ id: "Latest searches" })} + + + + + + + )} + + ) + } + + const displaySearchHistory = !debouncedSearch && searchHistory?.length + if (displaySearchHistory) { return ( @@ -153,44 +206,49 @@ export default function SearchList({ ) } - if (search) { - return ( - - - {intl.formatMessage({ id: "No results" })} - - - {intl.formatMessage({ - id: "We couldn't find a matching location for your search.", - })} - - {searchHistory ? ( - <> - - - {intl.formatMessage({ id: "Latest searches" })} - - - - - - ) : null} - - ) + if (!search) { + return null } - return null + return ( + + + + + ) +} + +function SearchListError({ + caption, + body, + getMenuProps, +}: { + caption: string + body: string + getMenuProps: SearchListProps["getMenuProps"] +}) { + return ( + + + + {caption} + + {body} + + ) } diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/searchList.module.css b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/searchList.module.css index 6e8b4467a..9aeeecba2 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/searchList.module.css +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/SearchList/searchList.module.css @@ -1,3 +1,7 @@ +.searchError { + white-space: normal; +} + @keyframes fade-out { 0% { opacity: 1; diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/index.tsx index d29c6cd3a..8420c210b 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/index.tsx @@ -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() +const SEARCH_TERM_NAME = "search" + +export default function Search({ handlePressEnter }: SearchProps) { + const { register, setValue } = useFormContext() const intl = useIntl() - const value = useWatch({ 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 | ChangeEvent ) { const newValue = evt.currentTarget.value - setValue("search", newValue) - dispatchInputValue(value) + setValue(SEARCH_TERM_NAME, newValue) } - function handleOnFocus(evt: FocusEvent) { - 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 ( (value ? value.name : "")} onSelect={handleOnSelect} - onInputValueChange={(inputValue) => dispatchInputValue(inputValue)} defaultHighlightedIndex={0} > {({ @@ -207,12 +80,8 @@ export default function Search({ locations, handlePressEnter }: SearchProps) { openMenu, }) => (
    - {value ? ( - // Adding hidden input to define hotel or city based on destination selection for basic form submit. - - ) : null}
    )} @@ -274,26 +142,8 @@ export function SearchSkeleton() {
    - +
    ) } - -/** - * 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) -} diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/reducer.ts b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/reducer.ts deleted file mode 100644 index 42c9b2110..000000000 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/reducer.ts +++ /dev/null @@ -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 - } -} diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/search.module.css b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/search.module.css index bac2e5c14..fe10a1c23 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/search.module.css +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/search.module.css @@ -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; + } } diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/useSearchHistory.ts b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/useSearchHistory.ts new file mode 100644 index 000000000..6af12fa03 --- /dev/null +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/Search/useSearchHistory.ts @@ -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([]) + + 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, + } +} diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/formContent.module.css b/apps/scandic-web/components/Forms/BookingWidget/FormContent/formContent.module.css index 70bf30857..cbf757386 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/formContent.module.css +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/formContent.module.css @@ -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); diff --git a/apps/scandic-web/components/Forms/BookingWidget/FormContent/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/FormContent/index.tsx index 593cdb48e..744885db1 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/FormContent/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/FormContent/index.tsx @@ -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({
    - +
    diff --git a/apps/scandic-web/components/Forms/BookingWidget/form.module.css b/apps/scandic-web/components/Forms/BookingWidget/form.module.css index 79676b9a0..17d9d7987 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/form.module.css +++ b/apps/scandic-web/components/Forms/BookingWidget/form.module.css @@ -7,6 +7,7 @@ .form { display: grid; + height: 100%; } @media screen and (max-width: 767px) { diff --git a/apps/scandic-web/components/Forms/BookingWidget/index.tsx b/apps/scandic-web/components/Forms/BookingWidget/index.tsx index d11ca0d08..f81f4e2c9 100644 --- a/apps/scandic-web/components/Forms/BookingWidget/index.tsx +++ b/apps/scandic-web/components/Forms/BookingWidget/index.tsx @@ -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() + const { handleSubmit, setValue } = useFormContext() 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} > - { - 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(), }) diff --git a/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx b/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx index 1f55f37ea..50318fc63 100644 --- a/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx +++ b/apps/scandic-web/components/Header/MainMenu/MyPagesMenu/index.tsx @@ -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 = { diff --git a/apps/scandic-web/components/SitewideAlert/index.tsx b/apps/scandic-web/components/SitewideAlert/index.tsx index 973a84705..b4adc20f7 100644 --- a/apps/scandic-web/components/SitewideAlert/index.tsx +++ b/apps/scandic-web/components/SitewideAlert/index.tsx @@ -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 (
    diff --git a/apps/scandic-web/components/SkeletonShimmer/index.tsx b/apps/scandic-web/components/SkeletonShimmer/index.tsx index 7ce56fda8..1fae4b6f9 100644 --- a/apps/scandic-web/components/SkeletonShimmer/index.tsx +++ b/apps/scandic-web/components/SkeletonShimmer/index.tsx @@ -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 ( {/* zero width space, allows for font styles to affect height */} diff --git a/apps/scandic-web/components/SkeletonShimmer/skeleton.module.css b/apps/scandic-web/components/SkeletonShimmer/skeleton.module.css index 4adf0dfbe..6a5165eec 100644 --- a/apps/scandic-web/components/SkeletonShimmer/skeleton.module.css +++ b/apps/scandic-web/components/SkeletonShimmer/skeleton.module.css @@ -45,3 +45,10 @@ transform: translateX(100%); } } + +.block { + display: block; +} +.inlineBlock { + display: inline-block; +} diff --git a/apps/scandic-web/server/index.ts b/apps/scandic-web/server/index.ts index 52d357e38..8437d1387 100644 --- a/apps/scandic-web/server/index.ts +++ b/apps/scandic-web/server/index.ts @@ -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 diff --git a/apps/scandic-web/server/routers/autocomplete/destinations.ts b/apps/scandic-web/server/routers/autocomplete/destinations.ts new file mode 100644 index 000000000..e92e18388 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/destinations.ts @@ -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 => { + 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 +} diff --git a/apps/scandic-web/server/routers/autocomplete/index.ts b/apps/scandic-web/server/routers/autocomplete/index.ts new file mode 100644 index 000000000..75dbeedf5 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/index.ts @@ -0,0 +1,7 @@ +import { router } from "@/server/trpc" + +import { getDestinationsAutoCompleteRoute } from "./destinations" + +export const autocompleteRouter = router({ + destinations: getDestinationsAutoCompleteRoute, +}) diff --git a/apps/scandic-web/server/routers/autocomplete/schema.ts b/apps/scandic-web/server/routers/autocomplete/schema.ts new file mode 100644 index 000000000..679e8b07f --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/schema.ts @@ -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 diff --git a/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.test.ts b/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.test.ts new file mode 100644 index 000000000..2dd3450e6 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.test.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + searchTokens: ["beach", "luxury", "grand hotel", "stockholm"], + } + + expect( + filterLocationByQuery({ + location: location as AutoCompleteLocation, + query: "luxury beach", + }) + ).toBe(true) + }) +}) diff --git a/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.ts b/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.ts new file mode 100644 index 000000000..931cdb410 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/filterLocationByQuery.ts @@ -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)) + ) +} diff --git a/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.test.ts b/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.test.ts new file mode 100644 index 000000000..d9c0432e7 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.test.ts @@ -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 = { + 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 = { + 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 = { + keyWords: ["", "Valid"], + name: "", + type: "hotels", + relationships: { city: { name: "" } }, + } + + const result = getSearchTokens(location as Location) + expect(result).toEqual(["valid"]) + }) +}) diff --git a/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.ts b/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.ts new file mode 100644 index 000000000..4fa20bd48 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/getSearchTokens.ts @@ -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 +} diff --git a/apps/scandic-web/server/routers/autocomplete/util/mapLocationToAutoCompleteLocation.ts b/apps/scandic-web/server/routers/autocomplete/util/mapLocationToAutoCompleteLocation.ts new file mode 100644 index 000000000..ad77f9c79 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/mapLocationToAutoCompleteLocation.ts @@ -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, + } +} diff --git a/apps/scandic-web/server/routers/autocomplete/util/sortAutoCompleteLocations.test.ts b/apps/scandic-web/server/routers/autocomplete/util/sortAutoCompleteLocations.test.ts new file mode 100644 index 000000000..77695cb91 --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/sortAutoCompleteLocations.test.ts @@ -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[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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[] = [ + { 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", + ]) + }) +}) diff --git a/apps/scandic-web/server/routers/autocomplete/util/sortAutocompleteLocations.ts b/apps/scandic-web/server/routers/autocomplete/util/sortAutocompleteLocations.ts new file mode 100644 index 000000000..de973f0cb --- /dev/null +++ b/apps/scandic-web/server/routers/autocomplete/util/sortAutocompleteLocations.ts @@ -0,0 +1,17 @@ +import type { AutoCompleteLocation } from "../schema" + +export function sortAutocompleteLocations( + 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) + }) +} diff --git a/apps/scandic-web/server/trpc.ts b/apps/scandic-web/server/trpc.ts index d53dcd1d0..44a30fda9 100644 --- a/apps/scandic-web/server/trpc.ts +++ b/apps/scandic-web/server/trpc.ts @@ -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) diff --git a/apps/scandic-web/types/DeepPartial.ts b/apps/scandic-web/types/DeepPartial.ts new file mode 100644 index 000000000..e11e638f5 --- /dev/null +++ b/apps/scandic-web/types/DeepPartial.ts @@ -0,0 +1,5 @@ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial + } + : T diff --git a/apps/scandic-web/types/components/form/bookingwidget.ts b/apps/scandic-web/types/components/form/bookingwidget.ts index 87c93b5a7..dc6f16418 100644 --- a/apps/scandic-web/types/components/form/bookingwidget.ts +++ b/apps/scandic-web/types/components/form/bookingwidget.ts @@ -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 { - initialValue?: string -} diff --git a/apps/scandic-web/types/components/search.ts b/apps/scandic-web/types/components/search.ts index 0574b8514..22afc04a8 100644 --- a/apps/scandic-web/types/components/search.ts +++ b/apps/scandic-web/types/components/search.ts @@ -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 { - index: number - location: Location + searchHistory: AutoCompleteLocation[] | null } export interface DialogProps