fix(BOOK-405): Pushing to history when opening sidepeek to avoid navigating back inside the booking flow

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Erik Tiekstra
2025-10-09 11:34:58 +00:00
parent 566dd54087
commit 527ab170b5
15 changed files with 674 additions and 584 deletions

View File

@@ -1,6 +1,6 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { type ReactNode, useState } from "react"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
@@ -14,7 +14,6 @@ import type {
Hotel,
Restaurant,
} from "@scandic-hotels/trpc/types/hotel"
import type { ReactNode } from "react"
enum SidePeekEnum {
hotelDetails = "hotel-detail-side-peek",
@@ -59,19 +58,21 @@ export function HotelDetailsSidePeek({
buttonVariant,
}: HotelDetailsSidePeekProps) {
const buttonProps = buttonPropsMap[buttonVariant]
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger>
<>
<Button
{...buttonProps}
wrapping={wrapping}
onPress={() =>
onPress={() => {
setIsOpen(true)
trackOpenSidePeekEvent({
name: SidePeekEnum.hotelDetails,
hotelId: hotel.operaId,
includePathname: true,
})
}
}}
>
{triggerLabel}
<MaterialIcon
@@ -81,13 +82,17 @@ export function HotelDetailsSidePeek({
/>
</Button>
<SidePeekSelfControlled title={hotel.name}>
<SidePeekSelfControlled
title={hotel.name}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<HotelSidePeekContent
hotel={hotel}
restaurants={restaurants}
additionalHotelData={additionalHotelData}
/>
</SidePeekSelfControlled>
</DialogTrigger>
</>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { useState } from "react"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
@@ -54,28 +54,34 @@ export function RoomDetailsSidePeek({
buttonVariant: variant = "primary",
}: RoomDetailsSidePeekProps) {
const buttonProps = buttonPropsMap[variant]
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger>
<>
<Button
{...buttonProps}
wrapping={wrapping}
onPress={() =>
onPress={() => {
setIsOpen(true)
trackOpenSidePeekEvent({
name: SidePeekEnum.roomDetails,
hotelId,
roomTypeCode,
includePathname: true,
})
}
}}
>
{triggerLabel}
<MaterialIcon icon="chevron_right" size={20} color="CurrentColor" />
</Button>
<SidePeekSelfControlled title={room.name}>
<SidePeekSelfControlled
title={room.name}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<RoomSidePeekContent room={room} />
</SidePeekSelfControlled>
</DialogTrigger>
</>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { useEffect, useRef } from "react"
const callbacks = new Set<() => void>()
if (typeof window !== "undefined") {
window.addEventListener("popstate", () => {
callbacks.forEach((callback) => callback())
})
}
export default function usePopStateHandler(
callback: () => void,
enabled = true
) {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
if (!enabled) {
return
}
const handler = () => callbackRef.current()
callbacks.add(handler)
return () => {
callbacks.delete(handler)
}
}, [enabled])
}

View File

@@ -85,14 +85,13 @@ function ImageGallery({
})}
/>
</div>
{isOpen ? (
<Lightbox
images={images}
dialogTitle={title}
onClose={() => setIsOpen(false)}
hideLabel={hideLabel}
/>
) : null}
<Lightbox
images={images}
dialogTitle={title}
onClose={() => setIsOpen(false)}
isOpen={isOpen}
hideLabel={hideLabel}
/>
</>
)
}

View File

