Files
web/apps/scandic-web/components/BookingWidget/Client.tsx
2025-03-07 09:08:19 +00:00

231 lines
6.4 KiB
TypeScript

"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form"
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 { CloseLargeIcon } from "@/components/Icons"
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"
import type { Location } from "@/types/trpc/routers/hotel/locations"
export default function BookingWidgetClient({
type,
bookingWidgetSearchParams,
}: BookingWidgetClientProps) {
const [isOpen, setIsOpen] = useState(false)
const bookingWidgetRef = useRef(null)
const lang = useLang()
const [locations] = trpc.hotel.locations.get.useSuspenseQuery({ lang })
useStickyPosition({
ref: bookingWidgetRef,
name: StickyElementNameEnum.BOOKING_WIDGET,
})
const params = convertSearchParamsToObj<BookingWidgetSearchData>(
bookingWidgetSearchParams
)
const now = dt()
// if fromDate or toDate is undefined, dt will return value that represents the same as 'now' above.
// this is fine as isDateParamValid will catch this and default the values accordingly.
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: Location | null = null
if (params.hotelId) {
selectedLocation = getLocationObj(locations, params.hotelId)
} else if (params.city) {
selectedLocation = getLocationObj(locations, params.city)
}
const selectedBookingCode = params.bookingCode ?? ""
const defaultRoomsData: BookingWidgetSchema["rooms"] = params.rooms?.map(
(room) => ({
adults: room.adults,
childrenInRoom: room.childrenInRoom ?? [],
})
) ?? [
{
adults: 1,
childrenInRoom: [],
},
]
const methods = useForm<BookingWidgetSchema>({
defaultValues: {
search: selectedLocation?.name ?? "",
location: selectedLocation ? JSON.stringify(selectedLocation) : undefined,
date: {
fromDate: fromDate.format("YYYY-MM-DD"),
toDate: toDate.format("YYYY-MM-DD"),
},
bookingCode: {
value: selectedBookingCode,
remember: false,
},
redemption: params?.searchType === REDEMPTION,
rooms: defaultRoomsData,
},
shouldFocusError: false,
mode: "onSubmit",
resolver: zodResolver(bookingWidgetSchema),
reValidateMode: "onSubmit",
})
function closeMobileSearch() {
setIsOpen(false)
document.body.style.overflowY = "visible"
}
function openMobileSearch() {
setIsOpen(true)
document.body.style.overflowY = "hidden"
}
useEffect(() => {
const debouncedResizeHandler = debounce(function ([
entry,
]: ResizeObserverEntry[]) {
if (entry.contentRect.width > 1366) {
closeMobileSearch()
}
})
const observer = new ResizeObserver(debouncedResizeHandler)
observer.observe(document.body)
return () => {
if (observer) {
observer.unobserve(document.body)
}
}
}, [])
useEffect(() => {
if (typeof window !== "undefined" && !selectedLocation) {
const sessionStorageSearchData = sessionStorage.getItem("searchData")
const initialSelectedLocation: Location | undefined =
sessionStorageSearchData && isValidJson(sessionStorageSearchData)
? JSON.parse(sessionStorageSearchData)
: undefined
initialSelectedLocation?.name &&
methods.setValue("search", initialSelectedLocation.name)
sessionStorageSearchData &&
methods.setValue(
"location",
encodeURIComponent(sessionStorageSearchData)
)
}
}, [methods, selectedLocation])
useEffect(() => {
if (typeof window !== "undefined" && !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])
return (
<FormProvider {...methods}>
<section
ref={bookingWidgetRef}
className={styles.wrapper}
data-open={isOpen}
>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}>
<button
className={styles.close}
onClick={closeMobileSearch}
type="button"
>
<CloseLargeIcon />
</button>
<Form locations={locations} type={type} onClose={closeMobileSearch} />
</div>
</section>
<div className={styles.backdrop} onClick={closeMobileSearch} />
</FormProvider>
)
}
export function BookingWidgetSkeleton() {
return (
<>
<section className={styles.wrapper} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton />
</div>
</section>
</>
)
}
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
}