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) { if (env.HIDE_FOR_NEXT_RELEASE) {
return notFound() return notFound()
} }
return <StartPage /> return <StartPage searchParams={searchParams} />
default: default:
const type: never = params.contentType const type: never = params.contentType
console.error(`Unsupported content type given: ${type}`) console.error(`Unsupported content type given: ${type}`)

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { cva } from "class-variance-authority"
import { use, useEffect, useRef, useState } from "react" import { use, useEffect, useRef, useState } from "react"
import { FormProvider, useForm } from "react-hook-form" import { FormProvider, useForm } from "react-hook-form"
@@ -126,6 +127,17 @@ export default function BookingWidgetClient({
reValidateMode: "onSubmit", 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() { function closeMobileSearch() {
setIsOpen(false) setIsOpen(false)
document.body.style.overflowY = "visible" document.body.style.overflowY = "visible"
@@ -190,7 +202,7 @@ export default function BookingWidgetClient({
}, [methods, selectedBookingCode]) }, [methods, selectedBookingCode])
if (isLoading) { if (isLoading) {
return <BookingWidgetSkeleton /> return <BookingWidgetSkeleton type={type} />
} }
if (!isSuccess || !locations) { if (!isSuccess || !locations) {
@@ -198,13 +210,13 @@ export default function BookingWidgetClient({
return null return null
} }
const classNames = bookingWidgetContainerVariants({
type,
})
return ( return (
<FormProvider {...methods}> <FormProvider {...methods}>
<section <section ref={bookingWidgetRef} className={classNames} data-open={isOpen}>
ref={bookingWidgetRef}
className={styles.wrapper}
data-open={isOpen}
>
<MobileToggleButton openMobileSearch={openMobileSearch} /> <MobileToggleButton openMobileSearch={openMobileSearch} />
<div className={styles.formContainer}> <div className={styles.formContainer}>
<button <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 ( return (
<> <>
<section className={styles.wrapper} style={{ top: 0 }}> <section className={classNames} style={{ top: 0 }}>
<MobileToggleButtonSkeleton /> <MobileToggleButtonSkeleton />
<div className={styles.formContainer}> <div className={styles.formContainer}>
<BookingWidgetFormSkeleton /> <BookingWidgetFormSkeleton type={type} />
</div> </div>
</section> </section>
</> </>
@@ -253,3 +273,16 @@ function getLocationObj(locations: Location[], destination: string) {
} }
return null 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; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
background-color: var(--UI-Input-Controls-Surface-Normal); 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); gap: var(--Spacing-x3);
height: calc(100dvh - 20px); height: calc(100dvh - 20px);
width: 100%; width: 100%;
@@ -17,6 +17,18 @@
transition: bottom 300ms ease; 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"] { .wrapper[data-open="true"] {
z-index: var(--booking-widget-open-z-index); 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 Blocks from "@/components/Blocks"
import FullWidthCampaign from "@/components/Blocks/FullWidthCampaign" import FullWidthCampaign from "@/components/Blocks/FullWidthCampaign"
import { FloatingBookingWidget } from "@/components/BookingWidget/FloatingBookingWidget"
import Image from "@/components/Image" import Image from "@/components/Image"
import Title from "@/components/TempDesignSystem/Text/Title" import Title from "@/components/TempDesignSystem/Text/Title"
import TrackingSDK from "@/components/TrackingSDK" import TrackingSDK from "@/components/TrackingSDK"
@@ -10,7 +11,11 @@ import styles from "./startPage.module.css"
import { BlocksEnums } from "@/types/enums/blocks" 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() const content = await getStartPage()
if (!content) { if (!content) {
return null return null
@@ -25,6 +30,7 @@ export default async function StartPage() {
<Title color="white" textAlign="center"> <Title color="white" textAlign="center">
{header.heading} {header.heading}
</Title> </Title>
<FloatingBookingWidget bookingWidgetSearchParams={searchParams} />
</div> </div>
{header.hero_image ? ( {header.hero_image ? (
<Image <Image
@@ -40,6 +46,7 @@ export default async function StartPage() {
/> />
) : null} ) : null}
</header> </header>
<main className={styles.main}> <main className={styles.main}>
{blocks.map((block, index) => { {blocks.map((block, index) => {
if (block.typename === BlocksEnums.block.FullWidthCampaign) { if (block.typename === BlocksEnums.block.FullWidthCampaign) {

View File

@@ -5,7 +5,7 @@
.header { .header {
height: 560px; height: 560px;
position: relative; position: relative;
overflow: hidden; z-index: 10;
} }
.header:after { .header:after {
@@ -34,7 +34,6 @@
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
.headerContent { .headerContent {
grid-gap: var(--Spacing-x4); grid-gap: var(--Spacing-x4);
padding: 320px 160px 80px;
} }
} }
@@ -42,6 +41,36 @@
max-width: 100%; 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 { .main {
display: grid; display: grid;
gap: var(--Spacing-x6); gap: var(--Spacing-x6);

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,9 +34,17 @@
.full { .full {
padding: var(--Spacing-x1) 0; padding: var(--Spacing-x1) 0;
} }
.form { .form {
width: 100%; width: 100%;
max-width: var(--max-width-page); max-width: var(--max-width-page);
margin: 0 auto; 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 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 { BookingWidgetFormProps } from "@/types/components/form/bookingwidget"
import type { Location } from "@/types/trpc/routers/hotel/locations" 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({ const classNames = bookingWidgetVariants({
type: "full", type,
}) })
return ( return (

View File

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

View File

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

View File

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