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

@@ -102,7 +102,7 @@ export default async function ContentTypePage({
if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound()
}
return <StartPage />
return <StartPage searchParams={searchParams} />
default:
const type: never = params.contentType
console.error(`Unsupported content type given: ${type}`)

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);
}

View File

@@ -2,6 +2,7 @@ import { getStartPage } from "@/lib/trpc/memoizedRequests"
import Blocks from "@/components/Blocks"
import FullWidthCampaign from "@/components/Blocks/FullWidthCampaign"
import { FloatingBookingWidget } from "@/components/BookingWidget/FloatingBookingWidget"
import Image from "@/components/Image"
import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK"
@@ -10,7 +11,11 @@ import styles from "./startPage.module.css"
import { BlocksEnums } from "@/types/enums/blocks"
export default async function StartPage() {
export default async function StartPage({
searchParams,
}: {
searchParams: { [key: string]: string }
}) {
const content = await getStartPage()
if (!content) {
return null
@@ -25,6 +30,7 @@ export default async function StartPage() {
<Title color="white" textAlign="center">
{header.heading}
</Title>
<FloatingBookingWidget bookingWidgetSearchParams={searchParams} />
</div>
{header.hero_image ? (
<Image
@@ -40,6 +46,7 @@ export default async function StartPage() {
/>
) : null}
</header>
<main className={styles.main}>
{blocks.map((block, index) => {
if (block.typename === BlocksEnums.block.FullWidthCampaign) {

View File

@@ -5,7 +5,7 @@
.header {
height: 560px;
position: relative;
overflow: hidden;
z-index: 10;
}
.header:after {
@@ -34,7 +34,6 @@
@media screen and (min-width: 768px) {
.headerContent {
grid-gap: var(--Spacing-x4);
padding: 320px 160px 80px;
}
}
@@ -42,6 +41,36 @@
max-width: 100%;
}
.fauxBookingWidget {
height: 84px;
width: 100%;
}
.floatingBookingWidgetContainer {
height: 0px;
width: 100vw;
display: flex;
justify-content: center;
background-color: green;
}
.floatingBookingWidget {
background: pink;
transform: translateY(-155px);
height: fit-content;
width: 100%;
display: flex;
justify-content: center;
&[data-intersecting="true"] {
background: white;
}
& > * {
max-width: 100%;
}
}
.main {
display: grid;
gap: var(--Spacing-x6);

View File

@@ -86,13 +86,13 @@ export function VoucherSkeleton() {
return (
<FormProvider {...form}>
<div className={styles.optionsContainer}>
<div>
<div className={styles.voucherSkeletonContainer}>
<label>
<Caption type="bold" color="red" asChild>
<span>{vouchers}</span>
</Caption>
</label>
<SkeletonShimmer width={"100%"} />
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
<div className={styles.options}>
<div className={styles.option}>

View File

@@ -16,6 +16,9 @@
display: flex;
flex-direction: column;
}
.voucherSkeletonContainer {
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;

View File

@@ -59,6 +59,9 @@
height: 60px;
}
}
.voucherContainer {
height: 100%;
}
@media screen and (min-width: 768px) {
.input {

View File

@@ -135,13 +135,13 @@ export function BookingWidgetFormContentSkeleton() {
{ totalNights: 0 }
)}
</Caption>
<SkeletonShimmer width={"100%"} />
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
<div className={styles.rooms}>
<Caption color="red" type="bold" asChild>
<span>{intl.formatMessage({ id: "Rooms & Guests" })}</span>
</Caption>
<SkeletonShimmer width={"100%"} />
<SkeletonShimmer width={"100%"} display={"block"} />
</div>
</div>
<div className={styles.voucherContainer}>

View File

@@ -34,9 +34,17 @@
.full {
padding: var(--Spacing-x1) 0;
}
.form {
width: 100%;
max-width: var(--max-width-page);
margin: 0 auto;
}
.compact {
width: 1160px;
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2)
var(--Spacing-x-one-and-half) var(--Spacing-x1);
white-space: nowrap;
}
}

View File

@@ -15,7 +15,10 @@ import { bookingWidgetVariants } from "./variants"
import styles from "./form.module.css"
import type { BookingWidgetSchema } from "@/types/components/bookingWidget"
import type {
BookingWidgetSchema,
BookingWidgetType,
} from "@/types/components/bookingWidget"
import type { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import type { Location } from "@/types/trpc/routers/hotel/locations"
@@ -85,9 +88,13 @@ export default function Form({
)
}
export function BookingWidgetFormSkeleton() {
export function BookingWidgetFormSkeleton({
type,
}: {
type: BookingWidgetType
}) {
const classNames = bookingWidgetVariants({
type: "full",
type,
})
return (

View File

@@ -7,6 +7,7 @@ export const bookingWidgetVariants = cva(styles.section, {
type: {
default: styles.default,
full: styles.full,
compact: styles.compact,
},
},
defaultVariants: {

View File

@@ -18,10 +18,12 @@ export default function SkeletonShimmer({
height,
width,
contrast = "light",
display = "initial",
}: {
height?: string
width?: string
contrast?: "light" | "dark"
display?: "block" | "inline-block" | "initial"
}) {
return (
<span
@@ -30,6 +32,7 @@ export default function SkeletonShimmer({
height: height,
width: width,
maxWidth: "100%",
display: display,
}}
/>
)

View File

@@ -0,0 +1 @@
export const isBrowser = typeof window !== "undefined" && !("Deno" in window)