Merged in chore/SW-2878-extract-booking-confirmation-pag (pull request #2779)

Chore/SW-2878 extract booking confirmation pag

* chore(SW-2878): Moved booking confirmation page to booking-flow package

* chore(SW-2878): Fixed promo styles as per design

* chore(SW-2878): Kept tiny duplicate function to avoid export from booking-flow package


Approved-by: Anton Gunnarsson
This commit is contained in:
Hrishikesh Vaipurkar
2025-09-10 07:50:48 +00:00
parent c6da0fb8cb
commit a5790ee454
77 changed files with 410 additions and 371 deletions

View File

@@ -0,0 +1,33 @@
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import styles from "./linkedReservationCardSkeleton.module.css"
export function LinkedReservationCardSkeleton() {
return (
<div className={styles.card}>
<div className={styles.content}>
<div className={styles.img}>
<SkeletonShimmer height={"204px"} width={"100%"} />
</div>
<div className={styles.roomDetails}>
<div className={styles.roomName}>
<SkeletonShimmer height={"24px"} width={"130px"} />
<SkeletonShimmer height={"20px"} width={"140px"} />
</div>
<div className={styles.details}>
<SkeletonShimmer height={"20px"} width={"300px"} />
<SkeletonShimmer height={"20px"} width={"300px"} />
<SkeletonShimmer height={"20px"} width={"300px"} />
<SkeletonShimmer height={"20px"} width={"300px"} />
</div>
<div className={styles.guest}>
<SkeletonShimmer height={"20px"} width={"100px"} />
<SkeletonShimmer height={"20px"} width={"200px"} />
<SkeletonShimmer height={"20px"} width={"150px"} />
<SkeletonShimmer height={"20px"} width={"300px"} />
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
"use client"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./retry.module.css"
export interface RetryProps {
handleRefetch: () => void
}
export default function Retry({ handleRefetch }: RetryProps) {
const intl = useIntl()
return (
<div className={styles.retry}>
<Typography variant={"Body/Paragraph/mdRegular"}>
<p>
{intl.formatMessage({
defaultMessage: "Something went wrong!",
})}
</p>
</Typography>
<Button size={"Small"} onPress={handleRefetch}>
{intl.formatMessage({
defaultMessage: "Try again",
})}
</Button>
</div>
)
}

View File

@@ -0,0 +1,95 @@
"use client"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { trpc } from "@scandic-hotels/trpc/client"
import useLang from "../../../../hooks/useLang"
import { useBookingConfirmationStore } from "../../../../stores/booking-confirmation"
import { mapRoomState } from "../../utils"
import { Room } from "../Room"
import { LinkedReservationCardSkeleton } from "./LinkedReservationCardSkeleton"
import Retry from "./Retry"
export interface LinkedReservationProps {
checkInTime: string
checkOutTime: string
refId: string
roomIndex: number
}
export function LinkedReservation({
checkInTime,
checkOutTime,
refId,
roomIndex,
}: LinkedReservationProps) {
const lang = useLang()
const { data, refetch, isLoading } = trpc.booking.get.useQuery({
refId,
lang,
})
const {
setRoom,
setFormattedTotalCost,
currencyCode,
totalBookingPrice,
totalBookingCheques,
} = useBookingConfirmationStore((state) => ({
setRoom: state.actions.setRoom,
setFormattedTotalCost: state.actions.setFormattedTotalCost,
currencyCode: state.currencyCode,
totalBookingPrice: state.totalBookingPrice,
totalBookingCheques: state.totalBookingCheques,
}))
const intl = useIntl()
useEffect(() => {
if (data?.room) {
const roomData = mapRoomState(data.booking, data.room, intl)
setRoom(roomData, roomIndex)
const formattedTotalCost = totalBookingCheques
? formatPrice(
intl,
totalBookingCheques,
CurrencyEnum.CC,
totalBookingPrice,
currencyCode
)
: formatPrice(intl, totalBookingPrice, currencyCode)
setFormattedTotalCost(formattedTotalCost)
}
}, [
data,
roomIndex,
intl,
setRoom,
totalBookingCheques,
totalBookingPrice,
currencyCode,
setFormattedTotalCost,
])
if (isLoading) {
return <LinkedReservationCardSkeleton />
}
if (!data?.room) {
return <Retry handleRefetch={refetch} />
}
return (
<Room
booking={data.booking}
checkInTime={checkInTime}
checkOutTime={checkOutTime}
img={data.room.images[0]}
roomName={data.room.name}
/>
)
}

View File

@@ -0,0 +1,66 @@
.card {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.content {
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
}
.img {
border-radius: var(--Corner-radius-md);
overflow: hidden;
}
.roomDetails {
display: grid;
gap: var(--Spacing-x2);
}
.roomName {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
grid-column: 1/-1;
justify-content: space-evenly;
}
.details {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-evenly;
}
.guest {
display: flex;
flex-direction: column;
gap: var(--Spacing-x1);
justify-content: space-evenly;
}
@media screen and (min-width: 1367px) {
.content {
gap: var(--Spacing-x3);
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2)
var(--Spacing-x2);
}
.img {
min-width: 306px;
}
.roomDetails {
grid-template-columns: 1fr 1fr;
}
.guest {
align-items: flex-end;
}
}

View File

@@ -0,0 +1,10 @@
.retry {
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
align-items: center;
}

View File

@@ -0,0 +1,246 @@
"use client"
import { useIntl } from "react-intl"
import { CancellationRuleEnum } from "@scandic-hotels/common/constants/booking"
import {
changeOrCancelDateFormat,
longDateFormat,
} from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import Image from "@scandic-hotels/design-system/Image"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { getHotelRoom } from "@scandic-hotels/trpc/routers/booking/helpers"
import { RoomDetailsSidePeek } from "../../../../components/RoomDetailsSidePeek"
import useLang from "../../../../hooks/useLang"
import { useBookingConfirmationStore } from "../../../../stores/booking-confirmation"
import styles from "./room.module.css"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
export interface RoomProps {
booking: BookingConfirmation["booking"]
checkInTime: string
checkOutTime: string
img?: NonNullable<BookingConfirmation["room"]>["images"][number]
roomName: NonNullable<BookingConfirmation["room"]>["name"]
}
export function Room({
booking,
checkInTime,
checkOutTime,
img,
roomName,
}: RoomProps) {
const intl = useIntl()
const lang = useLang()
const { roomCategories } = useBookingConfirmationStore((state) => ({
roomCategories: state.roomCategories,
}))
const room = getHotelRoom(roomCategories, booking.roomTypeCode)
const guestName = `${booking.guest.firstName} ${booking.guest.lastName}`
const fromDate = dt(booking.checkInDate).locale(lang)
const toDate = dt(booking.checkOutDate).locale(lang)
const isFlexBooking =
booking.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
const isChangeBooking =
booking.rateDefinition.cancellationRule === CancellationRuleEnum.Changeable
return (
<article className={styles.room}>
<header className={styles.header}>
<Typography variant="Title/Subtitle/md">
<h2>
{intl.formatMessage(
{
defaultMessage: "Booking number {value}",
},
{ value: booking.confirmationNumber }
)}
</h2>
</Typography>
{booking.rateDefinition.isMemberRate ? (
<div className={styles.benefits}>
<>
<MaterialIcon
color="Icon/Feedback/Success"
icon="check_circle"
size={20}
/>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Membership benefits applied",
})}
</p>
</Typography>
</>
</div>
) : null}
{booking.guaranteeInfo && (
<div className={styles.benefits}>
<MaterialIcon
icon="check_circle"
color="Icon/Feedback/Success"
size={20}
/>
<Typography
variant="Body/Supporting text (caption)/smBold"
className={styles.guaranteeText}
>
<p>
{intl.formatMessage({
defaultMessage: "Booking guaranteed.",
})}
</p>
</Typography>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}{" "}
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage:
"Your room will remain available for check-in even after 18:00.",
})}
</p>
</Typography>
</div>
)}
</header>
<div className={styles.booking}>
<Image
alt={img?.metaData.altText ?? ""}
className={styles.img}
focalPoint={{ x: 50, y: 50 }}
height={204}
src={img?.imageSizes.medium ?? ""}
style={{ borderRadius: "var(--Corner-radius-md)" }}
title={img?.metaData.title || img?.metaData.title_En || ""}
width={204}
/>
<div className={styles.roomDetails}>
<div className={styles.roomName}>
<Typography variant="Title/Subtitle/md">
<h2>{roomName}</h2>
</Typography>
{room && (
<RoomDetailsSidePeek
hotelId={booking.hotelId}
room={room}
roomTypeCode={booking.roomTypeCode}
buttonVariant="primary"
triggerLabel={intl.formatMessage({
defaultMessage: "View room details",
})}
wrapping={false}
/>
)}
</div>
<Typography variant="Body/Paragraph/mdRegular">
<ul className={styles.details}>
<li className={styles.listItem}>
<p className={styles.label}>
{intl.formatMessage({
defaultMessage: "Check-in",
})}
</p>
<p>
{intl.formatMessage(
{
defaultMessage: "{checkInDate} from {checkInTime}",
},
{
checkInDate: fromDate.format(longDateFormat[lang]),
checkInTime: checkInTime,
}
)}
</p>
</li>
<li className={styles.listItem}>
<p className={styles.label}>
{intl.formatMessage({
defaultMessage: "Check-out",
})}
</p>
<p>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{`${toDate.format(longDateFormat[lang])}, ${checkOutTime}`}
</p>
</li>
<li className={styles.listItem}>
<p className={styles.label}>
{intl.formatMessage({
defaultMessage: "Cancellation policy",
})}
</p>
<p>{booking.rateDefinition.cancellationText}</p>
</li>
{isFlexBooking || isChangeBooking ? (
<li className={styles.listItem}>
<p className={styles.label}>
{intl.formatMessage({
defaultMessage: "Change or cancel",
})}
</p>
<p>
{intl.formatMessage(
{
defaultMessage: "Until {time}, {date}",
},
{
time: "18:00",
date: fromDate.format(changeOrCancelDateFormat[lang]),
}
)}
</p>
</li>
) : null}
</ul>
</Typography>
<div className={styles.guest}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.label}>
{intl.formatMessage({
defaultMessage: "Guest",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p data-hj-suppress>{guestName}</p>
</Typography>
{booking.guest.membershipNumber ? (
<Typography variant="Body/Paragraph/mdRegular">
<p data-hj-suppress>
{intl.formatMessage(
{
defaultMessage: "Friend no. {value}",
},
{
value: booking.guest.membershipNumber,
}
)}
</p>
</Typography>
) : null}
{booking.guest.phoneNumber ? (
<Typography variant="Body/Paragraph/mdRegular">
<p data-hj-suppress>{booking.guest.phoneNumber}</p>
</Typography>
) : null}
{booking.guest.email ? (
<Typography variant="Body/Paragraph/mdRegular">
<p data-hj-suppress>{booking.guest.email}</p>
</Typography>
) : null}
</div>
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,114 @@
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.header {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.benefits {
align-items: center;
border: 1px solid var(--Base-Border-Subtle);
border-radius: var(--Corner-radius-md);
display: flex;
gap: var(--Spacing-x1);
padding: var(--Spacing-x1) var(--Spacing-x-one-and-half);
width: min(max-content, 100%);
}
.guaranteeText {
color: var(--Text-Feedback-Succes-Accent);
}
.booking {
background-color: var(--Background-Primary);
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Spacing-x2);
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x3)
var(--Spacing-x2);
}
.img {
width: 100%;
}
.roomDetails {
display: grid;
gap: var(--Spacing-x2);
}
.roomName {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--Spacing-x-half);
grid-column: 1/-1;
}
.details {
display: grid;
gap: var(--Spacing-x-half) var(--Spacing-x3);
list-style: none;
}
.listItem {
display: grid;
grid-template-columns: 1fr 1fr;
justify-content: space-between;
gap: var(--Space-x3);
}
.guest {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
}
.label {
color: var(--Text-Tertiary);
}
.details p:nth-of-type(even) {
text-align: right;
}
@media screen and (max-width: 1366px) {
.details {
padding-bottom: var(--Space-x1);
}
}
@media screen and (min-width: 1367px) {
.header {
grid-template-columns: 1fr auto;
}
.details p:nth-of-type(even) {
text-align: left;
}
.img {
width: 204px;
}
.booking {
gap: var(--Spacing-x3);
grid-template-columns: auto 1fr;
padding: var(--Spacing-x2) var(--Spacing-x3) var(--Spacing-x2)
var(--Spacing-x2);
}
.roomDetails {
grid-template-columns: 1fr auto;
}
.guest {
align-items: flex-end;
align-self: flex-end;
}
}

View File

@@ -0,0 +1,78 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { LinkedReservation } from "./LinkedReservation"
import { Room } from "./Room"
import styles from "./rooms.module.css"
import type { BookingConfirmation } from "@scandic-hotels/trpc/types/bookingConfirmation"
import type { Room as RoomProp } from "@scandic-hotels/trpc/types/hotel"
export interface BookingConfirmationRoomsProps
extends Pick<BookingConfirmation, "booking"> {
mainRoom: RoomProp & {
bedType: RoomProp["roomTypes"][number]
}
checkInTime: string
checkOutTime: string
}
export function Rooms({
booking,
checkInTime,
checkOutTime,
mainRoom,
}: BookingConfirmationRoomsProps) {
const intl = useIntl()
return (
<section className={styles.rooms}>
<div className={styles.room}>
{booking.linkedReservations.length ? (
<Typography variant="Title/Subtitle/md">
<h2 className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: 1 }
)}
</h2>
</Typography>
) : null}
<Room
booking={booking}
checkInTime={checkInTime}
checkOutTime={checkOutTime}
img={mainRoom.images[0]}
roomName={mainRoom.name}
/>
</div>
{booking.linkedReservations.map((reservation, idx) => (
<div className={styles.room} key={reservation.confirmationNumber}>
<Typography variant="Title/Subtitle/md">
<h2 className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: idx + 2 }
)}
</h2>
</Typography>
<LinkedReservation
checkInTime={checkInTime}
checkOutTime={checkOutTime}
refId={reservation.refId}
roomIndex={idx + 1}
/>
</div>
))}
</section>
)
}

View File

@@ -0,0 +1,15 @@
.rooms {
display: flex;
flex-direction: column;
gap: var(--Spacing-x5);
}
.room {
display: flex;
flex-direction: column;
gap: var(--Space-x025);
}
.roomTitle {
color: var(--Text-Tertiary);
}