Files
web/apps/scandic-web/components/BookingWidget/Client.tsx
Joakim Jäderberg da07e8a458 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
2025-04-09 10:43:08 +00:00

254 lines
6.8 KiB
TypeScript

"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { cva } from "class-variance-authority"
import { use, useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { REDEMPTION } from "@/constants/booking"
import { dt } from "@/lib/dt"
import { trpc } from "@/lib/trpc/client"
import { StickyElementNameEnum } from "@/stores/sticky-position"
import Form, {
BookingWidgetFormSkeleton,
} from "@/components/Forms/BookingWidget"
import { bookingWidgetSchema } from "@/components/Forms/BookingWidget/schema"
import useLang from "@/hooks/useLang"
import useStickyPosition from "@/hooks/useStickyPosition"
import { debounce } from "@/utils/debounce"
import isValidJson from "@/utils/isValidJson"
import { convertSearchParamsToObj } from "@/utils/url"
import MobileToggleButton, {
MobileToggleButtonSkeleton,
} from "./MobileToggleButton"
import styles from "./bookingWidget.module.css"
import type {
BookingCodeSchema,
BookingWidgetClientProps,
BookingWidgetSchema,
BookingWidgetSearchData,
} from "@/types/components/bookingWidget"
export default function BookingWidgetClient({
type,
bookingWidgetSearchParams,
pageSettingsBookingCodePromise,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
bookingWidgetSearchParams
)
const shouldFetchAutoComplete = !!params.hotelId || !!params.city
const { data, isPending } = trpc.autocomplete.destinations.useQuery(
{
lang,
query: "",
selectedHotelId: params.hotelId,
selectedCity: params.city,
},
{ enabled: shouldFetchAutoComplete }
)
const shouldShowSkeleton = shouldFetchAutoComplete && isPending
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const 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.
let fromDate = dt(params.fromDate)
let toDate = dt(params.toDate)
const isDateParamValid =
fromDate.isValid() &&
toDate.isValid() &&
fromDate.isSameOrAfter(now, "day") &&
toDate.isAfter(fromDate)
if (!isDateParamValid) {
fromDate = now
toDate = now.add(1, "day")
}
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.
const selectedBookingCode =
params.bookingCode ||
(pageSettingsBookingCodePromise !== null
? use(pageSettingsBookingCodePromise)
: "")
const defaultRoomsData: BookingWidgetSchema["rooms"] = params.rooms?.map(
(room) => ({
adults: room.adults,
childrenInRoom: room.childrenInRoom || [],
})
) ?? [
{
adults: 1,
childrenInRoom: [],
},
]
const hotelId = isNaN(+params.hotelId) ? undefined : +params.hotelId
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? "",
// 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"),
},
bookingCode: {
value: selectedBookingCode,
remember: false,
},
redemption: params?.searchType === REDEMPTION,
rooms: defaultRoomsData,
city: params.city || undefined,
hotel: hotelId,
},
shouldFocusError: false,
mode: "onSubmit",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onSubmit",
})
useEffect(() => {
if (!selectedLocation) return
/*
If `trpc.hotel.locations.get.useQuery` hasn't been fetched previously and is hence async
we need to update the default values when data is available
*/
methods.setValue("search", selectedLocation.name)
methods.setValue("selectedSearch", selectedLocation.name)
}, [selectedLocation, methods])
function closeMobileSearch() {
setIsOpen(false)
document.body.style.overflowY = "visible"
}
function openMobileSearch() {
setIsOpen(true)
document.body.style.overflowY = "hidden"
}
useEffect(() => {
const observer = new ResizeObserver(
debounce(([entry]) => {
if (entry.contentRect.width > 1366) {
closeMobileSearch()
}
})
)
observer.observe(document.body)
return () => {
observer.unobserve(document.body)
}
}, [])
useEffect(() => {
if (!window?.sessionStorage || !window?.localStorage) return
if (!selectedBookingCode) {
const storedBookingCode = localStorage.getItem("bookingCode")
const initialBookingCode: BookingCodeSchema | undefined =
storedBookingCode && isValidJson(storedBookingCode)
? JSON.parse(storedBookingCode)
: undefined
initialBookingCode?.remember &&
methods.setValue("bookingCode", initialBookingCode)
}
}, [methods, selectedBookingCode])
useEffect(() => {
if (!selectedLocation) {
return
}
methods.setValue("search", selectedLocation.name)
}, [selectedLocation, methods])
if (shouldShowSkeleton) {
return <BookingWidgetSkeleton type={type} />
}
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<FormProvider {...methods}>
<section ref={bookingWidgetRef} className={classNames} data-open={isOpen}>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<MaterialIcon icon="close" />
</button>
<Form type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
</FormProvider>
)
}
export function BookingWidgetSkeleton({
type = "full",
}: {
type?: BookingWidgetClientProps["type"]
}) {
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<>
<section className={classNames} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton type={type} />
</div>
</section>
</>
)
}
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
variants: {
type: {
default: "",
full: "",
compact: styles.compact,
},
},
defaultVariants: {
type: "full",
},
})