Merged in feature/SW-1736-foating-booking-widget (pull request #1696)

Feature/SW-1736 floating booking widget

* feature: Add floating booking widget on start page SW-1736

* fix: Make sure we don't try to use IntersectionObserver on the server

* fix: make sure that we disconnect the intersectionobserver when dismounting

* fix: pass searchparams to floating bookingwidget


Approved-by: Michael Zetterberg
This commit is contained in:
Joakim Jäderberg
2025-04-04 06:52:37 +00:00
parent 7b1760ca17
commit 3c810d67a2
17 changed files with 243 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
"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"
@@ -126,6 +127,17 @@ export default function BookingWidgetClient({
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("location", JSON.stringify(selectedLocation))
}, [selectedLocation, methods])
function closeMobileSearch() {
setIsOpen(false)
document.body.style.overflowY = "visible"
@@ -190,7 +202,7 @@ export default function BookingWidgetClient({
}, [methods, selectedBookingCode])
if (isLoading) {
return <BookingWidgetSkeleton />
return <BookingWidgetSkeleton type={type} />
}
if (!isSuccess || !locations) {
@@ -198,13 +210,13 @@ export default function BookingWidgetClient({
return null
}
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<FormProvider {...methods}>
<section
ref={bookingWidgetRef}
className={styles.wrapper}
data-open={isOpen}
>
<section ref={bookingWidgetRef} className={classNames} data-open={isOpen}>
<MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}>
<button
@@ -222,13 +234,21 @@ export default function BookingWidgetClient({
)
}
export function BookingWidgetSkeleton() {
export function BookingWidgetSkeleton({
type = "full",
}: {
type?: BookingWidgetClientProps["type"]
}) {
const classNames = bookingWidgetContainerVariants({
type,
})
return (
<>
<section className={styles.wrapper} style={{ top: 0 }}>
<section className={classNames} style={{ top: 0 }}>
<MobileToggleButtonSkeleton />
<div className={styles.formContainer}>
<BookingWidgetFormSkeleton />
<BookingWidgetFormSkeleton type={type} />
</div>
</section>
</>
@@ -253,3 +273,16 @@ function getLocationObj(locations: Location[], destination: string) {
}
return null
}
export const bookingWidgetContainerVariants = cva(styles.wrapper, {
variants: {
type: {
default: styles.default,
full: styles.full,
compact: styles.compact,
},
},
defaultVariants: {
type: "full",
},
})

View File

@@ -0,0 +1,27 @@
.floatingBookingWidget {
width: 100vw;
min-height: 84px;
z-index: 1000;
position: relative;
.floatingBackground {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: rgba(255, 255, 255, 0);
width: 100%;
transition: background 0.2s ease;
}
&[data-intersecting="true"] {
.floatingBackground {
background: transparent;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
}
}
}

View File

@@ -0,0 +1,65 @@
"use client"
import { useEffect, useRef, useState } from "react"
import BookingWidgetClient from "../Client"
import styles from "./FloatingBookingWidget.module.css"
import type { BookingWidgetClientProps } from "@/types/components/bookingWidget"
type Props = Omit<BookingWidgetClientProps, "type">
export function FloatingBookingWidgetClient(props: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const observerRef = useRef<IntersectionObserver | null>(null)
const [stickyTop, setStickyTop] = useState(false)
useEffect(() => {
observerRef.current = new IntersectionObserver(
([entry]) => {
const hasScrolledPastTop = entry.boundingClientRect.top < 0
setStickyTop(hasScrolledPastTop)
},
{ threshold: 0, rootMargin: "0px 0px -100% 0px" }
)
if (containerRef.current) {
observerRef.current?.observe(containerRef.current)
}
return () => {
observerRef.current?.disconnect()
}
}, [])
useEffect(() => {
/*
Re-observe the element on an interval to ensure the observer is up to date
This is a workaround for the fact that the observer doesn't always trigger
when the element is scrolled out of view if you do it too fast
*/
const interval = setInterval(() => {
if (!containerRef.current || !observerRef.current) {
return
}
observerRef.current.unobserve(containerRef.current)
observerRef.current.observe(containerRef.current)
}, 500)
return () => clearInterval(interval)
}, [])
return (
<div
className={styles.floatingBookingWidget}
data-intersecting={stickyTop}
ref={containerRef}
>
<div className={styles.floatingBackground}>
<BookingWidgetClient {...props} type={"compact"} />
</div>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { getPageSettingsBookingCode } from "@/lib/trpc/memoizedRequests"
import { FloatingBookingWidgetClient } from "./FloatingBookingWidgetClient"
import type { BookingWidgetProps } from "@/types/components/bookingWidget"
export async function FloatingBookingWidget({
bookingWidgetSearchParams,
}: Omit<BookingWidgetProps, "type">) {
console.log("DEBUG: FloatingBookingWidget", bookingWidgetSearchParams)
let pageSettingsBookingCodePromise: Promise<string> | null = null
if (!bookingWidgetSearchParams.bookingCode) {
pageSettingsBookingCodePromise = getPageSettingsBookingCode()
}
return (
<FloatingBookingWidgetClient
bookingWidgetSearchParams={bookingWidgetSearchParams}
pageSettingsBookingCodePromise={pageSettingsBookingCodePromise}
/>
)
}

View File

@@ -7,7 +7,7 @@
display: grid;
grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal);
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
border-radius: 0;
gap: var(--Spacing-x3);
height: calc(100dvh - 20px);
width: 100%;
@@ -17,6 +17,18 @@
transition: bottom 300ms ease;
}
.compact {
.formContainer {
border-radius: var(--Corner-radius-Large);
}
}
@media screen and (max-width: 767px) {
.formContainer {
border-radius: var(--Corner-radius-Large) var(--Corner-radius-Large) 0 0;
}
}
.wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index);
}