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_ENVIRONMENT="test"
|
||||||
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0"
|
NEXT_PUBLIC_SENTRY_CLIENT_SAMPLERATE="0"
|
||||||
SITEMAP_SYNC_SECRET="test"
|
SITEMAP_SYNC_SECRET="test"
|
||||||
|
|
||||||
|
BRANCH="test"
|
||||||
@@ -16,7 +16,29 @@ export default async function BookingWidgetPage({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.contentType === PageContentTypeEnum.startPage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (params.contentType === PageContentTypeEnum.hotelPage) {
|
if (params.contentType === PageContentTypeEnum.hotelPage) {
|
||||||
|
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 hotelPageData = await getHotelPage()
|
||||||
|
|
||||||
const hotelData = await getHotel({
|
const hotelData = await getHotel({
|
||||||
@@ -26,20 +48,17 @@ export default async function BookingWidgetPage({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isMeetingSubpage =
|
const isMeetingSubpage =
|
||||||
hotelData?.additionalData.meetingRooms.nameInUrl === searchParams.subpage
|
hotelData?.additionalData.meetingRooms.nameInUrl === subpage
|
||||||
|
|
||||||
if (isMeetingSubpage) {
|
if (isMeetingSubpage) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotelPageParams = {
|
const hotelPageParams = {
|
||||||
bookingCode: searchParams.bookingCode ?? "",
|
bookingCode: bookingCode ?? "",
|
||||||
hotel: hotelData?.hotel.id ?? "",
|
hotel: hotelData?.hotel.id ?? "",
|
||||||
city: hotelData?.hotel.cityName ?? "",
|
city: hotelData?.hotel.cityName ?? "",
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BookingWidget bookingWidgetSearchParams={hotelPageParams} />
|
return <BookingWidget bookingWidgetSearchParams={hotelPageParams} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <BookingWidget bookingWidgetSearchParams={searchParams} />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import type {
|
|||||||
BookingWidgetSchema,
|
BookingWidgetSchema,
|
||||||
BookingWidgetSearchData,
|
BookingWidgetSearchData,
|
||||||
} from "@/types/components/bookingWidget"
|
} from "@/types/components/bookingWidget"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|
||||||
|
|
||||||
export default function BookingWidgetClient({
|
export default function BookingWidgetClient({
|
||||||
type,
|
type,
|
||||||
@@ -44,23 +43,29 @@ export default function BookingWidgetClient({
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const bookingWidgetRef = useRef(null)
|
const bookingWidgetRef = useRef(null)
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const {
|
|
||||||
data: locations,
|
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
|
||||||
isLoading,
|
bookingWidgetSearchParams
|
||||||
isSuccess,
|
)
|
||||||
} = trpc.hotel.locations.get.useQuery({
|
|
||||||
|
const shouldFetchAutoComplete = !!params.hotelId || !!params.city
|
||||||
|
|
||||||
|
const { data, isPending } = trpc.autocomplete.destinations.useQuery(
|
||||||
|
{
|
||||||
lang,
|
lang,
|
||||||
})
|
query: "",
|
||||||
|
selectedHotelId: params.hotelId,
|
||||||
|
selectedCity: params.city,
|
||||||
|
},
|
||||||
|
{ enabled: shouldFetchAutoComplete }
|
||||||
|
)
|
||||||
|
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
|
||||||
|
|
||||||
useStickyPosition({
|
useStickyPosition({
|
||||||
ref: bookingWidgetRef,
|
ref: bookingWidgetRef,
|
||||||
name: StickyElementNameEnum.BOOKING_WIDGET,
|
name: StickyElementNameEnum.BOOKING_WIDGET,
|
||||||
})
|
})
|
||||||
|
|
||||||
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
|
|
||||||
bookingWidgetSearchParams
|
|
||||||
)
|
|
||||||
|
|
||||||
const now = dt()
|
const now = dt()
|
||||||
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
|
// 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.
|
// 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")
|
toDate = now.add(1, "day")
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedLocation: Location | null = null
|
let selectedLocation =
|
||||||
|
data?.currentSelection.hotel ?? data?.currentSelection.city
|
||||||
if (params.hotelId) {
|
|
||||||
selectedLocation = getLocationObj(locations ?? [], params.hotelId)
|
|
||||||
} else if (params.city) {
|
|
||||||
selectedLocation = getLocationObj(locations ?? [], params.city)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if bookingCode is not provided in the search params,
|
// if bookingCode is not provided in the search params,
|
||||||
// we will fetch it from the page settings stored in Contentstack.
|
// we will fetch it from the page settings stored in Contentstack.
|
||||||
@@ -105,11 +105,12 @@ export default function BookingWidgetClient({
|
|||||||
childrenInRoom: [],
|
childrenInRoom: [],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
const hotelId = isNaN(+params.hotelId) ? undefined : +params.hotelId
|
||||||
const methods = useForm<BookingWidgetSchema>({
|
const methods = useForm<BookingWidgetSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
search: selectedLocation?.name ?? "",
|
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: {
|
date: {
|
||||||
fromDate: fromDate.format("YYYY-MM-DD"),
|
fromDate: fromDate.format("YYYY-MM-DD"),
|
||||||
toDate: toDate.format("YYYY-MM-DD"),
|
toDate: toDate.format("YYYY-MM-DD"),
|
||||||
@@ -120,6 +121,8 @@ export default function BookingWidgetClient({
|
|||||||
},
|
},
|
||||||
redemption: params?.searchType === REDEMPTION,
|
redemption: params?.searchType === REDEMPTION,
|
||||||
rooms: defaultRoomsData,
|
rooms: defaultRoomsData,
|
||||||
|
city: params.city || undefined,
|
||||||
|
hotel: hotelId,
|
||||||
},
|
},
|
||||||
shouldFocusError: false,
|
shouldFocusError: false,
|
||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
@@ -135,7 +138,7 @@ export default function BookingWidgetClient({
|
|||||||
we need to update the default values when data is available
|
we need to update the default values when data is available
|
||||||
*/
|
*/
|
||||||
methods.setValue("search", selectedLocation.name)
|
methods.setValue("search", selectedLocation.name)
|
||||||
methods.setValue("location", JSON.stringify(selectedLocation))
|
methods.setValue("selectedSearch", selectedLocation.name)
|
||||||
}, [selectedLocation, methods])
|
}, [selectedLocation, methods])
|
||||||
|
|
||||||
function closeMobileSearch() {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!window?.sessionStorage || !window?.localStorage) return
|
if (!window?.sessionStorage || !window?.localStorage) return
|
||||||
|
|
||||||
@@ -200,13 +182,16 @@ export default function BookingWidgetClient({
|
|||||||
}
|
}
|
||||||
}, [methods, selectedBookingCode])
|
}, [methods, selectedBookingCode])
|
||||||
|
|
||||||
if (isLoading) {
|
useEffect(() => {
|
||||||
return <BookingWidgetSkeleton type={type} />
|
if (!selectedLocation) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSuccess || !locations) {
|
methods.setValue("search", selectedLocation.name)
|
||||||
// TODO: handle error cases
|
}, [selectedLocation, methods])
|
||||||
return null
|
|
||||||
|
if (shouldShowSkeleton) {
|
||||||
|
return <BookingWidgetSkeleton type={type} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const classNames = bookingWidgetContainerVariants({
|
const classNames = bookingWidgetContainerVariants({
|
||||||
@@ -225,7 +210,7 @@ export default function BookingWidgetClient({
|
|||||||
>
|
>
|
||||||
<MaterialIcon icon="close" />
|
<MaterialIcon icon="close" />
|
||||||
</button>
|
</button>
|
||||||
<Form locations={locations} type={type} onClose={closeMobileSearch} />
|
<Form type={type} onClose={closeMobileSearch} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
<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, {
|
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
|
||||||
variants: {
|
variants: {
|
||||||
type: {
|
type: {
|
||||||
default: styles.default,
|
default: "",
|
||||||
full: styles.full,
|
full: "",
|
||||||
compact: styles.compact,
|
compact: styles.compact,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1000;
|
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"
|
import { FloatingBookingWidgetClient } from "./FloatingBookingWidgetClient"
|
||||||
|
|
||||||
@@ -7,7 +10,11 @@ import type { BookingWidgetProps } from "@/types/components/bookingWidget"
|
|||||||
export async function FloatingBookingWidget({
|
export async function FloatingBookingWidget({
|
||||||
bookingWidgetSearchParams,
|
bookingWidgetSearchParams,
|
||||||
}: Omit<BookingWidgetProps, "type">) {
|
}: Omit<BookingWidgetProps, "type">) {
|
||||||
console.log("DEBUG: FloatingBookingWidget", bookingWidgetSearchParams)
|
const isHidden = await isBookingWidgetHidden()
|
||||||
|
|
||||||
|
if (isHidden) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
let pageSettingsBookingCodePromise: Promise<string> | null = null
|
let pageSettingsBookingCodePromise: Promise<string> | null = null
|
||||||
if (!bookingWidgetSearchParams.bookingCode) {
|
if (!bookingWidgetSearchParams.bookingCode) {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { dt } from "@/lib/dt"
|
|||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import useLang from "@/hooks/useLang"
|
import useLang from "@/hooks/useLang"
|
||||||
import isValidJson from "@/utils/isValidJson"
|
|
||||||
|
|
||||||
import styles from "./button.module.css"
|
import styles from "./button.module.css"
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import type {
|
|||||||
BookingWidgetSchema,
|
BookingWidgetSchema,
|
||||||
BookingWidgetToggleButtonProps,
|
BookingWidgetToggleButtonProps,
|
||||||
} from "@/types/components/bookingWidget"
|
} from "@/types/components/bookingWidget"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|
||||||
|
|
||||||
export default function MobileToggleButton({
|
export default function MobileToggleButton({
|
||||||
openMobileSearch,
|
openMobileSearch,
|
||||||
@@ -28,20 +26,16 @@ export default function MobileToggleButton({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const date = useWatch<BookingWidgetSchema, "date">({ name: "date" })
|
const date = useWatch<BookingWidgetSchema, "date">({ name: "date" })
|
||||||
const location = useWatch<BookingWidgetSchema, "location">({
|
|
||||||
name: "location",
|
|
||||||
})
|
|
||||||
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
|
const rooms = useWatch<BookingWidgetSchema, "rooms">({ name: "rooms" })
|
||||||
|
const searchTerm = useWatch<BookingWidgetSchema, "search">({ name: "search" })
|
||||||
const parsedLocation: Location | null =
|
const selectedSearchTerm = useWatch<BookingWidgetSchema, "selectedSearch">({
|
||||||
location && isValidJson(location)
|
name: "selectedSearch",
|
||||||
? JSON.parse(decodeURIComponent(location))
|
})
|
||||||
: null
|
|
||||||
|
|
||||||
const selectedFromDate = dt(date.fromDate).locale(lang).format("D MMM")
|
const selectedFromDate = dt(date.fromDate).locale(lang).format("D MMM")
|
||||||
const selectedToDate = dt(date.toDate).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 totalNights = dt(date.toDate).diff(dt(date.fromDate), "days")
|
||||||
const totalRooms = rooms.length
|
const totalRooms = rooms.length
|
||||||
@@ -99,8 +93,8 @@ export default function MobileToggleButton({
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant={"Body/Paragraph/mdRegular"}>
|
<Typography variant={"Body/Paragraph/mdRegular"}>
|
||||||
<span className={styles.placeholder}>
|
<span className={styles.placeholder}>
|
||||||
{parsedLocation
|
{searchTerm
|
||||||
? parsedLocation.name
|
? searchTerm
|
||||||
: intl.formatMessage({ id: "Destination" })}
|
: intl.formatMessage({ id: "Destination" })}
|
||||||
</span>
|
</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -133,7 +127,7 @@ export default function MobileToggleButton({
|
|||||||
<>
|
<>
|
||||||
<span className={styles.block}>
|
<span className={styles.block}>
|
||||||
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
|
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
|
||||||
<span className={styles.blockLabel}>{parsedLocation?.name}</span>
|
<span className={styles.blockLabel}>{selectedSearchTerm}</span>
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
|
<Typography variant={"Body/Supporting text (caption)/smRegular"}>
|
||||||
<span className={styles.locationAndDate}>
|
<span className={styles.locationAndDate}>
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { use } from "react"
|
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 { JumpToClient } from "../Client"
|
||||||
|
|
||||||
import type { JumpToHistory } from "@/types/components/destinationOverviewPage/jumpTo"
|
|
||||||
import type { JumpToResolverProps } from "@/types/components/destinationOverviewPage/jumpTo/resolver"
|
import type { JumpToResolverProps } from "@/types/components/destinationOverviewPage/jumpTo/resolver"
|
||||||
|
|
||||||
export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
|
export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
|
||||||
const data = use(dataPromise)
|
const data = use(dataPromise)
|
||||||
|
|
||||||
const [history, setHistory, clearHistory] = useLocalStorage<JumpToHistory>(
|
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||||
localStorageKey,
|
useSearchHistory()
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null
|
return null
|
||||||
@@ -25,18 +21,20 @@ export function JumpToResolver({ dataPromise }: JumpToResolverProps) {
|
|||||||
return (
|
return (
|
||||||
<JumpToClient
|
<JumpToClient
|
||||||
data={data}
|
data={data}
|
||||||
history={history}
|
history={searchHistory}
|
||||||
onAction={(key) => {
|
onAction={(key) => {
|
||||||
const existsInHistory = history.find((h) => h.id === key)
|
|
||||||
if (!existsInHistory) {
|
|
||||||
const item = data.find((d) => d.id === key)
|
const item = data.find((d) => d.id === key)
|
||||||
if (item) {
|
if (!item) {
|
||||||
const { id, type } = item
|
return
|
||||||
// latest added should be shown first
|
|
||||||
const newHistory = [{ id, type }, ...history]
|
|
||||||
setHistory(newHistory)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insertSearchHistoryItem({
|
||||||
|
id: item.id,
|
||||||
|
name: item.displayName,
|
||||||
|
type: item.type,
|
||||||
|
searchTokens: [],
|
||||||
|
destination: item.description,
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
onClearHistory={() => {
|
onClearHistory={() => {
|
||||||
clearHistory()
|
clearHistory()
|
||||||
|
|||||||
@@ -6,15 +6,20 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
||||||
|
&:placeholder-shown::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
background-image: url("/_static/icons/close.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::-webkit-search-cancel-button {
|
&:not(:placeholder-shown)::-webkit-search-cancel-button {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-image: url("/_static/icons/close.svg");
|
background-image: url("/_static/icons/close.svg");
|
||||||
height: 20px;
|
height: 20px;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.input:disabled,
|
.input:disabled,
|
||||||
.input:disabled::placeholder {
|
.input:disabled::placeholder {
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
|
|
||||||
import { listItemVariants } from "./variants"
|
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({
|
export default function ListItem({
|
||||||
getItemProps,
|
getItemProps,
|
||||||
@@ -14,8 +22,6 @@ export default function ListItem({
|
|||||||
variant: index === highlightedIndex ? "active" : "default",
|
variant: index === highlightedIndex ? "active" : "default",
|
||||||
})
|
})
|
||||||
|
|
||||||
const isCity = location.type === "cities"
|
|
||||||
const isHotelLocation = location.type === "hotels"
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
{...getItemProps({
|
{...getItemProps({
|
||||||
@@ -27,14 +33,35 @@ export default function ListItem({
|
|||||||
<Body color="black" textTransform="bold">
|
<Body color="black" textTransform="bold">
|
||||||
{location.name}
|
{location.name}
|
||||||
</Body>
|
</Body>
|
||||||
{isCity && location?.country ? (
|
{location.destination && (
|
||||||
<Body color="uiTextPlaceholder">{location.country}</Body>
|
<Body color="uiTextPlaceholder">{location.destination}</Body>
|
||||||
) : null}
|
)}
|
||||||
{isHotelLocation && location.relationships.city?.name ? (
|
</li>
|
||||||
<Body color="uiTextPlaceholder">
|
)
|
||||||
{location.relationships.city.name}
|
}
|
||||||
</Body>
|
|
||||||
) : null}
|
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>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
|
|
||||||
import Label from "./Label"
|
import Label from "./Label"
|
||||||
import ListItem from "./ListItem"
|
import ListItem, { ListItemSkeleton } from "./ListItem"
|
||||||
|
|
||||||
import styles from "./list.module.css"
|
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({
|
export default function List({
|
||||||
getItemProps,
|
getItemProps,
|
||||||
@@ -30,3 +40,16 @@ export default function List({
|
|||||||
</ul>
|
</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 { useEffect, useState } from "react"
|
||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
import { useDebounceValue } from "usehooks-ts"
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||||
|
|
||||||
|
import { trpc } from "@/lib/trpc/client"
|
||||||
|
|
||||||
import Divider from "@/components/TempDesignSystem/Divider"
|
import Divider from "@/components/TempDesignSystem/Divider"
|
||||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||||
|
import useLang from "@/hooks/useLang"
|
||||||
|
|
||||||
import ClearSearchButton from "./ClearSearchButton"
|
import ClearSearchButton from "./ClearSearchButton"
|
||||||
import Dialog from "./Dialog"
|
import Dialog from "./Dialog"
|
||||||
import List from "./List"
|
import List, { ListSkeleton } from "./List"
|
||||||
|
|
||||||
import styles from "./searchList.module.css"
|
import styles from "./searchList.module.css"
|
||||||
|
|
||||||
@@ -24,10 +28,10 @@ export default function SearchList({
|
|||||||
handleClearSearchHistory,
|
handleClearSearchHistory,
|
||||||
highlightedIndex,
|
highlightedIndex,
|
||||||
isOpen,
|
isOpen,
|
||||||
locations,
|
|
||||||
search,
|
search,
|
||||||
searchHistory,
|
searchHistory,
|
||||||
}: SearchListProps) {
|
}: SearchListProps) {
|
||||||
|
const lang = useLang()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const [hasMounted, setHasMounted] = useState(false)
|
const [hasMounted, setHasMounted] = useState(false)
|
||||||
const {
|
const {
|
||||||
@@ -36,6 +40,26 @@ export default function SearchList({
|
|||||||
} = useFormContext()
|
} = useFormContext()
|
||||||
const searchError = errors["search"]
|
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(() => {
|
useEffect(() => {
|
||||||
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
let timeoutID: ReturnType<typeof setTimeout> | null = null
|
||||||
if (searchError) {
|
if (searchError) {
|
||||||
@@ -61,77 +85,106 @@ export default function SearchList({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchError && isSubmitted) {
|
if (searchError && isSubmitted && typeof searchError.message === "string") {
|
||||||
if (typeof searchError.message === "string") {
|
|
||||||
if (!isOpen) {
|
|
||||||
if (searchError.message === "Required") {
|
if (searchError.message === "Required") {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<SearchListError
|
||||||
className={styles.fadeOut}
|
|
||||||
getMenuProps={getMenuProps}
|
getMenuProps={getMenuProps}
|
||||||
variant="error"
|
caption={intl.formatMessage({ id: "Enter destination or hotel" })}
|
||||||
>
|
body={intl.formatMessage({
|
||||||
<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.",
|
id: "A destination or hotel name is needed to be able to search for a hotel room.",
|
||||||
})}
|
})}
|
||||||
</Body>
|
/>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
} else if (searchError.type === "custom") {
|
}
|
||||||
|
|
||||||
|
if (searchError.type === "custom") {
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<SearchListError
|
||||||
className={styles.fadeOut}
|
|
||||||
getMenuProps={getMenuProps}
|
getMenuProps={getMenuProps}
|
||||||
variant="error"
|
caption={intl.formatMessage({ id: "No results" })}
|
||||||
>
|
body={intl.formatMessage({
|
||||||
<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.",
|
id: "We couldn't find a matching location for your search.",
|
||||||
})}
|
})}
|
||||||
</Body>
|
/>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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) {
|
if (!isOpen) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (locations.length) {
|
if (
|
||||||
const cities = locations.filter((location) => location.type === "cities")
|
(autocompleteQueryEnabled && isPending) ||
|
||||||
const hotels = locations.filter((location) => location.type === "hotels")
|
(search !== debouncedSearch && search)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<Dialog getMenuProps={getMenuProps} variant="search">
|
<Dialog getMenuProps={getMenuProps}>
|
||||||
<List
|
<ListSkeleton />
|
||||||
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>
|
</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 (
|
return (
|
||||||
<Dialog getMenuProps={getMenuProps}>
|
<Dialog getMenuProps={getMenuProps}>
|
||||||
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
<Footnote color="uiTextPlaceholder" textTransform="uppercase">
|
||||||
@@ -153,44 +206,49 @@ export default function SearchList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (!search) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog getMenuProps={getMenuProps} variant="error">
|
<Dialog getMenuProps={getMenuProps} variant="search">
|
||||||
<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
|
<List
|
||||||
getItemProps={getItemProps}
|
getItemProps={getItemProps}
|
||||||
highlightedIndex={highlightedIndex}
|
highlightedIndex={highlightedIndex}
|
||||||
locations={searchHistory}
|
label={intl.formatMessage({ id: "Cities" })}
|
||||||
|
locations={autocompleteData?.hits.cities ?? []}
|
||||||
/>
|
/>
|
||||||
<Divider className={styles.divider} color="beige" />
|
<List
|
||||||
<ClearSearchButton
|
|
||||||
getItemProps={getItemProps}
|
getItemProps={getItemProps}
|
||||||
handleClearSearchHistory={handleClearSearchHistory}
|
|
||||||
highlightedIndex={highlightedIndex}
|
highlightedIndex={highlightedIndex}
|
||||||
index={searchHistory.length}
|
initialIndex={autocompleteData?.hits.cities.length}
|
||||||
|
label={intl.formatMessage({ id: "Hotels" })}
|
||||||
|
locations={autocompleteData?.hits.hotels ?? []}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
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 {
|
@keyframes fade-out {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -1,199 +1,72 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import Downshift from "downshift"
|
import Downshift from "downshift"
|
||||||
import {
|
import { type ChangeEvent, type FormEvent } from "react"
|
||||||
type ChangeEvent,
|
|
||||||
type FocusEvent,
|
|
||||||
type FormEvent,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useReducer,
|
|
||||||
} from "react"
|
|
||||||
import { useFormContext, useWatch } from "react-hook-form"
|
import { useFormContext, useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
|
import { type AutoCompleteLocation } from "@/server/routers/autocomplete/schema"
|
||||||
|
|
||||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||||
import isValidJson from "@/utils/isValidJson"
|
|
||||||
|
|
||||||
import { Input } from "../Input"
|
import { Input } from "../Input"
|
||||||
import { init, localStorageKey, reducer, sessionStorageKey } from "./reducer"
|
|
||||||
import SearchList from "./SearchList"
|
import SearchList from "./SearchList"
|
||||||
|
import { useSearchHistory } from "./useSearchHistory"
|
||||||
|
|
||||||
import styles from "./search.module.css"
|
import styles from "./search.module.css"
|
||||||
|
|
||||||
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
||||||
import {
|
import type { SearchProps } from "@/types/components/search"
|
||||||
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"
|
|
||||||
|
|
||||||
export default function Search({ locations, handlePressEnter }: SearchProps) {
|
const SEARCH_TERM_NAME = "search"
|
||||||
const { register, setValue, unregister, getValues } =
|
|
||||||
useFormContext<BookingWidgetSchema>()
|
export default function Search({ handlePressEnter }: SearchProps) {
|
||||||
|
const { register, setValue } = useFormContext<BookingWidgetSchema>()
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const value = useWatch<BookingWidgetSchema, "search">({ name: "search" })
|
const searchTerm = useWatch({ name: SEARCH_TERM_NAME })
|
||||||
const locationString = getValues("location")
|
const { searchHistory, insertSearchHistoryItem, clearHistory } =
|
||||||
const location =
|
useSearchHistory()
|
||||||
locationString && isValidJson(decodeURIComponent(locationString))
|
|
||||||
? JSON.parse(decodeURIComponent(locationString))
|
|
||||||
: null
|
|
||||||
const [state, dispatch] = useReducer(
|
|
||||||
reducer,
|
|
||||||
{ defaultLocations: locations, initialValue: value },
|
|
||||||
init
|
|
||||||
)
|
|
||||||
const handleMatchLocations = useCallback(
|
|
||||||
function (searchValue: string) {
|
|
||||||
return locations.filter((location) => {
|
|
||||||
return location.name.toLowerCase().includes(searchValue.toLowerCase())
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[locations]
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleClearSearchHistory() {
|
|
||||||
localStorage.removeItem(localStorageKey)
|
|
||||||
dispatch({ type: ActionType.CLEAR_HISTORY_LOCATIONS })
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchInputValue(inputValue: string) {
|
|
||||||
if (inputValue) {
|
|
||||||
dispatch({
|
|
||||||
payload: { search: inputValue },
|
|
||||||
type: ActionType.SEARCH_LOCATIONS,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
dispatch({ type: ActionType.CLEAR_SEARCH_LOCATIONS })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOnChange(
|
function handleOnChange(
|
||||||
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
evt: FormEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>
|
||||||
) {
|
) {
|
||||||
const newValue = evt.currentTarget.value
|
const newValue = evt.currentTarget.value
|
||||||
setValue("search", newValue)
|
setValue(SEARCH_TERM_NAME, newValue)
|
||||||
dispatchInputValue(value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOnFocus(evt: FocusEvent<HTMLInputElement>) {
|
function handleOnSelect(selectedItem: AutoCompleteLocation | null) {
|
||||||
const searchValue = evt.currentTarget.value
|
if (!selectedItem) {
|
||||||
if (searchValue) {
|
return
|
||||||
const matchingLocations = handleMatchLocations(searchValue)
|
|
||||||
if (matchingLocations.length) {
|
|
||||||
dispatch({
|
|
||||||
payload: { search: searchValue },
|
|
||||||
type: ActionType.SEARCH_LOCATIONS,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function handleClearSearchHistory() {
|
||||||
if (selectedItem) {
|
clearHistory()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Downshift
|
<Downshift
|
||||||
initialSelectedItem={state.searchData}
|
inputValue={searchTerm}
|
||||||
inputValue={value}
|
|
||||||
itemToString={(value) => (value ? value.name : "")}
|
itemToString={(value) => (value ? value.name : "")}
|
||||||
onSelect={handleOnSelect}
|
onSelect={handleOnSelect}
|
||||||
onInputValueChange={(inputValue) => dispatchInputValue(inputValue)}
|
|
||||||
defaultHighlightedIndex={0}
|
defaultHighlightedIndex={0}
|
||||||
>
|
>
|
||||||
{({
|
{({
|
||||||
@@ -207,12 +80,8 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
|||||||
openMenu,
|
openMenu,
|
||||||
}) => (
|
}) => (
|
||||||
<div className={styles.container}>
|
<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
|
<label
|
||||||
{...getLabelProps({ htmlFor: "search" })}
|
{...getLabelProps({ htmlFor: SEARCH_TERM_NAME })}
|
||||||
className={styles.label}
|
className={styles.label}
|
||||||
>
|
>
|
||||||
<Caption
|
<Caption
|
||||||
@@ -220,22 +89,22 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
|||||||
color={isOpen ? "uiTextActive" : "red"}
|
color={isOpen ? "uiTextActive" : "red"}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<span>{getLocationLabel()}</span>
|
<span>{intl.formatMessage({ id: "Where to?" })}</span>
|
||||||
</Caption>
|
</Caption>
|
||||||
</label>
|
</label>
|
||||||
<div {...getRootProps({}, { suppressRefError: true })}>
|
<div {...getRootProps({}, { suppressRefError: true })}>
|
||||||
<label className={styles.searchInput}>
|
<label className={styles.searchInput}>
|
||||||
<Input
|
<Input
|
||||||
{...getInputProps({
|
{...getInputProps({
|
||||||
id: "search",
|
id: SEARCH_TERM_NAME,
|
||||||
onFocus(evt) {
|
onFocus() {
|
||||||
handleOnFocus(evt)
|
|
||||||
openMenu()
|
openMenu()
|
||||||
},
|
},
|
||||||
placeholder: intl.formatMessage({
|
placeholder: intl.formatMessage({
|
||||||
id: "Hotels & Destinations",
|
id: "Hotels & Destinations",
|
||||||
}),
|
}),
|
||||||
...register("search", {
|
value: searchTerm,
|
||||||
|
...register(SEARCH_TERM_NAME, {
|
||||||
onChange: handleOnChange,
|
onChange: handleOnChange,
|
||||||
}),
|
}),
|
||||||
onKeyDown: (e) => {
|
onKeyDown: (e) => {
|
||||||
@@ -254,9 +123,8 @@ export default function Search({ locations, handlePressEnter }: SearchProps) {
|
|||||||
handleClearSearchHistory={handleClearSearchHistory}
|
handleClearSearchHistory={handleClearSearchHistory}
|
||||||
highlightedIndex={highlightedIndex}
|
highlightedIndex={highlightedIndex}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
locations={state.locations}
|
search={searchTerm}
|
||||||
search={state.search}
|
searchHistory={searchHistory}
|
||||||
searchHistory={state.searchHistory}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -274,26 +142,8 @@ export function SearchSkeleton() {
|
|||||||
</Caption>
|
</Caption>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<SkeletonShimmer width={"100%"} />
|
<SkeletonShimmer width={"100%"} display="block" height="16px" />
|
||||||
</div>
|
</div>
|
||||||
</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);
|
padding: var(--Spacing-x3) var(--Spacing-x-one-and-half) var(--Spacing-x-half);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: grid;
|
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 {
|
.voucherContainer {
|
||||||
height: 100%;
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.input {
|
.input {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
.inputContainer {
|
.inputContainer {
|
||||||
display: flex;
|
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) {
|
@media screen and (min-width: 768px) and (max-width: 1366px) {
|
||||||
.input {
|
.input {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -126,6 +139,7 @@
|
|||||||
}
|
}
|
||||||
.buttonContainer {
|
.buttonContainer {
|
||||||
padding-right: var(--Layout-Tablet-Margin-Margin-min);
|
padding-right: var(--Layout-Tablet-Margin-Margin-min);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
.input .buttonContainer .button {
|
.input .buttonContainer .button {
|
||||||
padding: var(--Spacing-x1);
|
padding: var(--Spacing-x1);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useFormContext, useWatch } from "react-hook-form"
|
import { useFormContext, useWatch } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -22,7 +23,6 @@ import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
|
|||||||
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
|
import type { BookingWidgetFormContentProps } from "@/types/components/form/bookingwidget"
|
||||||
|
|
||||||
export default function FormContent({
|
export default function FormContent({
|
||||||
locations,
|
|
||||||
formId,
|
formId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSearching,
|
isSearching,
|
||||||
@@ -41,7 +41,7 @@ export default function FormContent({
|
|||||||
<div className={styles.input}>
|
<div className={styles.input}>
|
||||||
<div className={styles.inputContainer}>
|
<div className={styles.inputContainer}>
|
||||||
<div className={styles.where}>
|
<div className={styles.where}>
|
||||||
<Search locations={locations} handlePressEnter={onSubmit} />
|
<Search handlePressEnter={onSubmit} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.when}>
|
<div className={styles.when}>
|
||||||
<Caption color="red" type="bold">
|
<Caption color="red" type="bold">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 767px) {
|
@media screen and (max-width: 767px) {
|
||||||
|
|||||||
@@ -20,15 +20,10 @@ import type {
|
|||||||
BookingWidgetType,
|
BookingWidgetType,
|
||||||
} from "@/types/components/bookingWidget"
|
} from "@/types/components/bookingWidget"
|
||||||
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|
||||||
|
|
||||||
const formId = "booking-widget"
|
const formId = "booking-widget"
|
||||||
|
|
||||||
export default function Form({
|
export default function Form({ type, onClose }: BookingWidgetFormProps) {
|
||||||
locations,
|
|
||||||
type,
|
|
||||||
onClose,
|
|
||||||
}: BookingWidgetFormProps) {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
@@ -37,19 +32,17 @@ export default function Form({
|
|||||||
type,
|
type,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { handleSubmit, register, setValue } =
|
const { handleSubmit, setValue } = useFormContext<BookingWidgetSchema>()
|
||||||
useFormContext<BookingWidgetSchema>()
|
|
||||||
|
|
||||||
function onSubmit(data: BookingWidgetSchema) {
|
function onSubmit(data: BookingWidgetSchema) {
|
||||||
const locationData: Location = JSON.parse(decodeURIComponent(data.location))
|
const type = data.city?.length ? "city" : "hotel"
|
||||||
|
|
||||||
const bookingFlowPage =
|
const bookingFlowPage =
|
||||||
locationData.type == "cities" ? selectHotel(lang) : selectRate(lang)
|
type === "city" ? selectHotel(lang) : selectRate(lang)
|
||||||
const bookingWidgetParams = convertObjToSearchParams({
|
const bookingWidgetParams = convertObjToSearchParams({
|
||||||
rooms: data.rooms,
|
rooms: data.rooms,
|
||||||
...data.date,
|
...data.date,
|
||||||
...(locationData.type == "cities"
|
...(type === "city" ? { city: data.city } : { hotel: data.hotel }),
|
||||||
? { city: locationData.name }
|
|
||||||
: { hotel: locationData.operaId || "" }),
|
|
||||||
...(data.bookingCode?.value
|
...(data.bookingCode?.value
|
||||||
? { bookingCode: data.bookingCode.value }
|
? { bookingCode: data.bookingCode.value }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -76,9 +69,7 @@ export default function Form({
|
|||||||
className={styles.form}
|
className={styles.form}
|
||||||
id={formId}
|
id={formId}
|
||||||
>
|
>
|
||||||
<input {...register("location")} type="hidden" />
|
|
||||||
<FormContent
|
<FormContent
|
||||||
locations={locations}
|
|
||||||
formId={formId}
|
formId={formId}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
isSearching={isPending}
|
isSearching={isPending}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { z } from "zod"
|
|||||||
import { REDEMPTION } from "@/constants/booking"
|
import { REDEMPTION } from "@/constants/booking"
|
||||||
|
|
||||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|
||||||
|
|
||||||
export const guestRoomSchema = z
|
export const guestRoomSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -70,29 +69,10 @@ export const bookingWidgetSchema = z
|
|||||||
fromDate: z.string(),
|
fromDate: z.string(),
|
||||||
toDate: 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),
|
redemption: z.boolean().default(false),
|
||||||
rooms: guestRoomsSchema,
|
rooms: guestRoomsSchema,
|
||||||
search: z.string({ coerce: true }).min(1, "Required"),
|
search: z.string({ coerce: true }).min(1, "Required"),
|
||||||
|
selectedSearch: z.string().optional(),
|
||||||
hotel: z.number().optional(),
|
hotel: z.number().optional(),
|
||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,12 +34,18 @@ export default function SiteWideAlert() {
|
|||||||
const updateHeight = useCallback(() => {
|
const updateHeight = useCallback(() => {
|
||||||
if (alertRef.current) {
|
if (alertRef.current) {
|
||||||
const height = alertRef.current.offsetHeight
|
const height = alertRef.current.offsetHeight
|
||||||
|
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
"--sitewide-alert-height",
|
"--sitewide-alert-height",
|
||||||
`${height}px`
|
`${height}px`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--sitewide-alert-sticky-height",
|
||||||
|
isAlarm ? `${height}px` : "0px"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isAlarm])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const alertElement = alertRef.current
|
const alertElement = alertRef.current
|
||||||
@@ -66,6 +72,7 @@ export default function SiteWideAlert() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id="sitewide-alert"
|
||||||
ref={alertRef}
|
ref={alertRef}
|
||||||
className={`${styles.sitewideAlert} ${isAlarm ? styles.alarm : ""}`}
|
className={`${styles.sitewideAlert} ${isAlarm ? styles.alarm : ""}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ const variants = cva(styles.shimmer, {
|
|||||||
light: styles.light,
|
light: styles.light,
|
||||||
dark: styles.dark,
|
dark: styles.dark,
|
||||||
},
|
},
|
||||||
|
display: {
|
||||||
|
block: styles.block,
|
||||||
|
"inline-block": styles.inlineBlock,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
contrast: "light",
|
contrast: "light",
|
||||||
|
display: "inline-block",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -19,22 +24,21 @@ export default function SkeletonShimmer({
|
|||||||
height,
|
height,
|
||||||
width,
|
width,
|
||||||
contrast = "light",
|
contrast = "light",
|
||||||
display = "initial",
|
display = "inline-block",
|
||||||
}: {
|
}: {
|
||||||
className?: string
|
className?: string
|
||||||
height?: string
|
height?: string
|
||||||
width?: string
|
width?: string
|
||||||
contrast?: "light" | "dark"
|
contrast?: "light" | "dark"
|
||||||
display?: "block" | "inline-block" | "initial"
|
display?: "block" | "inline-block"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cx(className, variants({ contrast }))}
|
className={cx(className, variants({ contrast, display }))}
|
||||||
style={{
|
style={{
|
||||||
height: height,
|
height: height,
|
||||||
width: width,
|
width: width,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
display: display,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* zero width space, allows for font styles to affect height */}
|
{/* zero width space, allows for font styles to affect height */}
|
||||||
|
|||||||
@@ -45,3 +45,10 @@
|
|||||||
transform: translateX(100%);
|
transform: translateX(100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.inlineBlock {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** Routers */
|
/** Routers */
|
||||||
|
import { autocompleteRouter } from "./routers/autocomplete"
|
||||||
import { bookingRouter } from "./routers/booking"
|
import { bookingRouter } from "./routers/booking"
|
||||||
import { contentstackRouter } from "./routers/contentstack"
|
import { contentstackRouter } from "./routers/contentstack"
|
||||||
import { hotelsRouter } from "./routers/hotels"
|
import { hotelsRouter } from "./routers/hotels"
|
||||||
@@ -14,6 +15,7 @@ export const appRouter = router({
|
|||||||
user: userRouter,
|
user: userRouter,
|
||||||
partner: partnerRouter,
|
partner: partnerRouter,
|
||||||
navigation: navitaionRouter,
|
navigation: navitaionRouter,
|
||||||
|
autocomplete: autocompleteRouter,
|
||||||
})
|
})
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter
|
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 =
|
export const contentStackUidWithServiceProcedure =
|
||||||
contentstackExtendedProcedureUID.concat(serviceProcedure)
|
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 { BookingWidgetType } from "@/types/components/bookingWidget"
|
||||||
import type { Location } from "@/types/trpc/routers/hotel/locations"
|
|
||||||
|
|
||||||
export interface BookingWidgetFormProps {
|
export interface BookingWidgetFormProps {
|
||||||
locations: Location[]
|
|
||||||
type?: BookingWidgetType
|
type?: BookingWidgetType
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingWidgetFormContentProps {
|
export interface BookingWidgetFormContentProps {
|
||||||
locations: Location[]
|
|
||||||
formId: string
|
formId: string
|
||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
isSearching: boolean
|
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 { PropGetters } from "downshift"
|
||||||
|
|
||||||
import type { dialogVariants } from "@/components/Forms/BookingWidget/FormContent/Search/SearchList/Dialog/variants"
|
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 {
|
export interface SearchProps {
|
||||||
locations: Location[]
|
|
||||||
handlePressEnter: () => void
|
handlePressEnter: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,24 +21,8 @@ export interface SearchListProps {
|
|||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
handleClearSearchHistory: () => void
|
handleClearSearchHistory: () => void
|
||||||
highlightedIndex: HighlightedIndex
|
highlightedIndex: HighlightedIndex
|
||||||
locations: Location[]
|
|
||||||
search: string
|
search: string
|
||||||
searchHistory: Location[] | null
|
searchHistory: AutoCompleteLocation[] | 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DialogProps
|
export interface DialogProps
|
||||||
|
|||||||
Reference in New Issue
Block a user