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 { useState } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
@@ -22,15 +22,23 @@ export default function ExpiringPointsSeeAllButton({
expiryDate,
}: ExpiringPointsSeeAllButtonProps) {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger>
<Button variant="Text" size="Medium" typography="Body/Paragraph/mdBold">
<>
<Button
variant="Text"
size="Medium"
typography="Body/Paragraph/mdBold"
onPress={() => setIsOpen(true)}
>
{intl.formatMessage({ defaultMessage: "See all" })}
<MaterialIcon icon="chevron_right" color="CurrentColor" />
</Button>
<SidePeekSelfControlled
title={intl.formatMessage({ defaultMessage: "Expiring Points" })}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<div className={styles.sidePeekContent}>
<Typography variant="Body/Paragraph/mdRegular">
@@ -48,6 +56,6 @@ export default function ExpiringPointsSeeAllButton({
/>
</div>
</SidePeekSelfControlled>
</DialogTrigger>
</>
)
}

View File

@@ -3,9 +3,9 @@
import { useState } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import Image from "@scandic-hotels/design-system/Image"
import Lightbox from "@scandic-hotels/design-system/Lightbox"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import { mapImageVaultImagesToGalleryImages } from "@/utils/imageGallery"
@@ -61,29 +61,29 @@ export default function TopImages({ images, destinationName }: TopImageProps) {
{images.length > 1 && (
<>
<Button
theme="base"
intent="inverted"
size="small"
onClick={() => setLightboxState({ open: true, activeIndex: 0 })}
variant="Primary"
color="Inverted"
size="Small"
onPress={() => setLightboxState({ open: true, activeIndex: 0 })}
typography="Body/Supporting text (caption)/smBold"
className={styles.seeAllButton}
>
{intl.formatMessage({
defaultMessage: "See all photos",
})}
</Button>
{lightboxState.open ? (
<Lightbox
images={lightboxImages}
dialogTitle={intl.formatMessage(
{
defaultMessage: "{title} - Image gallery",
},
{ title: destinationName }
)}
activeIndex={lightboxState.activeIndex}
onClose={() => setLightboxState({ open: false, activeIndex: 0 })}
/>
) : null}
<Lightbox
images={lightboxImages}
dialogTitle={intl.formatMessage(
{
defaultMessage: "{title} - Image gallery",
},
{ title: destinationName }
)}
activeIndex={lightboxState.activeIndex}
onClose={() => setLightboxState({ open: false, activeIndex: 0 })}
isOpen={lightboxState.open}
/>
</>
)}
</div>

View File

@@ -71,21 +71,18 @@ export default function PreviewImages({
defaultMessage: "See all photos",
})}
</Button>
{lightboxState.isOpen ? (
<Lightbox
images={lightboxImages}
dialogTitle={intl.formatMessage(
{
defaultMessage: "{title} - Image gallery",
},
{ title: hotelName }
)}
activeIndex={lightboxState.activeIndex}
onClose={() =>
setLightboxState({ activeIndex: 0, isOpen: false })
}
/>
) : null}
<Lightbox
images={lightboxImages}
dialogTitle={intl.formatMessage(
{
defaultMessage: "{title} - Image gallery",
},
{ title: hotelName }
)}
activeIndex={lightboxState.activeIndex}
onClose={() => setLightboxState({ activeIndex: 0, isOpen: false })}
isOpen={lightboxState.isOpen}
/>
</>
)}
</div>

View File

