Merged in feat/SW-1719-strikethrough-rates (pull request #2266)
Feat/SW-1719 strikethrough rates * feat(SW-1719): Strikethrough rate if logged in on regular rate cards * feat(SW-1719): Strikethrough rate if logged in on rate summary * feat(SW-1719): Strikethrough rate if logged in on mobile rate summary * feat(SW-1719): Strikethrough rate if logged in on enter details * feat(SW-1719): Strikethrough rate support for multiple rooms * feat(SW-1719): booking receipt fixes on confirmation page * feat(SW-1719): improve initial total price calculation * feat: harmonize enter details total price to use one and the same function Approved-by: Michael Zetterberg
This commit is contained in:
committed by
Michael Zetterberg
parent
e1ede52014
commit
85acd3453d
@@ -0,0 +1,13 @@
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.textDefault {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.uiTextMediumContrast {
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./breakfast.module.css"
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
|
||||
interface BreakfastProps {
|
||||
adults: number
|
||||
breakfast: BreakfastPackage | false | undefined
|
||||
breakfastIncluded: boolean
|
||||
guests: string
|
||||
nights: number
|
||||
}
|
||||
|
||||
export default function Breakfast({
|
||||
adults,
|
||||
breakfast,
|
||||
breakfastIncluded,
|
||||
guests,
|
||||
nights,
|
||||
}: BreakfastProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const breakfastBuffet = intl.formatMessage({
|
||||
defaultMessage: "Breakfast buffet",
|
||||
})
|
||||
|
||||
if (breakfastIncluded || breakfast) {
|
||||
const price = breakfast
|
||||
? formatPrice(
|
||||
intl,
|
||||
breakfast.localPrice.price * adults * nights,
|
||||
breakfast.localPrice.currency
|
||||
)
|
||||
: intl.formatMessage({ defaultMessage: "Included" })
|
||||
return (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.textDefault}>{breakfastBuffet}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.uiTextMediumContrast}>{guests}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.textDefault}>{price}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (breakfast === false) {
|
||||
const noBreakfast = intl.formatMessage({ defaultMessage: "No breakfast" })
|
||||
return (
|
||||
<div className={styles.entry}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.textDefault}>{breakfastBuffet}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.textDefault}>{noBreakfast}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { getMemberPrice, getPublicPrice } from "../utils"
|
||||
import Breakfast from "./Breakfast"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Room as RoomType } from "@/types/stores/enter-details"
|
||||
|
||||
interface RoomProps {
|
||||
room: RoomType
|
||||
roomNumber: number
|
||||
roomCount: number
|
||||
isMember: boolean
|
||||
isSpecialRate: boolean
|
||||
nightsCount: number
|
||||
defaultCurrency: CurrencyEnum
|
||||
}
|
||||
|
||||
export default function Room({
|
||||
room,
|
||||
roomNumber,
|
||||
roomCount,
|
||||
isMember,
|
||||
isSpecialRate,
|
||||
nightsCount,
|
||||
defaultCurrency,
|
||||
}: RoomProps) {
|
||||
const intl = useIntl()
|
||||
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 publicPrice = getPublicPrice(room.roomRate)
|
||||
|
||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||
const isOrWillBecomeMember = !!(
|
||||
room.guest.join ||
|
||||
room.guest.membershipNo ||
|
||||
isFirstRoomMember
|
||||
)
|
||||
const showMemberPrice = !!(isOrWillBecomeMember && memberPrice)
|
||||
const showDiscounted = isSpecialRate || showMemberPrice
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
let rateDetails = room.rateDetails
|
||||
if (room.memberRateDetails) {
|
||||
if (isMember || room.guest.join) {
|
||||
rateDetails = room.memberRateDetails
|
||||
}
|
||||
}
|
||||
|
||||
const guests = guestsParts.join(", ")
|
||||
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
|
||||
<div>
|
||||
{roomCount > 1 ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.roomTitle}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{room.roomType}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.additionalInformation}>
|
||||
<p>{guests}</p>
|
||||
<p>{room.cancellationText}</p>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.prices}>
|
||||
<p
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{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
|
||||
)}
|
||||
</p>
|
||||
{showDiscounted && publicPrice ? (
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
publicPrice.amount,
|
||||
publicPrice.currency
|
||||
)}
|
||||
</s>
|
||||
) : null}
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{rateDetails?.length ? (
|
||||
<div className={styles.ctaWrapper}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
className={styles.termsButton}
|
||||
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.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{rateDetails.map((info) => (
|
||||
<Typography key={info} variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.termsText}>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</p>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<Typography key={feature.code} variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>{feature.description}</p>
|
||||
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
feature.localPrice.price,
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
))
|
||||
: null}
|
||||
|
||||
{room.bedType ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>{room.bedType.description}</p>
|
||||
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>{zeroPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{childBedCrib ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</p>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Based on availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{childBedExtraBed ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Breakfast
|
||||
adults={room.adults}
|
||||
breakfast={room.breakfast}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
guests={guests}
|
||||
nights={nightsCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x15);
|
||||
overflow-y: auto;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.roomTitle,
|
||||
.additionalInformation {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Space-x3);
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Space-x1);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
margin-top: var(--Space-x15);
|
||||
}
|
||||
Reference in New Issue
Block a user