@@ -3,6 +3,8 @@ import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useState } from 'react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import usePopStateHandler from '@scandic-hotels/common/hooks/usePopStateHandler'
import FullView from './FullView'
import Gallery from './Gallery'
@@ -18,6 +20,7 @@ type LightboxProps = {
images: LightboxImage[]
dialogTitle: string /* Accessible title for dialog screen readers */
onClose: () => void
isOpen: boolean
activeIndex?: number
hideLabel?: boolean
}
@@ -26,21 +29,34 @@ export default function Lightbox({
images,
dialogTitle,
onClose,
isOpen,
activeIndex = 0,
hideLabel,
}: LightboxProps) {
const [selectedImageIndex, setSelectedImageIndex] = useState(activeIndex)
const [isFullView, setIsFullView] = useState(false)
function handleClose(moveBack = false) {
setSelectedImageIndex(0)
if (moveBack) {
window.history.back()
} else {
onClose()
}
}
usePopStateHandler(() => handleClose(), isOpen)
useEffect(() => {
if (isOpen) {
window.history.pushState(null, '', window.location.href)
}
}, [isOpen])
useEffect(() => {
setSelectedImageIndex(activeIndex)
}, [activeIndex])
function handleClose() {
setSelectedImageIndex(0)
onClose()
}
function handleNext() {
setSelectedImageIndex((prevIndex) => (prevIndex + 1) % images.length)
}
@@ -51,24 +67,10 @@ export default function Lightbox({
)
}
useEffect(() => {
function handlePopState() {
handleClose()
}
window.history.pushState(null, '', window.location.href)
window.addEventListener('popstate', handlePopState)
return () => {
window.removeEventListener('popstate', handlePopState)
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [])
return (
<ModalOverlay
isOpen={true}
onOpenChange={handleClose}
isOpen={isOpen}
onOpenChange={() => handleClose(true)}
className={styles.overlay}
isDismissable
>

View File

@@ -4,6 +4,7 @@ import { useEffect } from 'react'
import { Dialog, Modal, ModalOverlay } from 'react-aria-components'
import { useIntl } from 'react-intl'
import usePopStateHandler from '@scandic-hotels/common/hooks/usePopStateHandler'
import useSetOverflowVisibleOnRA from '@scandic-hotels/common/hooks/useSetOverflowVisibleOnRA'
import { IconButton } from '../../IconButton'
@@ -14,45 +15,72 @@ import SidePeekSEO from './SidePeekSEO'
import styles from './sidePeekSelfControlled.module.css'
import type { SidePeekSelfControlledProps } from './sidePeek'
interface SidePeekSelfControlledProps extends React.PropsWithChildren {
title: string
isOpen: boolean
onClose: () => void
}
export default function SidePeekSelfControlled({
children,
isOpen,
onClose,
title,
}: React.PropsWithChildren<SidePeekSelfControlledProps>) {
}: SidePeekSelfControlledProps) {
const intl = useIntl()
function handleClose(moveBack = false) {
if (moveBack) {
window.history.back()
} else {
onClose()
}
}
// Only register popstate handler when open
usePopStateHandler(() => handleClose(), isOpen)
useEffect(() => {
if (isOpen) {
window.history.pushState(null, '', window.location.href)
}
}, [isOpen])
return (
<>
<ModalOverlay className={styles.overlay} isDismissable>
<ModalOverlay
className={styles.overlay}
isDismissable
onOpenChange={() => handleClose(true)}
isOpen={isOpen}
>
<Modal className={styles.modal}>
<Dialog className={styles.dialog} aria-label={title}>
{({ close }) => (
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Typography variant="Title/md" className={styles.heading}>
<h2>{title}</h2>
</Typography>
) : null}
<IconButton
theme="Black"
style="Muted"
onPress={close}
aria-label={intl.formatMessage({
defaultMessage: 'Close',
})}
>
<MaterialIcon
icon="close"
size={24}
color="Icon/Interactive/Default"
/>
</IconButton>
</header>
<div className={styles.sidePeekContent}>{children}</div>
<KeepBodyVisible />
</aside>
)}
<aside className={styles.sidePeek}>
<header className={styles.header}>
{title ? (
<Typography variant="Title/md" className={styles.heading}>
<h2>{title}</h2>
</Typography>
) : null}
<IconButton
theme="Black"
style="Muted"
onPress={() => handleClose(true)}
aria-label={intl.formatMessage({
defaultMessage: 'Close',
})}
>
<MaterialIcon
icon="close"
size={24}
color="Icon/Interactive/Default"
/>
</IconButton>
</header>
<div className={styles.sidePeekContent}>{children}</div>
<KeepBodyVisible />
</aside>
</Dialog>
</Modal>
</ModalOverlay>