265 lines
7.9 KiB
TypeScript
265 lines
7.9 KiB
TypeScript
"use client"
|
|
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { useSearchParams } from "next/navigation"
|
|
import { use, useEffect, useRef, useState } from "react"
|
|
import { FormProvider, useForm } from "react-hook-form"
|
|
|
|
import { dt } from "@scandic-hotels/common/dt"
|
|
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
|
|
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
|
|
import { debounce } from "@scandic-hotels/common/utils/debounce"
|
|
import isValidJson from "@scandic-hotels/common/utils/isValidJson"
|
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
import { trpc } from "@scandic-hotels/trpc/client"
|
|
import { SEARCH_TYPE_REDEMPTION } from "@scandic-hotels/trpc/constants/booking"
|
|
|
|
import { useBookingFlowConfig } from "../../bookingFlowConfig/bookingFlowConfigContext"
|
|
import { useBookingWidgetState } from "../../hooks/useBookingWidgetState"
|
|
import useLang from "../../hooks/useLang"
|
|
import {
|
|
type bookingCodeSchema,
|
|
bookingWidgetSchema,
|
|
} from "./BookingWidgetForm/schema"
|
|
import Form from "./BookingWidgetForm"
|
|
import MobileToggleButton from "./MobileToggleButton"
|
|
import { BookingWidgetSkeleton } from "./Skeleton"
|
|
import {
|
|
bookingWidgetContainerVariants,
|
|
formContainerVariants,
|
|
} from "./variant"
|
|
|
|
import styles from "./bookingWidget.module.css"
|
|
|
|
import type { z } from "zod"
|
|
|
|
import type { BookingWidgetSearchData, BookingWidgetType } from "."
|
|
|
|
export type BookingWidgetSchema = z.output<typeof bookingWidgetSchema>
|
|
export type BookingCodeSchema = z.output<typeof bookingCodeSchema>
|
|
|
|
export type BookingWidgetClientProps = {
|
|
type?: BookingWidgetType
|
|
data: BookingWidgetSearchData
|
|
pageSettingsBookingCodePromise: Promise<string> | null
|
|
}
|
|
export default function BookingWidgetClient({
|
|
type,
|
|
data,
|
|
pageSettingsBookingCodePromise,
|
|
}: BookingWidgetClientProps) {
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const bookingWidgetRef = useRef(null)
|
|
const lang = useLang()
|
|
const [originalOverflowY, setOriginalOverflowY] = useState<string | null>(
|
|
null
|
|
)
|
|
const bookingFlowConfig = useBookingFlowConfig()
|
|
const storedBookingWidgetState = useBookingWidgetState()
|
|
|
|
const shouldFetchAutoComplete = !!data.hotelId || !!data.city
|
|
|
|
const { data: destinationsData, isPending } =
|
|
trpc.autocomplete.destinations.useQuery(
|
|
{
|
|
lang,
|
|
query: "",
|
|
includeTypes: ["hotels", "cities"],
|
|
selectedHotelId: data.hotelId ? data.hotelId.toString() : undefined,
|
|
selectedCity: data.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(data.fromDate)
|
|
let toDate = dt(data.toDate)
|
|
|
|
const isDateParamValid =
|
|
fromDate.isValid() &&
|
|
toDate.isValid() &&
|
|
fromDate.isSameOrAfter(now, "day") &&
|
|
toDate.isAfter(fromDate)
|
|
|
|
if (!isDateParamValid) {
|
|
fromDate = now
|
|
toDate = now.add(1, "day")
|
|
}
|
|
|
|
const selectedLocation =
|
|
destinationsData?.currentSelection.hotel ??
|
|
destinationsData?.currentSelection.city
|
|
|
|
// if bookingCode is not provided in the search params,
|
|
// we will fetch it from the page settings stored in Contentstack.
|
|
const selectedBookingCode =
|
|
data.bookingCode ||
|
|
(pageSettingsBookingCodePromise !== null
|
|
? use(pageSettingsBookingCodePromise)
|
|
: "")
|
|
|
|
const defaultRoomsData: BookingWidgetSchema["rooms"] = data.rooms?.map(
|
|
(room) => ({
|
|
adults: room.adults,
|
|
childrenInRoom: room.childrenInRoom || [],
|
|
})
|
|
) ?? [
|
|
{
|
|
adults: 1,
|
|
childrenInRoom: [],
|
|
},
|
|
]
|
|
const hotelId = data.hotelId ? parseInt(data.hotelId) : undefined
|
|
const methods = useForm({
|
|
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: bookingFlowConfig.bookingCodeEnabled ? selectedBookingCode : "",
|
|
remember: false,
|
|
},
|
|
redemption: data.searchType === SEARCH_TYPE_REDEMPTION,
|
|
rooms: defaultRoomsData,
|
|
city: data.city || undefined,
|
|
hotel: hotelId,
|
|
},
|
|
shouldFocusError: false,
|
|
mode: "onSubmit",
|
|
resolver: zodResolver(bookingWidgetSchema),
|
|
reValidateMode: "onSubmit",
|
|
})
|
|
|
|
const searchParams = useSearchParams()
|
|
const bookingCodeFromSearchParams = searchParams.get("bookingCode") || ""
|
|
const [bookingCode, setBookingCode] = useState(bookingCodeFromSearchParams)
|
|
|
|
if (bookingCode !== bookingCodeFromSearchParams) {
|
|
methods.setValue("bookingCode", {
|
|
value: bookingCodeFromSearchParams,
|
|
})
|
|
setBookingCode(bookingCodeFromSearchParams)
|
|
}
|
|
|
|
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)
|
|
const overflowY = originalOverflowY ?? "visible"
|
|
document.body.style.overflowY = overflowY
|
|
}
|
|
|
|
function openMobileSearch() {
|
|
setIsOpen(true)
|
|
setOriginalOverflowY(document.body.style.overflowY)
|
|
document.body.style.overflowY = "hidden"
|
|
}
|
|
|
|
useEffect(() => {
|
|
const observer = new ResizeObserver(
|
|
debounce(([entry]) => {
|
|
if (entry.contentRect.width > 768) {
|
|
setIsOpen(false)
|
|
document.body.style.removeProperty("overflow-y")
|
|
}
|
|
})
|
|
)
|
|
|
|
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
|
|
|
|
if (initialBookingCode?.remember) {
|
|
methods.setValue("bookingCode", initialBookingCode)
|
|
}
|
|
}
|
|
}, [methods, selectedBookingCode])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!data.fromDate &&
|
|
!data.toDate &&
|
|
!data.rooms &&
|
|
storedBookingWidgetState
|
|
) {
|
|
methods.reset({
|
|
...methods.getValues(),
|
|
date: {
|
|
fromDate: storedBookingWidgetState.fromDate,
|
|
toDate: storedBookingWidgetState.toDate,
|
|
},
|
|
rooms: storedBookingWidgetState.rooms,
|
|
})
|
|
}
|
|
}, [data, methods, storedBookingWidgetState])
|
|
|
|
if (shouldShowSkeleton) {
|
|
return <BookingWidgetSkeleton type={type} config={bookingFlowConfig} />
|
|
}
|
|
|
|
const classNames = bookingWidgetContainerVariants({
|
|
type,
|
|
})
|
|
|
|
const formContainerClassNames = formContainerVariants({
|
|
type,
|
|
})
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<section
|
|
ref={bookingWidgetRef}
|
|
className={classNames}
|
|
data-booking-widget-open={isOpen}
|
|
>
|
|
<MobileToggleButton openMobileSearch={openMobileSearch} />
|
|
<div className={formContainerClassNames}>
|
|
<button
|
|
className={styles.close}
|
|
onClick={closeMobileSearch}
|
|
type="button"
|
|
>
|
|
<MaterialIcon icon="close" />
|
|
</button>
|
|
<Form type={type} onClose={closeMobileSearch} />
|
|
</div>
|
|
<div className={styles.backdrop} onClick={closeMobileSearch} />
|
|
</section>
|
|
</FormProvider>
|
|
)
|
|
}
|