@@ -1,9 +1,11 @@
"use client"
import { Button as ButtonRAC, DialogTrigger } from "react-aria-components"
import { useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
import BookedRoomSidePeekContent from "@/components/SidePeeks/BookedRoomSidePeekContent"
import { trackOpenSidePeekEvent } from "@/utils/tracking"
import styles from "./sidePeek.module.css"
@@ -21,11 +23,14 @@ export default function RoomDetailsSidePeek({
booking,
user,
}: RoomDetailsSidePeekProps) {
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger>
<>
<ButtonRAC
className={styles.trigger}
onPress={() => {
setIsOpen(true)
trackOpenSidePeekEvent({
name: SidePeekEnum.bookedRoomDetails,
hotelId: booking.hotelId,
@@ -35,7 +40,13 @@ export default function RoomDetailsSidePeek({
>
<MaterialIcon icon="pan_zoom" color="CurrentColor" />
</ButtonRAC>
<BookedRoomSidePeek hotelRoom={booking.room} room={booking} user={user} />
</DialogTrigger>
<SidePeekSelfControlled
title={booking.room?.name ?? booking.roomName}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<BookedRoomSidePeekContent room={booking} user={user} />
</SidePeekSelfControlled>
</>
)
}

View File

@@ -1,14 +1,15 @@
"use client"
import { DialogTrigger } from "react-aria-components"
import { useState } from "react"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
import { useMyStayStore } from "@/stores/my-stay"
import BookedRoomSidePeek from "@/components/SidePeeks/BookedRoomSidePeek"
import BookedRoomSidePeekContent from "@/components/SidePeeks/BookedRoomSidePeekContent"
import { trackOpenSidePeekEvent } from "@/utils/tracking"
import { SidePeekEnum } from "@/types/sidepeek"
@@ -23,9 +24,10 @@ export default function RoomDetailsSidePeek({
}: RoomDetailsSidePeekProps) {
const intl = useIntl()
const bookedRoom = useMyStayStore((state) => state.bookedRoom)
const [isOpen, setIsOpen] = useState(false)
return (
<DialogTrigger>
<>
<Button
intent="text"
size="small"
@@ -33,6 +35,7 @@ export default function RoomDetailsSidePeek({
variant="icon"
wrapping
onPress={() => {
setIsOpen(true)
trackOpenSidePeekEvent({
name: SidePeekEnum.bookedRoomDetails,
hotelId: bookedRoom.hotelId,
@@ -43,11 +46,13 @@ export default function RoomDetailsSidePeek({
{intl.formatMessage({ defaultMessage: "See room details" })}
<MaterialIcon icon="chevron_right" size={14} color="CurrentColor" />
</Button>
<BookedRoomSidePeek
hotelRoom={bookedRoom.room}
room={bookedRoom}
user={user}
/>
</DialogTrigger>
<SidePeekSelfControlled
title={bookedRoom.room?.name ?? bookedRoom.roomName}
isOpen={isOpen}
onClose={() => setIsOpen(false)}
>
<BookedRoomSidePeekContent room={bookedRoom} user={user} />
</SidePeekSelfControlled>
</>
)
}

View File

@@ -1,460 +0,0 @@
import { useIntl } from "react-intl"
import { getRoomFeatureDescription } from "@scandic-hotels/booking-flow/utils/getRoomFeatureDescription"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Accordion from "@scandic-hotels/design-system/Accordion"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import IconChip from "@scandic-hotels/design-system/IconChip"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import SidePeekSelfControlled from "@scandic-hotels/design-system/SidePeekSelfControlled"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
import useLang from "@/hooks/useLang"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import RoomDetails from "./RoomDetails"
import styles from "./bookedRoomSidePeek.module.css"
import type { BedTypeSchema } from "@scandic-hotels/booking-flow/stores/enter-details/types"
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
import type { BookingConfirmationSchema } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { Room as HotelRoom } from "@scandic-hotels/trpc/types/hotel"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
import type { SafeUser } from "@/types/user"
type PartialHotelRoom = Pick<
HotelRoom,
"descriptions" | "images" | "name" | "roomFacilities" | "roomTypes"
>
type Room = Pick<
BookingConfirmationSchema,
| "adults"
| "bookingCode"
| "cancellationNumber"
| "checkInDate"
| "cheques"
| "confirmationNumber"
| "refId"
| "currencyCode"
| "guest"
| "rateDefinition"
| "totalPoints"
| "totalPrice"
| "vouchers"
> & {
bedType: BedTypeSchema
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined
childrenInRoom: Child[]
isCancelled: boolean
packages: Packages | null
priceType: PriceTypeEnum
roomName: string
roomNumber: number
terms: string | null
}
interface RoomDetailsSidePeekProps {
hotelRoom: PartialHotelRoom | null
room: Room
user: SafeUser
}
export default function BookedRoomSidePeek({
hotelRoom,
room,
user,
}: RoomDetailsSidePeekProps) {
const intl = useIntl()
const lang = useLang()
const {
adults,
bedType,
bookingCode,
breakfast,
cancellationNumber,
checkInDate,
cheques,
childrenInRoom,
confirmationNumber,
refId,
currencyCode,
guest,
isCancelled,
roomName,
packages,
priceType,
rateDefinition,
roomNumber,
totalPoints,
terms,
totalPrice,
vouchers,
} = room
let totalRoomPrice = totalPrice
// API returns negative values for totalPrice
// on voucher bookings (╯°□°)╯︵ ┻━┻
if (vouchers && totalRoomPrice < 0) {
const pkgsSum = sumPackages(packages)
totalRoomPrice = pkgsSum.price
}
const fromDate = dt(checkInDate).locale(lang)
const galleryImages = hotelRoom
? mapApiImagesToGalleryImages(hotelRoom.images)
: null
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{
adults: adults,
}
)
const childrenMsg = intl.formatMessage(
{
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{
children: childrenInRoom.length,
}
)
const adultsOnlyMsg = adultsMsg
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
const formattedTotalPrice = formatPrice(intl, totalPrice, currencyCode)
let breakfastPrice = intl.formatMessage({
defaultMessage: "No breakfast",
})
if (rateDefinition.breakfastIncluded) {
breakfastPrice = intl.formatMessage({
defaultMessage: "Included",
})
} else if (breakfast) {
breakfastPrice = formatPrice(
intl,
breakfast.localPrice.totalPrice,
breakfast.localPrice.currency
)
}
const hotelRoomName = hotelRoom?.name || roomName
return (
<SidePeekSelfControlled title={hotelRoomName}>
<div className={styles.wrapper}>
<div className={styles.roomHeader}>
{isCancelled ? (
<IconChip
color={"red"}
icon={
<MaterialIcon
icon="cancel"
size={20}
color="Icon/Feedback/Error"
/>
}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{intl.formatMessage({
defaultMessage: "Cancelled",
})}
</span>
</Typography>
</IconChip>
) : (
<div className={styles.chip}>
<Typography variant="Tag/sm">
<span>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNumber }
)}
</span>
</Typography>
</div>
)}
<div className={styles.reference}>
<Typography variant="Body/Supporting text (caption)/smBold">
{isCancelled ? (
<span>
{intl.formatMessage({
defaultMessage: "Cancellation no",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{":"}
</span>
) : (
<span>
{intl.formatMessage({
defaultMessage: "Booking number",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{":"}
</span>
)}
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
{isCancelled ? (
<span className={styles.cancellationNumber}>
{cancellationNumber}
</span>
) : (
<span>{confirmationNumber}</span>
)}
</Typography>
</div>
</div>
<div className={styles.mainContent}>
<div className={styles.imageContainer}>
{galleryImages ? (
<ImageGallery
height={280}
images={galleryImages}
title={hotelRoomName}
/>
) : null}
</div>
<div className={styles.roomDetails}>
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="person" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Guests",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{childrenInRoom.length > 0
? adultsAndChildrenMsg
: adultsOnlyMsg}
</p>
</Typography>
</div>
</div>
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="contract" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Terms",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{terms}</p>
</Typography>
</div>
</div>
{hasModifiableRate(rateDefinition.cancellationRule) && (
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="refresh" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Modify By",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "Until {time}, {date}",
},
{
time: "18:00",
date: fromDate.format(changeOrCancelDateFormat[lang]),
}
)}
</p>
</Typography>
</div>
</div>
)}
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="coffee" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Breakfast",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{breakfastPrice}</p>
</Typography>
</div>
</div>
{packages?.some((item) =>
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
) && (
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon
icon="meeting_room"
color="Icon/Default"
size={20}
/>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Room classification",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{packages
?.filter((item) =>
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
)
.map((item) =>
getRoomFeatureDescription(
item.code,
item.description,
intl
)
)
.join(", ")}
</p>
</Typography>
</div>
</div>
)}
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="bed" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Bed preference",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{bedType?.description}</p>
</Typography>
</div>
</div>
</div>
<div className={styles.bookingInformation}>
<div className={styles.priceDetails}>
<div className={styles.price}>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage: "Room total",
})}
</p>
</Typography>
<PriceType
cheques={cheques}
formattedTotalPrice={formattedTotalPrice}
isCancelled={isCancelled}
priceType={priceType}
currencyCode={currencyCode}
rateDefinition={rateDefinition}
totalPoints={totalPoints}
totalPrice={totalRoomPrice}
vouchers={vouchers}
/>
</div>
</div>
</div>
{bookingCode && (
<Typography variant="Body/Supporting text (caption)/smBold">
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
{intl.formatMessage(
{
defaultMessage: "<strong>Booking code</strong>: {value}",
},
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
)}
<GuestDetails
refId={refId}
guest={guest}
isCancelled={isCancelled}
user={user}
/>
</div>
{hotelRoom ? (
<Accordion type="sidepeek">
<AccordionItem
title={intl.formatMessage({
defaultMessage: "Room details",
})}
type="sidepeek"
>
<RoomDetails
roomDescription={hotelRoom.descriptions.medium}
roomFacilities={hotelRoom.roomFacilities}
roomTypes={hotelRoom.roomTypes}
/>
</AccordionItem>
</Accordion>
) : null}
</div>
</SidePeekSelfControlled>
)
}

View File

@@ -8,7 +8,7 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { getBedIconName } from "@/components/utils"
import styles from "./bookedRoomSidePeek.module.css"
import styles from "./bookedRoomSidePeekContent.module.css"
import type { RoomDetailsProps } from "@/types/components/sidePeeks/bookedRoomSidePeek"

View File

@@ -0,0 +1,457 @@
import { useIntl } from "react-intl"
import { getRoomFeatureDescription } from "@scandic-hotels/booking-flow/utils/getRoomFeatureDescription"
import { sumPackages } from "@scandic-hotels/booking-flow/utils/SelectRate"
import { changeOrCancelDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Accordion from "@scandic-hotels/design-system/Accordion"
import AccordionItem from "@scandic-hotels/design-system/Accordion/AccordionItem"
import IconChip from "@scandic-hotels/design-system/IconChip"
import DiscountIcon from "@scandic-hotels/design-system/Icons/DiscountIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
import GuestDetails from "@/components/HotelReservation/MyStay/GuestDetails"
import PriceType from "@/components/HotelReservation/MyStay/PriceType"
import { hasModifiableRate } from "@/components/HotelReservation/MyStay/utils"
import useLang from "@/hooks/useLang"
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
import RoomDetails from "./RoomDetails"
import styles from "./bookedRoomSidePeekContent.module.css"
import type { BedTypeSchema } from "@scandic-hotels/booking-flow/stores/enter-details/types"
import type { BreakfastPackage } from "@scandic-hotels/trpc/routers/hotels/schemas/packages"
import type { BookingConfirmationSchema } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { Room as HotelRoom } from "@scandic-hotels/trpc/types/hotel"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { PriceTypeEnum } from "@/types/components/hotelReservation/myStay/myStay"
import type { SafeUser } from "@/types/user"
type PartialHotelRoom = Pick<
HotelRoom,
"descriptions" | "images" | "name" | "roomFacilities" | "roomTypes"
>
type Room = Pick<
BookingConfirmationSchema,
| "adults"
| "bookingCode"
| "cancellationNumber"
| "checkInDate"
| "cheques"
| "confirmationNumber"
| "refId"
| "currencyCode"
| "guest"
| "rateDefinition"
| "totalPoints"
| "totalPrice"
| "vouchers"
> & {
bedType: BedTypeSchema
breakfast: Omit<BreakfastPackage, "requestedPrice"> | false | undefined
childrenInRoom: Child[]
isCancelled: boolean
packages: Packages | null
priceType: PriceTypeEnum
room: PartialHotelRoom | null
roomName: string
roomNumber: number
terms: string | null
}
interface BookedRoomSidepeekContentProps {
room: Room
user: SafeUser
}
export default function BookedRoomSidePeekContent({
room,
user,
}: BookedRoomSidepeekContentProps) {
const intl = useIntl()
const lang = useLang()
const {
adults,
bedType,
bookingCode,
breakfast,
cancellationNumber,
checkInDate,
cheques,
childrenInRoom,
confirmationNumber,
refId,
currencyCode,
guest,
isCancelled,
roomName,
packages,
priceType,
rateDefinition,
room: hotelRoom,
roomNumber,
totalPoints,
terms,
totalPrice,
vouchers,
} = room
let totalRoomPrice = totalPrice
// API returns negative values for totalPrice
// on voucher bookings (╯°□°)╯︵ ┻━┻
if (vouchers && totalRoomPrice < 0) {
const pkgsSum = sumPackages(packages)
totalRoomPrice = pkgsSum.price
}
const fromDate = dt(checkInDate).locale(lang)
const galleryImages = hotelRoom
? mapApiImagesToGalleryImages(hotelRoom.images)
: null
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
},
{
adults: adults,
}
)
const childrenMsg = intl.formatMessage(
{
defaultMessage: "{children, plural, one {# child} other {# children}}",
},
{
children: childrenInRoom.length,
}
)
const adultsOnlyMsg = adultsMsg
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
const formattedTotalPrice = formatPrice(intl, totalPrice, currencyCode)
let breakfastPrice = intl.formatMessage({
defaultMessage: "No breakfast",
})
if (rateDefinition.breakfastIncluded) {
breakfastPrice = intl.formatMessage({
defaultMessage: "Included",
})
} else if (breakfast) {
breakfastPrice = formatPrice(
intl,
breakfast.localPrice.totalPrice,
breakfast.localPrice.currency
)
}
const hotelRoomName = hotelRoom?.name || roomName
return (
<div className={styles.wrapper}>
<div className={styles.roomHeader}>
{isCancelled ? (
<IconChip
color={"red"}
icon={
<MaterialIcon
icon="cancel"
size={20}
color="Icon/Feedback/Error"
/>
}
>
<Typography variant="Body/Supporting text (caption)/smBold">
<span>
{intl.formatMessage({
defaultMessage: "Cancelled",
})}
</span>
</Typography>
</IconChip>
) : (
<div className={styles.chip}>
<Typography variant="Tag/sm">
<span>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomNumber }
)}
</span>
</Typography>
</div>
)}
<div className={styles.reference}>
<Typography variant="Body/Supporting text (caption)/smBold">
{isCancelled ? (
<span>
{intl.formatMessage({
defaultMessage: "Cancellation no",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{":"}
</span>
) : (
<span>
{intl.formatMessage({
defaultMessage: "Booking number",
})}
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{":"}
</span>
)}
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
{isCancelled ? (
<span className={styles.cancellationNumber}>
{cancellationNumber}
</span>
) : (
<span>{confirmationNumber}</span>
)}
</Typography>
</div>
</div>
<div className={styles.mainContent}>
<div className={styles.imageContainer}>
{galleryImages ? (
<ImageGallery
height={280}
images={galleryImages}
title={hotelRoomName}
/>
) : null}
</div>
<div className={styles.roomDetails}>
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="person" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Guests",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{childrenInRoom.length > 0
? adultsAndChildrenMsg
: adultsOnlyMsg}
</p>
</Typography>
</div>
</div>
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="contract" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Terms",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{terms}</p>
</Typography>
</div>
</div>
{hasModifiableRate(rateDefinition.cancellationRule) && (
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="refresh" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Modify By",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "Until {time}, {date}",
},
{
time: "18:00",
date: fromDate.format(changeOrCancelDateFormat[lang]),
}
)}
</p>
</Typography>
</div>
</div>
)}
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="coffee" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Breakfast",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{breakfastPrice}</p>
</Typography>
</div>
</div>
{packages?.some((item) =>
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
) && (
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon
icon="meeting_room"
color="Icon/Default"
size={20}
/>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Room classification",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{packages
?.filter((item) =>
Object.values(RoomPackageCodeEnum).includes(
item.code as RoomPackageCodeEnum
)
)
.map((item) =>
getRoomFeatureDescription(
item.code,
item.description,
intl
)
)
.join(", ")}
</p>
</Typography>
</div>
</div>
)}
<div className={styles.row}>
<span className={styles.rowTitle}>
<MaterialIcon icon="bed" color="Icon/Default" size={20} />
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Bed preference",
})}
</p>
</Typography>
</span>
<div className={styles.rowContent}>
<Typography variant="Body/Paragraph/mdRegular">
<p>{bedType?.description}</p>
</Typography>
</div>
</div>
</div>
<div className={styles.bookingInformation}>
<div className={styles.priceDetails}>
<div className={styles.price}>
<Typography variant="Body/Lead text">
<p>
{intl.formatMessage({
defaultMessage: "Room total",
})}
</p>
</Typography>
<PriceType
cheques={cheques}
formattedTotalPrice={formattedTotalPrice}
isCancelled={isCancelled}
priceType={priceType}
currencyCode={currencyCode}
rateDefinition={rateDefinition}
totalPoints={totalPoints}
totalPrice={totalRoomPrice}
vouchers={vouchers}
/>
</div>
</div>
</div>
{bookingCode && (
<Typography variant="Body/Supporting text (caption)/smBold">
<IconChip
color="blue"
icon={<DiscountIcon color="Icon/Feedback/Information" />}
>
{intl.formatMessage(
{
defaultMessage: "<strong>Booking code</strong>: {value}",
},
{
value: bookingCode,
strong: (text) => (
<Typography variant="Body/Supporting text (caption)/smBold">
<strong>{text}</strong>
</Typography>
),
}
)}
</IconChip>
</Typography>
)}
<GuestDetails
refId={refId}
guest={guest}
isCancelled={isCancelled}
user={user}
/>
</div>
{hotelRoom ? (
<Accordion type="sidepeek">
<AccordionItem
title={intl.formatMessage({
defaultMessage: "Room details",
})}
type="sidepeek"
>
<RoomDetails
roomDescription={hotelRoom.descriptions.medium}
roomFacilities={hotelRoom.roomFacilities}
roomTypes={hotelRoom.roomTypes}
/>
</AccordionItem>
</Accordion>
) : null}
</div>
)
}

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>