Files
web/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx
Tobias Johansson 13261d425c Merged in feat/SW-2703-mobile-summary-improvements (pull request #2060)
Feat/SW-2703 mobile price summary improvements

* feat(SW-2703): fixes to select rate price summary

* feat(SW-2703): fixes enter details summary mobile

* fix: z-index issue related to booking widget popover

* fix

* fix: added accessibility props to overlay div

* fix: added button inside header

* fix: rename aria button


Approved-by: Michael Zetterberg
2025-05-13 09:22:34 +00:00

531 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { Fragment } from "react"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { dt } from "@/lib/dt"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import Modal from "@/components/Modal"
import Divider from "@/components/TempDesignSystem/Divider"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { mapToPrice } from "./mapToPrice"
import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
import { CurrencyEnum } from "@/types/enums/currency"
const notDisplayableCurrencies = [
CurrencyEnum.CC,
CurrencyEnum.POINTS,
CurrencyEnum.Voucher,
CurrencyEnum.Unknown,
]
export default function SummaryUI({
booking,
rooms,
totalPrice,
isMember,
vat,
toggleSummaryOpen,
}: EnterDetailsSummaryProps) {
const intl = useIntl()
const lang = useLang()
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
const nightsMsg = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: nights }
)
function handleToggleSummary() {
if (toggleSummaryOpen) {
toggleSummaryOpen()
}
}
function getMemberPrice(roomRate: RoomRate) {
if ("member" in roomRate && roomRate.member) {
return {
amount: roomRate.member.localPrice.pricePerStay,
currency: roomRate.member.localPrice.currency,
pricePerNight: roomRate.member.localPrice.pricePerNight,
}
}
return null
}
const roomOneGuest = rooms[0].room.guest
const showSignupPromo =
rooms.length === 1 &&
!isMember &&
!roomOneGuest.membershipNo &&
!roomOneGuest.join
const roomOneMemberPrice = getMemberPrice(rooms[0].room.roomRate)
const roomOneRoomRate = rooms[0].room.roomRate
// In case of Redemption, voucher and Corporate cheque do not show approx price
const isSpecialRate =
"corporateCheque" in roomOneRoomRate ||
"redemption" in roomOneRoomRate ||
"voucher" in roomOneRoomRate
const isSameCurrency = totalPrice.requested
? totalPrice.requested.currency === totalPrice.local.currency
: false
const priceDetailsRooms = mapToPrice(rooms, isMember)
const isAllCampaignRate = rooms.every(
(room) => room.room.roomRate.rateDefinition.isCampaignRate
)
const isAllBreakfastIncluded = rooms.every(
(room) => room.room.roomRate.rateDefinition.breakfastIncluded
)
return (
<section className={styles.summary}>
<header
className={styles.header}
role="button"
onClick={handleToggleSummary}
>
<Subtitle className={styles.title} type="two">
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</Subtitle>
<Body className={styles.date} color="baseTextMediumContrast">
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
<MaterialIcon
icon="arrow_right"
color="Icon/Interactive/Secondary"
size={15}
/>
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nightsMsg})
</Body>
<MaterialIcon
className={styles.chevronIcon}
icon="keyboard_arrow_down"
size={30}
color="CurrentColor"
/>
</header>
<Divider color="primaryLightSubtle" />
{rooms.map(({ room }, idx) => {
const roomNumber = idx + 1
const adults = room.adults
const childrenInRoom = room.childrenInRoom
const childrenBeds = childrenInRoom?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = getMemberPrice(room.roomRate)
const isFirstRoomMember = roomNumber === 1 && isMember
const isOrWillBecomeMember = !!(
room.guest.join ||
room.guest.membershipNo ||
isFirstRoomMember
)
const showMemberPrice = !!(isOrWillBecomeMember && memberPrice)
const adultsMsg = intl.formatMessage(
{
defaultMessage:
"{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
const hideBedCurrency = notDisplayableCurrencies.includes(
room.roomPrice.perStay.local.currency
)
let rateDetails = room.rateDetails
if (room.memberRateDetails) {
if (isMember || room.guest.join) {
rateDetails = room.memberRateDetails
}
}
return (
<Fragment key={idx}>
<div
className={styles.addOns}
data-testid={`summary-room-${roomNumber}`}
>
<div>
{rooms.length > 1 ? (
<Body textTransform="bold">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</Body>
) : null}
<div className={styles.entry}>
<Body color="uiTextHighContrast">{room.roomType}</Body>
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
{showMemberPrice
? formatPrice(
intl,
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</Body>
</div>
<Caption color="uiTextMediumContrast">
{guestsParts.join(", ")}
</Caption>
<Caption color="uiTextMediumContrast">
{room.cancellationText}
</Caption>
{rateDetails ? (
<Modal
trigger={
<Button
variant="Text"
typography="Body/Supporting text (caption)/smBold"
wrapping={false}
>
{intl.formatMessage({
defaultMessage: "Rate details",
})}
<MaterialIcon
icon="chevron_right"
size={20}
color="CurrentColor"
/>
</Button>
}
title={
room.rateTitle ? room.rateTitle : room.cancellationText
}
subtitle={
room.rateTitle ? room.cancellationText : undefined
}
>
<div className={styles.terms}>
{rateDetails.map((info) => {
return (
<Body
key={info}
color="uiTextHighContrast"
className={styles.termsText}
>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</Body>
)
})}
</div>
</Modal>
) : null}
</div>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<Body color="uiTextHighContrast">
{feature.description}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
feature.localPrice.price,
feature.localPrice.currency
)}
</Body>
</div>
))
: null}
{room.bedType ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{room.bedType.description}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
hideBedCurrency
? ""
: room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib }
)}
</Body>
<Caption color="uiTextMediumContrast">
{intl.formatMessage({
defaultMessage: "Based on availability",
})}
</Caption>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed,
}
)}
</Body>
</div>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{room.breakfastIncluded ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Breakfast buffet",
})}
</Body>
<Body color="red">
{intl.formatMessage({
defaultMessage: "Included",
})}
</Body>
</div>
) : room.breakfast === false ? (
<div className={styles.entry}>
<Body color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "No breakfast",
})}
</Body>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.roomPrice.perStay.local.currency
)}
</Body>
</div>
) : null}
{room.breakfast ? (
<div>
<Body color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Breakfast buffet",
})}
</Body>
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage:
"{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
room.breakfast.localPrice.price * adults * nights,
room.breakfast.localPrice.currency
)}
</Body>
</div>
{childrenInRoom?.length ? (
<div className={styles.entry}>
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)}
</Caption>
<Body color="uiTextHighContrast">
{formatPrice(
intl,
0,
room.breakfast.localPrice.currency
)}
</Body>
</div>
) : null}
</div>
) : null}
</div>
<Divider color="primaryLightSubtle" />
</Fragment>
)
})}
<div className={styles.total}>
<div className={styles.entry}>
<div>
<Body>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{ b: (str) => <b>{str}</b> }
)}
</Body>
<PriceDetailsModal
bookingCode={booking.bookingCode}
fromDate={booking.fromDate}
isCampaignRate={isAllCampaignRate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</div>
<div>
<Body textTransform="bold" data-testid="total-price">
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Body>
{totalPrice.local.regularPrice ? (
<Caption color="uiTextMediumContrast" striked={true}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</Caption>
) : null}
{totalPrice.requested && !isSpecialRate && !isSameCurrency && (
<Caption color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
),
}
)}
</Caption>
)}
</div>
</div>
<BookingCodeChip
isCampaign={isAllCampaignRate}
bookingCode={booking.bookingCode}
isBreakfastIncluded={isAllBreakfastIncluded}
alignCenter
/>
<Divider className={styles.bottomDivider} color="primaryLightSubtle" />
</div>
{showSignupPromo && roomOneMemberPrice && !isMember ? (
<SignupPromoDesktop
memberPrice={roomOneMemberPrice}
badgeContent={"✌️"}
isEnterDetailsPage
/>
) : null}
</section>
)
}