feat(SW-2863): Move contentstack router to trpc package * Add exports to packages and lint rule to prevent relative imports * Add env to trpc package * Add eslint to trpc package * Apply lint rules * Use direct imports from trpc package * Add lint-staged config to trpc * Move lang enum to common * Restructure trpc package folder structure * WIP first step * update internal imports in trpc * Fix most errors in scandic-web Just 100 left... * Move Props type out of trpc * Fix CategorizedFilters types * Move more schemas in hotel router * Fix deps * fix getNonContentstackUrls * Fix import error * Fix entry error handling * Fix generateMetadata metrics * Fix alertType enum * Fix duplicated types * lint:fix * Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package * Fix broken imports * Merge branch 'master' into feat/sw-2863-move-contentstack-router-to-trpc-package Approved-by: Linus Flood
255 lines
7.2 KiB
TypeScript
255 lines
7.2 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
|
|
|
import { REDEMPTION } from "@/constants/booking"
|
|
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 MobileToggleButton, {
|
|
MobileToggleButtonSkeleton,
|
|
} from "./MobileToggleButton"
|
|
import {
|
|
bookingWidgetContainerVariants,
|
|
formContainerVariants,
|
|
} from "./variant"
|
|
|
|
import styles from "./bookingWidget.module.css"
|
|
|
|
import type {
|
|
BookingCodeSchema,
|
|
BookingWidgetClientProps,
|
|
BookingWidgetSchema,
|
|
} from "@/types/components/bookingWidget"
|
|
|
|
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 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")
|
|
}
|
|
|
|
let 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: selectedBookingCode,
|
|
remember: false,
|
|
},
|
|
redemption: data.searchType === 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
|
|
|
|
initialBookingCode?.remember &&
|
|
methods.setValue("bookingCode", initialBookingCode)
|
|
}
|
|
}, [methods, selectedBookingCode])
|
|
|
|
if (shouldShowSkeleton) {
|
|
return <BookingWidgetSkeleton type={type} />
|
|
}
|
|
|
|
const classNames = bookingWidgetContainerVariants({
|
|
type,
|
|
})
|
|
|
|
const formContainerClassNames = formContainerVariants({
|
|
type,
|
|
})
|
|
|
|
return (
|
|
<FormProvider {...methods}>
|
|
<section ref={bookingWidgetRef} className={classNames} data-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>
|
|
</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>
|
|
</>
|
|
)
|
|
}
|