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

@@ -0,0 +1,100 @@
"use client"
import { useIntl } from "react-intl"
import { FacilityIcon } from "@scandic-hotels/design-system/Icons/FacilityIcon"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getBedIconName } from "@/components/utils"
import styles from "./bookedRoomSidePeekContent.module.css"
import type { RoomDetailsProps } from "@/types/components/sidePeeks/bookedRoomSidePeek"
export default function RoomDetails({
roomDescription,
roomFacilities,
roomTypes,
}: RoomDetailsProps) {
const intl = useIntl()
const filteredSortedFacilities = [...roomFacilities]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((facility) => {
const Icon = <FacilityIcon name={facility.icon} color="Icon/Default" />
return { ...facility, Icon }
})
const bedOptions = roomTypes.map((roomType) => {
const bedIconName = getBedIconName(roomType.mainBed.type)
return { ...roomType, bedIconName }
})
return (
<div className={styles.descriptionContainer}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.text}>{roomDescription}</p>
</Typography>
<div className={styles.listContainer}>
<Typography variant="Title/Subtitle/md">
<p className={styles.text}>
{intl.formatMessage({
defaultMessage: "This room is equipped with",
})}
</p>
</Typography>
<ul className={styles.facilityList}>
{filteredSortedFacilities.map(
({ name, Icon, availableInAllRooms }) => (
<li key={name}>
{Icon}
<Typography variant="Body/Paragraph/mdRegular">
<span className={styles.listText}>
{availableInAllRooms
? name
: intl.formatMessage(
{
defaultMessage:
"{facility} (available in some rooms)",
},
{
facility: name,
}
)}
</span>
</Typography>
</li>
)
)}
</ul>
</div>
<div className={styles.listContainer}>
<Typography variant="Title/Subtitle/md">
<p className={styles.text}>
{intl.formatMessage({
defaultMessage: "Bed options",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.text}>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
<ul className={styles.bedOptions}>
{bedOptions.map(({ code, mainBed, bedIconName }) => (
<li key={code}>
<MaterialIcon icon={bedIconName} color="Icon/Default" size={24} />
<Typography variant="Body/Paragraph/mdRegular">
<span className={styles.listText}>{mainBed.description}</span>
</Typography>
</li>
))}
</ul>
</div>
</div>
)
}

View File

@@ -0,0 +1,190 @@
.button {
margin-left: auto;
padding: 0 0 0 var(--Spacing-x-half);
text-decoration: none;
}
.wrapper {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
position: relative;
}
.mainContent {
display: flex;
flex-direction: column;
gap: var(--Spacing-x3);
}
.listContainer {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.roomHeader {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.chip {
background-color: var(--Scandic-Peach-30);
color: var(--Scandic-Red-100);
border-radius: var(--Corner-radius-sm);
padding: var(--Spacing-x-half) var(--Spacing-x1);
height: fit-content;
}
.reference {
display: flex;
gap: var(--Spacing-x-half);
}
.imageContainer {
position: relative;
border-radius: var(--Corner-radius-md);
overflow: hidden;
}
.imageContainer img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
}
.roomDetails {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
}
.row {
display: flex;
flex-direction: column;
}
.row:last-child {
border-bottom: none;
}
.rowTitle {
display: flex;
flex-direction: row;
gap: var(--Spacing-x1);
}
.rowTitle svg {
width: 24px;
height: 24px;
}
.rowContent {
padding-left: var(--Spacing-x4);
}
.bookingInformation {
display: flex;
flex-direction: column-reverse;
align-items: center;
gap: var(--Spacing-x2);
background-color: var(--Scandic-Beige-10);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-md);
}
.priceDetails {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--Spacing-x1);
padding: var(--Spacing-x-one-and-half) 0;
width: calc(100% - var(--Spacing-x4));
justify-content: center;
margin: 0 auto;
}
.price {
display: flex;
gap: var(--Spacing-x1);
justify-content: space-between;
align-items: center;
width: 100%;
}
.bookingCode {
color: var(--UI-Semantic-Information);
}
.facilityList {
column-count: 2;
column-gap: var(--Spacing-x2);
}
.facilityList li {
display: flex !important; /* Overrides the display none from grids.stackable on Hotel Page */
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.bedOptions li {
display: flex;
gap: var(--Spacing-x1);
margin-bottom: var(--Spacing-x-half);
}
.facilityList li svg {
flex-shrink: 0;
}
.noIcon {
margin-left: var(--Spacing-x4);
}
.buttonContainer {
background-color: var(--Background-Primary);
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x4) var(--Spacing-x2);
width: 100%;
position: absolute;
left: 0;
bottom: 0;
}
.roomDetailsContainer {
display: none;
}
.roomDetailsContainer.open {
display: block;
}
.descriptionContainer {
display: flex;
flex-direction: column;
padding: var(--Spacing-x3);
gap: var(--Spacing-x3);
background-color: var(--Main-Grey-White);
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-sm);
}
.text {
color: var(--Scandic-Grey-100);
}
.listText {
color: var(--Scandic-Grey-80);
}
.hidden {
display: none;
}
.visible {
display: block;
}
.cancellationNumber {
text-decoration: line-through;
}

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