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:
Simon.Emanuelsson
2025-06-13 12:01:16 +00:00
committed by Michael Zetterberg
parent e1ede52014
commit 85acd3453d
52 changed files with 2403 additions and 1380 deletions

View File

@@ -1,7 +1,10 @@
"use client"
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"
@@ -9,8 +12,6 @@ import { CancellationRuleEnum, ChildBedTypeEnum } from "@/constants/booking"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import { formatPrice } from "@/utils/numberFormatting"
import Breakfast from "./Breakfast"
@@ -21,12 +22,13 @@ import styles from "./room.module.css"
import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
export default function ReceiptRoom({
roomIndex,
room,
roomNumber,
roomCount,
}: BookingConfirmationReceiptRoomProps) {
const intl = useIntl()
const { room, currencyCode, isVatCurrency } = useBookingConfirmationStore(
const { currencyCode, isVatCurrency } = useBookingConfirmationStore(
(state) => ({
room: state.rooms[roomIndex],
currencyCode: state.currencyCode,
isVatCurrency: state.isVatCurrency,
})
@@ -64,173 +66,199 @@ export default function ReceiptRoom({
}
const guests = guestsParts.join(", ")
const showDiscounted = room.rateDefinition.isMemberRate
return (
<article className={styles.room}>
<header className={styles.roomHeader}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>{room.name}</p>
</Typography>
{room.rateDefinition.isMemberRate ? (
<div className={styles.memberPrice}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.red}>{room.formattedRoomCost}</p>
</Typography>
</div>
) : (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{room.formattedRoomCost}
</p>
</Typography>
)}
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextMediumContrast}>{guests}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextMediumContrast}>
{room.rateDefinition.cancellationText}
</p>
</Typography>
<Modal
trigger={
<Button intent="text" className={styles.termsLink}>
<Link
color="Text/Interactive/Secondary"
href=""
size="small"
textDecoration="underline"
variant="icon"
>
{intl.formatMessage({
defaultMessage: "Reservation policy",
})}
<MaterialIcon icon="info" color="CurrentColor" />
</Link>
</Button>
}
title={
(isVatCurrency
? room.rateDefinition.cancellationText
: room.rateDefinition.title) || ""
}
subtitle={
room.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
? intl.formatMessage({
defaultMessage: "Pay later",
})
: intl.formatMessage({
defaultMessage: "Pay now",
})
}
>
<div className={styles.terms}>
{room.rateDefinition.generalTerms?.map((info) => (
<Typography
key={info}
className={styles.termsText}
variant="Body/Paragraph/mdRegular"
>
<span>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</span>
</Typography>
))}
</div>
</Modal>
</header>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{feature.description}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{formatPrice(intl, feature.totalPrice, feature.currency)}
</p>
</Typography>
</div>
))
: null}
<div className={styles.entry}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>{room.bedDescription}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{formatPrice(intl, 0, currencyCode)}
</p>
</Typography>
</div>
{childBedCrib ? (
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
<>
<div className={styles.room}>
<div>
{roomCount > 1 ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib.quantity }
)}
</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.uiTextHighContrast}>
{intl.formatMessage({
defaultMessage: "Based on availability",
})}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{formatPrice(intl, 0, currencyCode)}
</p>
</Typography>
</div>
) : null}
{childBedExtraBed ? (
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
defaultMessage: "Room {roomIndex}",
},
{
count: childBedExtraBed.quantity,
roomIndex: roomNumber,
}
)}
</p>
</Typography>
) : null}
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdBold">
<p>{room.name}</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.additionalInformation}>
<p>{guestsParts.join(", ")}</p>
<p>{room.rateDefinition.cancellationText}</p>
</div>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.prices}>
<p
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{room.formattedRoomCost}
</p>
{/* TODO: add original price, we're currently not receiving this value from API */}
</div>
</Typography>
</div>
{room.rateDefinition.generalTerms ? (
<div className={styles.ctaWrapper}>
<Modal
trigger={
<Button
className={styles.termsButton}
variant="Text"
typography="Body/Supporting text (caption)/smBold"
wrapping={false}
>
{intl.formatMessage({
defaultMessage: "Reservation policy",
})}
<MaterialIcon
icon="chevron_right"
size={20}
color="CurrentColor"
/>
</Button>
}
title={
(isVatCurrency
? room.rateDefinition.cancellationText
: room.rateDefinition.title) || ""
}
subtitle={
room.rateDefinition.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM
? intl.formatMessage({
defaultMessage: "Pay later",
})
: intl.formatMessage({
defaultMessage: "Pay now",
})
}
>
<div className={styles.terms}>
{room.rateDefinition.generalTerms?.map((info) => (
<Typography
key={info}
className={styles.termsText}
variant="Body/Paragraph/mdRegular"
>
<span>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</span>
</Typography>
))}
</div>
</Modal>
</div>
) : null}
</div>
{room.roomFeatures
? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{feature.description}
</p>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{formatPrice(intl, feature.totalPrice, feature.currency)}
</p>
</Typography>
</div>
))
: null}
<div className={styles.entry}>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>{room.bedDescription}</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}>
{formatPrice(intl, 0, currencyCode)}
</p>
</Typography>
</div>
) : null}
<Breakfast
breakfast={room.breakfast}
breakfastIncluded={room.breakfastIncluded}
guests={guests}
/>
</article>
{childBedCrib ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib.quantity }
)}
</p>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.uiTextHighContrast}>
{intl.formatMessage({
defaultMessage: "Based on availability",
})}
</p>
</Typography>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, currencyCode)}
</span>
</div>
</div>
</Typography>
) : null}
{childBedExtraBed ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed.quantity,
}
)}
</p>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, currencyCode)}
</span>
</div>
</div>
</Typography>
) : null}
<Breakfast
breakfast={room.breakfast}
breakfastIncluded={room.breakfastIncluded}
guests={guests}
/>
</div>
<Divider color="Border/Divider/Subtle" />
</>
)
}

View File

@@ -1,54 +1,56 @@
.room {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
gap: var(--Space-x15);
overflow-y: auto;
color: var(--Text-Default);
}
.roomHeader {
display: grid;
grid-template-columns: 1fr auto;
.roomTitle,
.additionalInformation {
color: var(--Text-Secondary);
}
.roomHeader :nth-child(n + 3) {
grid-column: 1/-1;
.terms {
margin-top: var(--Space-x3);
margin-bottom: var(--Space-x3);
}
.memberPrice {
.termsText:nth-child(n) {
display: flex;
gap: var(--Spacing-x1);
align-items: center;
margin-bottom: var(--Space-x1);
}
.terms .termsIcon {
margin-right: var(--Space-x1);
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.termsLink {
justify-self: flex-start;
.prices {
justify-items: flex-end;
flex-shrink: 0;
display: grid;
align-content: start;
}
.terms {
padding-top: var(--Spacing-x3);
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.termsText:nth-child(n) {
display: flex;
align-items: center;
padding-bottom: var(--Spacing-x1);
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.terms .termsIcon {
padding-right: var(--Spacing-x1);
}
.red {
color: var(--Scandic-Brand-Scandic-Red);
}
.uiTextHighContrast {
color: var(--UI-Text-High-contrast);
}
.uiTextMediumContrast {
color: var(--UI-Text-Medium-contrast);
.ctaWrapper {
margin-top: var(--Space-x15);
}

View File

@@ -1,5 +1,6 @@
"use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
@@ -18,6 +19,7 @@ export default function TotalPrice() {
const intl = useIntl()
const { rooms, formattedTotalCost } = useBookingConfirmationStore(
(state) => ({
bookingCode: state.bookingCode,
rooms: state.rooms,
formattedTotalCost: state.formattedTotalCost,
})
@@ -25,35 +27,58 @@ export default function TotalPrice() {
const hasAllRoomsLoaded = rooms.every((room) => room)
const bookingCode = rooms.find((room) => room?.bookingCode)?.bookingCode
const isMemberRate = rooms.some((room) => room?.rateDefinition.isMemberRate)
const showDiscounted = bookingCode || isMemberRate
return (
<>
<Divider color="Border/Divider/Subtle" />
<div className={styles.price}>
<div className={styles.entry}>
<Typography variant="Body/Paragraph/mdBold">
<p>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</p>
</Typography>
{hasAllRoomsLoaded ? (
<Typography variant="Body/Paragraph/mdBold">
<p>{formattedTotalCost}</p>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
) : (
<SkeletonShimmer width={"25%"} />
)}
{/* TODO: Add approx price, we're currently not receiving this value from API */}
</div>
<div className={styles.prices}>
{hasAllRoomsLoaded ? (
<Typography variant="Body/Paragraph/mdBold">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{formattedTotalCost}
</span>
</Typography>
) : (
<SkeletonShimmer width={"25%"} />
)}
</div>
</div>
</div>
<div className={styles.ctaWrapper}>
{hasAllRoomsLoaded ? (
<PriceDetails />
) : (
<div className={styles.priceDetailsLoader}>
<SkeletonShimmer width={"100%"} />
</div>
<SkeletonShimmer width={"100%"} />
)}
</div>
{bookingCode && <BookingCodeChip bookingCode={bookingCode} alignCenter />}
</>
)

View File

@@ -1,12 +1,33 @@
.entry {
display: flex;
gap: var(--Space-x05);
justify-content: space-between;
margin-bottom: var(--Space-x15);
}
.price button.btn {
padding: 0;
.prices {
justify-items: flex-end;
flex-shrink: 0;
display: grid;
}
.priceDetailsLoader {
padding-top: var(--Spacing-x1);
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.approxPrice {
color: var(--Text-Secondary);
}
.ctaWrapper {
margin-top: var(--Space-x15);
}

View File

@@ -2,10 +2,14 @@
import { useIntl } from "react-intl"
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 { dt } from "@/lib/dt"
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Body from "@/components/TempDesignSystem/Text/Body"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import Room from "./Room"
import TotalPrice from "./TotalPrice"
@@ -13,31 +17,56 @@ import TotalPrice from "./TotalPrice"
import styles from "./receipt.module.css"
export default function Receipt() {
const lang = useLang()
const intl = useIntl()
const rooms = useBookingConfirmationStore((state) => state.rooms)
const { rooms, fromDate, toDate } = useBookingConfirmationStore((state) => ({
rooms: state.rooms,
fromDate: state.fromDate,
toDate: state.toDate,
}))
const totalNights = dt(toDate).diff(fromDate, "days")
const nights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights }
)
const filteredRooms = rooms.filter(
(room): room is NonNullable<typeof room> => !!room
)
return (
<section className={styles.receipt}>
<Subtitle type="two">
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</Subtitle>
<header>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</h3>
</Typography>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.dates}>
{dt(fromDate).locale(lang).format("ddd, D MMM")}
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
</p>
</Typography>
</header>
{rooms.map((room, idx) => (
<div key={room ? room.confirmationNumber : `loader-${idx}`}>
{rooms.length > 1 ? (
<Body color="uiTextHighContrast" textTransform={"bold"}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: idx + 1 }
)}
</Body>
) : null}
<Room roomIndex={idx} />
</div>
<Divider color="Border/Divider/Subtle" />
{filteredRooms.map((room, idx) => (
<Room
key={room ? room.confirmationNumber : `loader-${idx}`}
room={room}
roomNumber={idx + 1}
roomCount={rooms.length}
/>
))}
<TotalPrice />

View File

@@ -1,11 +1,22 @@
.receipt {
display: grid;
gap: var(--Space-x2);
}
.heading {
color: var(--Text-Default);
}
.dates {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
align-items: center;
gap: var(--Space-x1);
justify-content: flex-start;
color: var(--Text-Accent-Secondary);
}
@media screen and (min-width: 1367px) {
.receipt {
padding: var(--Spacing-x3);
padding: var(--Space-x3);
}
}

View File

@@ -1,8 +1,7 @@
.wrapper {
display: grid;
grid-template-rows: 0fr 7.5em;
transition: 0.5s ease-in-out;
grid-template-rows: 0fr auto;
transition: all 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
align-content: end;
@@ -10,24 +9,22 @@
.bottomSheet {
display: grid;
grid-template-columns: 1fr auto;
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
grid-template-columns: 1fr 1fr;
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
align-items: flex-start;
transition: 0.5s ease-in-out;
max-width: var(--max-width-page);
width: 100%;
margin: 0 auto;
transition: all 0.5s ease-in-out;
width: 100vw;
}
.priceDetailsButton {
display: block;
border: none;
background: none;
border-width: 0;
background-color: transparent;
text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer;
white-space: nowrap;
padding: 0;
display: grid;
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.wrapper[data-open="true"] {
@@ -51,35 +48,47 @@
opacity: 1;
}
.priceDetailsButton {
overflow: hidden;
}
.content {
max-height: 50dvh;
overflow-y: auto;
}
.summaryAccordion {
background-color: var(--Main-Grey-White);
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
border-style: solid;
border-width: 1px;
border-bottom: none;
z-index: 10;
}
.priceLabel {
color: var(--Text-Default);
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.seeDetails {
margin-top: var(--Space-x15);
display: flex;
gap: var(--Space-x1);
align-items: center;
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
}
@media screen and (min-width: 768px) {
.bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
padding: var(--Space-x2) 0 var(--Space-x7);
}
}

View File

@@ -1,33 +1,49 @@
"use client"
import { cx } from "class-variance-authority"
import { useSearchParams } from "next/navigation"
import { type PropsWithChildren, useEffect, useRef } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useEnterDetailsStore } from "@/stores/enter-details"
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./bottomSheet.module.css"
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
interface SummaryBottomSheetProps
extends PropsWithChildren<{
isMember: boolean
}> { }
export default function SummaryBottomSheet({
children,
isMember,
}: SummaryBottomSheetProps) {
const intl = useIntl()
const scrollY = useRef(0)
const searchParams = useSearchParams()
const errorCode = searchParams.get("errorCode")
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmitting } =
useEnterDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
isSubmitting: state.isSubmitting,
}))
const {
isSummaryOpen,
toggleSummaryOpen,
totalPrice,
isSubmitting,
rooms,
} = useEnterDetailsStore((state) => ({
isSummaryOpen: state.isSummaryOpen,
toggleSummaryOpen: state.actions.toggleSummaryOpen,
totalPrice: state.totalPrice,
isSubmitting: state.isSubmitting,
rooms: state.rooms,
}))
useEffect(() => {
if (isSummaryOpen) {
@@ -53,43 +69,77 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
}
}, [isSummaryOpen, errorCode])
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.room.roomRate)
)
const showDiscounted = containsBookingCodeRate || isMember
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>{children}</div>
<div className={styles.bottomSheet}>
<button
<ButtonRAC
data-open={isSummaryOpen}
onClick={toggleSummaryOpen}
onPress={toggleSummaryOpen}
className={styles.priceDetailsButton}
>
<Caption>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</Caption>
<Subtitle>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">
{intl.formatMessage({
defaultMessage: "See details",
})}
</Caption>
</button>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.priceLabel}>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</span>
</Typography>
<Typography variant="Title/Subtitle/lg">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted && totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</s>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.seeDetails}>
<span>
{intl.formatMessage({
defaultMessage: "See details",
})}
</span>
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={20}
/>
</span>
</Typography>
</ButtonRAC>
<Button
variant="Primary"
color="Primary"
size="Large"
type="submit"
typography="Body/Paragraph/mdBold"
isDisabled={isSubmitting}
isPending={isSubmitting}
typography="Body/Supporting text (caption)/smBold"
form={formId}
>
{intl.formatMessage({
defaultMessage: "Complete booking",

View File

@@ -51,7 +51,7 @@ export default function MobileSummary({ isMember }: SummaryProps) {
/>
)}
<SummaryBottomSheet>
<SummaryBottomSheet isMember={isMember}>
<div className={styles.wrapper}>
<SummaryUI
booking={booking}

View File

@@ -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" />
</>
)
}

View File

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

View File

@@ -1,32 +1,30 @@
"use client"
import { Fragment } from "react"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts"
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 { dt } from "@/lib/dt"
import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import Modal from "@/components/Modal"
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 Breakfast from "./Breakfast"
import { mapToPrice } from "./mapToPrice"
import Room from "./Room"
import { getMemberPrice } from "./utils"
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"
export default function SummaryUI({
@@ -57,18 +55,6 @@ export default function SummaryUI({
}
}
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 &&
@@ -85,10 +71,6 @@ export default function SummaryUI({
"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
@@ -96,6 +78,10 @@ export default function SummaryUI({
const isAllBreakfastIncluded = rooms.every(
(room) => room.room.roomRate.rateDefinition.breakfastIncluded
)
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.room.roomRate)
)
const showDiscounted = containsBookingCodeRate || isMember
return (
<section className={styles.summary}>
@@ -127,306 +113,109 @@ export default function SummaryUI({
/>
</header>
<Divider color="Border/Divider/Subtle" />
{rooms.map(({ room }, idx) => {
const roomNumber = idx + 1
const adults = room.adults
const childrenInRoom = room.childrenInRoom
{rooms.map(({ room }, idx) => (
<Room
key={idx}
defaultCurrency={defaultCurrency}
room={room}
roomNumber={idx + 1}
roomCount={rooms.length}
isMember={isMember}
isSpecialRate={isSpecialRate}
nightsCount={nights}
/>
))}
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 guests = guestsParts.join(", ")
let rateDetails = room.rateDetails
if (room.memberRateDetails) {
if (isMember || room.guest.join) {
rateDetails = room.memberRateDetails
}
}
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
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">{guests}</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">{zeroPrice}</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">{zeroPrice}</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">{zeroPrice}</Body>
</div>
) : null}
<Breakfast
adults={room.adults}
breakfast={room.breakfast}
breakfastIncluded={room.breakfastIncluded}
guests={guests}
nights={nights}
/>
</div>
<Divider color="Border/Divider/Subtle" />
</Fragment>
)
})}
<div className={styles.total}>
<div>
<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}
defaultCurrency={defaultCurrency}
/>
</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">
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</Caption>
)}
</p>
</Typography>
{totalPrice.requested ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.approxPrice}>
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
),
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.prices}>
<Typography variant="Body/Paragraph/mdBold">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
data-testid="total-price"
>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted && totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</s>
</Typography>
) : null}
</div>
</div>
<BookingCodeChip
isCampaign={isAllCampaignRate}
bookingCode={booking.bookingCode}
isBreakfastIncluded={isAllBreakfastIncluded}
alignCenter
/>
<Divider
className={styles.bottomDivider}
color="Border/Divider/Subtle"
/>
<div className={styles.ctaWrapper}>
<PriceDetailsModal
bookingCode={booking.bookingCode}
defaultCurrency={defaultCurrency}
fromDate={booking.fromDate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</div>
</div>
<BookingCodeChip
isCampaign={isAllCampaignRate}
bookingCode={booking.bookingCode}
isBreakfastIncluded={isAllBreakfastIncluded}
alignCenter
/>
<Divider className={styles.bottomDivider} color="Border/Divider/Subtle" />
{showSignupPromo && roomOneMemberPrice && !isMember ? (
<SignupPromoDesktop
memberPrice={roomOneMemberPrice}

View File

@@ -10,14 +10,21 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
const pkgsSum = sumPackages(room.roomFeatures)
const roomWithoutPrice = {
...room,
packages: room.roomFeatures,
rateDefinition: {
isMemberRate: false,
},
}
if ("corporateCheque" in room.roomRate) {
if (
room.roomRate.corporateCheque.localPrice.additionalPricePerStay ||
pkgsSum.price
) {
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
corporateCheque: {
...room.roomRate.corporateCheque.localPrice,
@@ -29,8 +36,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
}
}
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
corporateCheque: room.roomRate.corporateCheque.localPrice,
},
@@ -43,8 +49,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
pkgsSum.price
) {
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
redemption: {
...room.roomRate.redemption.localPrice,
@@ -56,8 +61,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
}
}
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
redemption: room.roomRate.redemption.localPrice,
},
@@ -66,8 +70,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
if ("voucher" in room.roomRate) {
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
voucher: room.roomRate.voucher,
},
@@ -79,22 +82,35 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
if ("member" in room.roomRate && room.roomRate.member) {
if (pkgsSum.price) {
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
rateDefinition: {
isMemberRate: true,
},
price: {
regular: {
...room.roomRate.member.localPrice,
pricePerNight: room.roomRate.member.localPrice.pricePerNight,
pricePerStay: room.roomRate.member.localPrice.pricePerStay,
regularPricePerStay:
(room.roomRate.public?.localPrice.pricePerStay ||
room.roomRate.member.localPrice.pricePerStay) +
pkgsSum.price,
},
},
}
}
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
rateDefinition: {
isMemberRate: true,
},
price: {
regular: room.roomRate.member.localPrice,
regular: {
...room.roomRate.member.localPrice,
regularPricePerStay:
room.roomRate.public?.localPrice.pricePerStay ||
room.roomRate.member.localPrice.pricePerStay,
},
},
}
}
@@ -103,20 +119,14 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
if ("public" in room.roomRate && room.roomRate.public) {
if (pkgsSum.price) {
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
regular: {
...room.roomRate.public.localPrice,
pricePerNight: room.roomRate.public.localPrice.pricePerNight,
pricePerStay: room.roomRate.public.localPrice.pricePerStay,
},
regular: room.roomRate.public.localPrice,
},
}
}
return {
...room,
packages: room.roomFeatures,
...roomWithoutPrice,
price: {
regular: room.roomRate.public.localPrice,
},

View File

@@ -53,9 +53,32 @@
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
.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 !important;
color: var(--Text-Secondary);
}
.approxPrice {
color: var(--Text-Secondary);
}
.ctaWrapper {
margin-top: var(--Space-x15);
}
.total {

View File

@@ -0,0 +1,25 @@
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
export 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
}
export function getPublicPrice(roomRate: RoomRate) {
if ("public" in roomRate && roomRate.public) {
return {
amount: roomRate.public.localPrice.pricePerStay,
currency: roomRate.public.localPrice.currency,
pricePerNight: roomRate.public.localPrice.pricePerNight,
}
}
return null
}

View File

@@ -23,6 +23,9 @@ export function mapToPrice(room: Room) {
currency: room.currencyCode,
pricePerNight: room.roomPrice.perNight.local.price,
pricePerStay: room.roomPrice.perStay.local.price,
regularPricePerStay:
room.roomPrice.perStay.local.regularPrice ||
room.roomPrice.perStay.local.price,
},
}
case PriceTypeEnum.points:

View File

@@ -40,12 +40,12 @@ export default function MultiRoom(props: MultiRoomProps) {
<div className={styles.container}>
<div className={styles.roomsContainer}>
{rooms.map((booking, index) => (
<div
<Room
key={booking.confirmationNumber}
className={styles.roomWrapper}
>
<Room {...props} booking={booking} roomNr={index + 1} />
</div>
{...props}
booking={booking}
roomNr={index + 1}
/>
))}
</div>
</div>

View File

@@ -22,15 +22,6 @@
width: 100%;
}
.roomWrapper {
min-width: 0;
width: 100%;
}
.roomWrapper > * {
width: 100%;
}
.totalContainer {
display: flex;
flex-direction: column;
@@ -49,7 +40,7 @@
grid-template-columns: repeat(2, 1fr);
}
.roomsContainer:has(> *:nth-child(3):last-child) {
.roomsContainer:has(> *:nth-of-type(3):last-child) {
grid-template-columns: repeat(3, 1fr);
}

View File

@@ -110,6 +110,9 @@
@media (min-width: 768px) {
.multiRoom {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
padding: 0;
}
}

View File

@@ -1,3 +1,5 @@
import { cx } from "class-variance-authority"
import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./row.module.css"
@@ -5,9 +7,16 @@ import styles from "./row.module.css"
interface RowProps {
label: string
value: string
regularValue?: string
isDiscounted?: boolean
}
export default function BoldRow({ label, value }: RowProps) {
export default function BoldRow({
label,
value,
regularValue,
isDiscounted = false,
}: RowProps) {
return (
<tr className={styles.row}>
<td>
@@ -16,8 +25,15 @@ export default function BoldRow({ label, value }: RowProps) {
</Typography>
</td>
<td className={styles.price}>
{isDiscounted && regularValue ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<s className={styles.strikeThroughRate}>{regularValue}</s>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{value}</span>
<span className={cx({ [styles.discounted]: isDiscounted })}>
{value}
</span>
</Typography>
</td>
</tr>

View File

@@ -1,51 +0,0 @@
"use client"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./row.module.css"
import type { CurrencyEnum } from "@/types/enums/currency"
import type { Package } from "@/types/requests/packages"
interface DiscountedRegularPriceRowProps {
currency: CurrencyEnum
packages: Package[]
regularPrice?: number
}
export default function DiscountedRegularPriceRow({
currency,
packages,
regularPrice,
}: DiscountedRegularPriceRowProps) {
const intl = useIntl()
if (!regularPrice) {
return null
}
const totalPackagesPrice = packages.reduce(
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const price = formatPrice(intl, regularPrice + totalPackagesPrice, currency)
return (
<tr className={styles.row}>
<td></td>
<td className={styles.price}>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span>
<s>{price}</s>
</span>
</Typography>
<Caption color="uiTextMediumContrast" striked></Caption>
</td>
</tr>
)
}

View File

@@ -1,25 +1,66 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./row.module.css"
import type { Price } from "@/types/components/hotelReservation/price"
interface RowProps {
allPricesIsDiscounted: boolean
label: string
value: string
price: Price
}
export default function LargeRow({ label, value }: RowProps) {
export default function LargeRow({
allPricesIsDiscounted,
label,
price,
}: RowProps) {
const intl = useIntl()
const totalPrice = formatPrice(
intl,
price.local.price,
price.local.currency,
price.local.additionalPrice,
price.local.additionalPriceCurrency
)
const regularPrice = price.local.regularPrice
? formatPrice(
intl,
price.local.regularPrice,
price.local.currency,
price.local.additionalPrice,
price.local.additionalPriceCurrency
)
: null
const isDiscounted =
allPricesIsDiscounted ||
(price.local.regularPrice !== undefined &&
price.local.regularPrice > price.local.price)
return (
<tr className={styles.row}>
<td>
<Typography variant="Body/Paragraph/mdBold">
<Typography variant="Body/Paragraph/mdBold">
<tr className={styles.row}>
<td>
<span>{label}</span>
</Typography>
</td>
<td className={styles.price}>
<Typography variant="Body/Paragraph/mdBold">
<span>{value}</span>
</Typography>
</td>
</tr>
</td>
<td className={styles.price}>
{isDiscounted && regularPrice ? (
<>
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>{regularPrice}</s>
</Typography>
</>
) : null}
<span className={cx({ [styles.discounted]: isDiscounted })}>
{totalPrice}
</span>
</td>
</tr>
</Typography>
)
}

View File

@@ -16,15 +16,18 @@ export interface RegularPriceType {
currency: CurrencyEnum
pricePerNight: number
pricePerStay: number
regularPricePerStay: number
}
}
interface RegularPriceProps extends SharedPriceRowProps {
isMemberRate: boolean
price: RegularPriceType["regular"]
}
export default function RegularPrice({
bedType,
isMemberRate,
nights,
packages,
price,
@@ -47,11 +50,21 @@ export default function RegularPrice({
const roomCharge = formatPrice(intl, price.pricePerStay, price.currency)
const regularPriceIsHigherThanPrice =
price.regularPricePerStay > price.pricePerStay
let regularCharge = undefined
if (regularPriceIsHigherThanPrice) {
regularCharge = formatPrice(intl, price.regularPricePerStay, price.currency)
}
const isDiscounted = isMemberRate || regularPriceIsHigherThanPrice
return (
<>
<BoldRow
label={intl.formatMessage({ defaultMessage: "Room charge" })}
value={roomCharge}
regularValue={regularCharge}
isDiscounted={isDiscounted}
/>
{nights > 1 ? (
<RegularRow label={averagePriceTitle} value={avgeragePricePerNight} />

View File

@@ -1,8 +1,21 @@
.row {
display: flex;
justify-content: space-between;
color: var(--Text-Default);
}
.price {
text-align: end;
display: flex;
align-items: center;
gap: var(--Space-x1);
}
.discounted {
color: var(--Text-Accent-Primary);
}
.price .strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}

View File

@@ -7,10 +7,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import BookingCodeRow from "./Row/BookingCode"
import DiscountedRegularPriceRow from "./Row/DiscountedRegularPrice"
import HeaderRow from "./Row/Header"
import LargeRow from "./Row/Large"
import CorporateChequePrice, {
@@ -32,7 +30,8 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
import type { Price } from "@/types/components/hotelReservation/price"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { CurrencyEnum } from "@/types/enums/currency"
import type { Package, Packages } from "@/types/requests/packages"
import type { Packages } from "@/types/requests/packages"
import type { RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
type RoomPrice =
| CorporateChequePriceType
@@ -49,6 +48,7 @@ export interface Room {
childrenInRoom: Child[] | undefined
packages: Packages | null
price: RoomPrice
rateDefinition: Pick<RateDefinition, "isMemberRate">
roomType: string
}
@@ -86,11 +86,22 @@ export default function PriceDetailsTable({
const departue = dt(toDate).locale(lang).format("ddd, D MMM")
const duration = ` ${arrival} - ${departue} (${nightsMsg})`
const allRoomsPackages: Package[] = rooms
.flatMap((r) => r.packages)
.filter((r): r is Package => !!r)
const isAllBreakfastIncluded = rooms.every((room) => room.breakfastIncluded)
const allPricesIsDiscounted = rooms.every((room) => {
if (!("regular" in room.price)) {
return false
}
if (room.rateDefinition.isMemberRate) {
return true
}
if (!room.price.regular) {
return false
}
return room.price.regular.pricePerStay > room.price.regular.pricePerStay
})
return (
<table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => {
@@ -104,10 +115,12 @@ export default function PriceDetailsTable({
}
}
let isMemberRate = false
let price: RegularPriceType["regular"] | undefined
if ("regular" in room.price && room.price.regular) {
price = room.price.regular
currency = room.price.regular.currency
isMemberRate = room.rateDefinition.isMemberRate
}
let redemptionPrice: RedemptionPriceType["redemption"] | undefined
@@ -153,6 +166,7 @@ export default function PriceDetailsTable({
<RegularPrice
bedType={room.bedType}
packages={room.packages}
isMemberRate={isMemberRate}
nights={nights}
price={price}
/>
@@ -197,20 +211,9 @@ export default function PriceDetailsTable({
<VatRow totalPrice={totalPrice} vat={vat} />
<LargeRow
allPricesIsDiscounted={allPricesIsDiscounted}
label={intl.formatMessage({ defaultMessage: "Price including VAT" })}
value={formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
/>
<DiscountedRegularPriceRow
currency={totalPrice.local.currency}
packages={allRoomsPackages}
regularPrice={totalPrice.local.regularPrice}
price={totalPrice}
/>
<BookingCodeRow

View File

@@ -0,0 +1,193 @@
"use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt"
import { useRatesStore } from "@/stores/select-rate"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { mapToPrice } from "../mapToPrice"
import Room from "../Room"
import { getMemberPrice, isBookingCodeRate } from "../utils"
import styles from "./summaryContent.module.css"
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
export default function SummaryContent({
booking,
rooms,
totalPrice,
isMember,
vat,
toggleSummaryOpen,
}: SelectRateSummaryProps) {
const { rateSummary, defaultCurrency } = useRatesStore((state) => ({
rateSummary: state.rateSummary,
defaultCurrency: state.defaultCurrency,
}))
const intl = useIntl()
const lang = useLang()
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
const nights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: diff }
)
const filteredRooms = rooms.filter(
(room): room is NonNullable<typeof room> => !!room
)
const memberPrice =
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.roomRate)
)
const showDiscounted = containsBookingCodeRate || isMember
const priceDetailsRooms = mapToPrice(rateSummary, booking.rooms, isMember)
return (
<section className={styles.summary}>
<header>
<div className={styles.headingWrapper}>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</h3>
</Typography>
<IconButton
className={styles.closeButton}
onPress={toggleSummaryOpen}
theme="Black"
style="Muted"
>
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</IconButton>
</div>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.dates}>
{dt(booking.fromDate).locale(lang).format("ddd, D MMM")}
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
{dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights})
</p>
</Typography>
</header>
<Divider color="Border/Divider/Subtle" />
{filteredRooms.map((room, idx) => (
<Room
key={idx}
room={room}
roomNumber={idx + 1}
roomCount={rooms.length}
isMember={isMember}
/>
))}
<div>
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
{totalPrice.requested ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.approxPrice}>
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
totalPrice.requested.price,
totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
),
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.prices}>
<Typography variant="Body/Paragraph/mdBold">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
data-testid="total-price"
>
{formatPrice(
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted && totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPrice.local.regularPrice,
totalPrice.local.currency
)}
</s>
</Typography>
) : null}
</div>
</div>
<PriceDetailsModal
bookingCode={booking.bookingCode}
defaultCurrency={defaultCurrency}
fromDate={booking.fromDate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</div>
{!isMember && memberPrice ? (
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
) : null}
</section>
)
}

View File

@@ -0,0 +1,59 @@
.summary {
border-radius: var(--Corner-radius-lg);
display: grid;
gap: var(--Space-x2);
padding: var(--Space-x3);
}
.headingWrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.heading {
color: var(--Text-Default);
}
.closeButton {
margin-top: -10px; /* Compensate for padding of the button */
margin-right: -10px; /* Compensate for padding of the button */
}
.dates {
display: flex;
align-items: center;
gap: var(--Space-x1);
justify-content: flex-start;
color: var(--Text-Accent-Secondary);
}
.entry {
display: flex;
gap: var(--Space-x05);
justify-content: space-between;
margin-bottom: var(--Space-x15);
}
.prices {
justify-items: flex-end;
flex-shrink: 0;
display: grid;
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.approxPrice {
color: var(--Text-Secondary);
}

View File

@@ -0,0 +1,277 @@
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, isBookingCodeRate } from "../utils"
import styles from "./room.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type {
RoomPrice,
RoomRate,
} from "@/types/components/hotelReservation/enterDetails/details"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Packages } from "@/types/requests/packages"
interface RoomProps {
room: {
adults: number
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: RoomPrice
roomRate: RoomRate
rateDetails: string[] | undefined
cancellationText: string
packages?: Packages
}
roomNumber: number
roomCount: number
isMember: boolean
}
export default function Room({
room,
roomNumber,
roomCount,
isMember,
}: 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 showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
const showDiscounted = isBookingCodeRate(room.roomRate) || 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)
}
const roomPackages = room.packages
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>{guestsParts.join(", ")}</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 && room.roomPrice.perStay.local.price ? (
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</s>
) : null}
</div>
</Typography>
</div>
{room.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}>
{room.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>
{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}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed,
}
)}
</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}
{roomPackages?.map((pkg) => (
<Typography key={pkg.code} variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<p>{pkg.description}</p>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(
intl,
pkg.localPrice.price,
pkg.localPrice.currency
)}
</span>
</div>
</div>
</Typography>
))}
</div>
<Divider color="Border/Divider/Subtle" />
</>
)
}

View File

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

View File

@@ -19,8 +19,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import { isBookingCodeRate } from "./isBookingCodeRate"
import { mapToPrice } from "./mapToPrice"
import { isBookingCodeRate } from "./utils"
import styles from "./summary.module.css"

View File

@@ -1,18 +1,20 @@
"use client"
import { cx } from "class-variance-authority"
import { useEffect, useRef, useState } from "react"
import { Button as ButtonRAC } from "react-aria-components"
import { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRatesStore } from "@/stores/select-rate"
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
import Button from "@/components/TempDesignSystem/Button"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting"
import { isBookingCodeRate } from "./isBookingCodeRate"
import SummaryContent from "./Content"
import { mapRate } from "./mapRate"
import Summary from "./Summary"
import { isBookingCodeRate } from "./utils"
import styles from "./mobileSummary.module.css"
@@ -23,7 +25,6 @@ export default function MobileSummary({
isAllRoomsSelected,
isUserLoggedIn,
totalPriceToShow,
showMemberDiscountBanner,
}: MobileSummaryProps) {
const intl = useIntl()
const scrollY = useRef(0)
@@ -62,6 +63,7 @@ export default function MobileSummary({
return () => {
document.body.style.position = ""
document.body.style.top = ""
document.body.style.width = ""
}
}, [isSummaryOpen])
@@ -82,50 +84,37 @@ export default function MobileSummary({
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
return (
<>
{isSummaryOpen && (
<div
className={styles.overlay}
role="presentation"
aria-hidden="true"
onClick={toggleSummaryOpen}
/>
)}
{showMemberDiscountBanner ? (
<div className={styles.signupPromoWrapper}>
<SignupPromoMobile />
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<SummaryContent
booking={booking}
rooms={rooms}
isMember={isUserLoggedIn}
totalPrice={totalPriceToShow}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
) : null}
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<Summary
booking={booking}
rooms={rooms}
isMember={isUserLoggedIn}
totalPrice={totalPriceToShow}
vat={vat}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
</div>
<div className={styles.bottomSheet}>
<button
data-open={isSummaryOpen}
onClick={(e) => {
e.preventDefault()
toggleSummaryOpen()
}}
className={styles.priceDetailsButton}
>
<Caption>
</div>
<div className={styles.bottomSheet}>
<ButtonRAC
data-open={isSummaryOpen}
onPress={toggleSummaryOpen}
className={styles.priceDetailsButton}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.priceLabel}>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</Caption>
<Subtitle
color={showDiscounted ? "red" : "uiTextHighContrast"}
className={styles.wrappedText}
</span>
</Typography>
<Typography variant="Title/Subtitle/lg">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{formatPrice(
intl,
@@ -134,27 +123,48 @@ export default function MobileSummary({
totalPriceToShow.local.additionalPrice,
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
<Caption color="baseTextHighContrast" type="underline">
{intl.formatMessage({
defaultMessage: "See details",
})}
</Caption>
</button>
<Button
intent="primary"
theme="base"
size="large"
type="submit"
fullWidth
disabled={!isAllRoomsSelected}
>
{intl.formatMessage({
defaultMessage: "Continue",
})}
</Button>
</div>
</span>
</Typography>
{showDiscounted && totalPriceToShow.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
totalPriceToShow.local.regularPrice,
totalPriceToShow.local.currency
)}
</s>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.seeDetails}>
<span>
{intl.formatMessage({
defaultMessage: "See details",
})}
</span>
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={20}
/>
</span>
</Typography>
</ButtonRAC>
<Button
variant="Primary"
color="Primary"
size="Large"
type="submit"
typography="Body/Paragraph/mdBold"
isDisabled={!isAllRoomsSelected}
>
{intl.formatMessage({
defaultMessage: "Continue",
})}
</Button>
</div>
</>
</div>
)
}

View File

@@ -34,7 +34,12 @@ export function mapToPrice(
const onlyMemberRate = !room.product.public && memberRate
if ((isUserLoggedIn && isMainRoom && memberRate) || onlyMemberRate) {
price = {
regular: memberRate.localPrice,
regular: {
...memberRate.localPrice,
regularPricePerStay:
room.product.public?.localPrice.pricePerStay ||
memberRate.localPrice.pricePerStay,
},
}
} else if (room.product.public) {
price = {

View File

@@ -1,13 +1,30 @@
.wrapper {
position: relative;
display: grid;
grid-template-rows: 0fr 7.5em;
transition: 0.5s ease-in-out;
grid-template-rows: 0fr auto;
transition: all 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal);
align-content: end;
z-index: var(--default-modal-z-index);
&[data-open="true"] {
grid-template-rows: 1fr auto;
.bottomSheet {
grid-template-columns: 0fr auto;
}
.priceDetailsButton {
opacity: 0;
height: 0;
}
}
&[data-open="false"] .priceDetailsButton {
opacity: 1;
height: auto;
}
}
.signupPromoWrapper {
@@ -28,46 +45,21 @@
.bottomSheet {
display: grid;
grid-template-columns: 1fr 1fr;
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
align-items: flex-start;
transition: 0.5s ease-in-out;
max-width: var(--max-width-page);
width: 100%;
margin: 0 auto;
transition: all 0.5s ease-in-out;
width: 100vw;
}
.priceDetailsButton {
display: block;
border: none;
background: none;
border-width: 0;
background-color: transparent;
text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer;
white-space: nowrap;
padding: 0;
}
.wrapper[data-open="true"] {
grid-template-rows: 1fr 7.5em;
}
.wrapper[data-open="true"] .bottomSheet {
grid-template-columns: 0fr auto;
}
.wrapper[data-open="true"] .priceDetailsButton {
animation: fadeOut 0.3s ease-out;
opacity: 0;
padding: 0;
}
.wrapper[data-open="false"] .priceDetailsButton {
animation: fadeIn 0.8s ease-in;
opacity: 1;
}
.priceDetailsButton {
display: grid;
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.content {
@@ -84,30 +76,33 @@
z-index: 10;
}
.wrappedText {
white-space: normal;
.priceLabel {
color: var(--Text-Default);
}
.price {
color: var(--Text-Default);
&.discounted {
color: var(--Text-Accent-Primary);
}
}
.strikeThroughRate {
text-decoration: line-through;
color: var(--Text-Secondary);
}
.seeDetails {
margin-top: var(--Space-x15);
display: flex;
gap: var(--Space-x1);
align-items: center;
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
}
@media screen and (min-width: 768px) {
.bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
padding: var(--Space-x2) 0 var(--Space-x7);
}
}

View File

@@ -1,6 +1,19 @@
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
export 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
}
export function isBookingCodeRate(product: Product) {
if (
"corporateCheque" in product ||

View File

@@ -188,6 +188,8 @@ export default function RateSummary() {
mainRoomCurrency = rateProduct.public.localPrice.currency
}
const showStrikedThroughPrice = bookingCode || isUserLoggedIn
// attribute data-footer-spacing used to add spacing
// beneath footer to be able to show entire footer upon
// scrolling down to the bottom of the page
@@ -338,7 +340,8 @@ export default function RateSummary() {
totalPriceToShow.local.additionalPriceCurrency
)}
</Subtitle>
{bookingCode && totalPriceToShow.local.regularPrice && (
{showStrikedThroughPrice &&
totalPriceToShow.local.regularPrice ? (
<Caption
textAlign="right"
color="uiTextMediumContrast"
@@ -350,7 +353,7 @@ export default function RateSummary() {
totalPriceToShow.local.currency
)}
</Caption>
)}
) : null}
{totalPriceToShow.requested ? (
<Body color="uiTextMediumContrast">
{intl.formatMessage(
@@ -410,7 +413,6 @@ export default function RateSummary() {
isAllRoomsSelected={isAllRoomsSelected}
isUserLoggedIn={isUserLoggedIn}
totalPriceToShow={totalPriceToShow}
showMemberDiscountBanner={showMemberDiscountBanner}
/>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { sumPackages } from "@/components/HotelReservation/utils"
import type { Price } from "@/types/components/hotelReservation/price"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { Packages } from "@/types/requests/packages"
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
@@ -19,8 +20,10 @@ export function calculateTotalPrice(
const roomNr = idx + 1
const isMainRoom = roomNr === 1
let rate
let publicRate
if (isUserLoggedIn && isMainRoom && room.product.member) {
rate = room.product.member
publicRate = room.product.public
} else if (room.product.public) {
rate = room.product.public
}
@@ -44,10 +47,16 @@ export function calculateTotalPrice(
total.local.price =
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.localPrice.regularPricePerStay) {
if (rate.rateType === RateTypeEnum.Regular && publicRate) {
total.local.regularPrice =
(total.local.regularPrice || 0) +
rate.localPrice.regularPricePerStay +
publicRate.localPrice.pricePerStay +
packagesPrice.local
} else {
total.local.regularPrice =
(total.local.regularPrice || 0) +
(rate.localPrice.regularPricePerStay ||
rate.localPrice.pricePerStay) +
packagesPrice.local
}

View File

@@ -87,7 +87,9 @@ export default function SelectedRoomPanel() {
(total, pkg) => total + pkg.localPrice.totalPrice,
0
)
const selectedPackagesPricePerNight = selectedPackagesPrice / nights
const selectedPackagesPricePerNight = Math.ceil(
selectedPackagesPrice / nights
)
const night = intl.formatMessage({
defaultMessage: "night",

View File

@@ -5,13 +5,12 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
import { useRoomContext } from "@/contexts/SelectRate/Room"
import BookingCodeFilter from "./BookingCodeFilter"
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
import RoomPackageFilter from "./RoomPackageFilter"
import styles from "./roomsHeader.module.css"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
export default function RoomsHeader() {
const { isFetchingPackages, rooms, totalRooms } = useRoomContext()

View File

@@ -78,9 +78,9 @@ export default function Regular({
const isMainRoomLoggedInWithoutMember =
isMainRoomAndLoggedIn && !product.member
const noRateAvailable = !product.member && !product.public
const hideStandardPrice = isMainRoomAndLoggedIn && !!member
const isMemberRateActive = isMainRoomAndLoggedIn && !!member
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
const rateCode = hideStandardPrice ? member.rateCode : standard?.rateCode
const rateCode = isMemberRateActive ? member.rateCode : standard?.rateCode
if (
noRateAvailable ||
isMainRoomLoggedInWithoutMember ||
@@ -133,10 +133,13 @@ export default function Regular({
let approximateStandardRatePrice = null
if (standardPricePerNight) {
const standardPriceUnit = isMemberRateActive
? standard!.localPrice.currency
: `${standard!.localPrice.currency}/${night}`
rates.rate = {
label: standardPriceMsg,
price: standardPricePerNight.totalPrice,
unit: `${standard!.localPrice.currency}/${night}`,
unit: standardPriceUnit,
}
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
@@ -194,7 +197,7 @@ export default function Regular({
key={product.rate}
approximateRate={approximateRate}
handleChange={() => handleSelectRate(product)}
hidePublicRate={hideStandardPrice}
isMemberRateActive={isMemberRateActive}
isSelected={isSelected}
name={`rateCode-${roomNr}-${rateCode}`}
paymentTerm={rateTitles[product.rate].paymentTerm}

View File

@@ -20,7 +20,7 @@
}
.receipt .hider {
background-color: var(--Main-Grey-White);
background-color: transparent;
height: 150px;
margin-top: -78px;
top: -40px;

View File

@@ -5,9 +5,9 @@ import { useEffect, useRef, useState } from "react"
import { dt } from "@/lib/dt"
import { createDetailsStore } from "@/stores/enter-details"
import {
calcTotalPrice,
checkIsSameBooking as checkIsSameBooking,
clearSessionStorage,
getTotalPrice,
readFromSessionStorage,
writeToSessionStorage,
} from "@/stores/enter-details/helpers"
@@ -18,7 +18,6 @@ import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { InitialState, RoomState } from "@/types/stores/enter-details"
@@ -174,25 +173,8 @@ export default function EnterDetailsProvider({
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
// We only extract the first room for its currency,
// the value is the same for the rest of the rooms
const product = filteredOutMissingRooms[0].room.roomRate
let currency = CurrencyEnum.Unknown
if ("corporateCheque" in product) {
currency = CurrencyEnum.CC
} else if ("redemption" in product) {
currency = CurrencyEnum.POINTS
} else if ("voucher" in product) {
currency = CurrencyEnum.Voucher
} else if ("public" in product && product.public) {
currency = product.public.localPrice.currency
} else if ("member" in product && product.member) {
currency = product.member.localPrice.currency
}
const totalPrice = calcTotalPrice(
filteredOutMissingRooms,
currency,
const totalPrice = getTotalPrice(
filteredOutMissingRooms.map((r) => r.room),
!!user,
nights
)

View File

@@ -1344,6 +1344,10 @@ export function selectRateRedirectURL(
}
searchParams.set(`room[${idx}].ratecode`, room.rateCode)
searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode)
} else {
if (!searchParams.has("modifyRateIndex")) {
searchParams.set("modifyRateIndex", idx.toString())
}
}
if (room.bookingCode) {
searchParams.set(`room[${idx}].bookingCode`, room.bookingCode)

View File

@@ -8,12 +8,20 @@ import {
import { detailsStorageName } from "."
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import type { Package } from "@/types/requests/packages"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { Packages } from "@/types/requests/packages"
import type { PersistedState, RoomState } from "@/types/stores/enter-details"
import type {
CorporateChequeProduct,
PriceProduct,
RedemptionProduct,
VoucherProduct,
} from "@/types/trpc/routers/hotel/roomAvailability"
import type { SafeUser } from "@/types/user"
export function extractGuestFromUser(user: NonNullable<SafeUser>) {
@@ -75,6 +83,13 @@ export function add(...nums: (number | string | undefined)[]) {
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && "member" in roomRate && roomRate.member) {
let publicRate
if (
"public" in roomRate &&
roomRate.public?.rateType === RateTypeEnum.Regular
) {
publicRate = roomRate.public
}
return {
perNight: {
requested: roomRate.member.requestedPrice
@@ -86,6 +101,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
local: {
currency: roomRate.member.localPrice.currency,
price: roomRate.member.localPrice.pricePerNight,
regularPrice:
publicRate?.localPrice.pricePerStay ||
roomRate.member.localPrice.regularPricePerNight,
},
},
perStay: {
@@ -98,6 +116,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
local: {
currency: roomRate.member.localPrice.currency,
price: roomRate.member.localPrice.pricePerStay,
regularPrice:
publicRate?.localPrice.pricePerStay ||
roomRate.member.localPrice.regularPricePerStay,
},
},
}
@@ -231,329 +252,6 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
)
}
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
const totalPrice = roomRates.reduce<Price>(
(total, roomRate, idx) => {
const isMainRoom = idx === 0
let rate
if (isMainRoom && isMember && "member" in roomRate && roomRate.member) {
rate = roomRate.member
} else if ("public" in roomRate && roomRate.public) {
rate = roomRate.public
}
// TODO: Handle other products?
if (!rate) {
return total
}
total.local.currency = rate.localPrice.currency
total.local.price = add(total.local.price, rate.localPrice.pricePerStay)
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice = add(
total.local.regularPrice,
rate.localPrice.regularPricePerStay
)
}
if (rate.requestedPrice) {
if (total.requested) {
total.requested.price = add(
total.requested.price,
rate.requestedPrice.pricePerStay
)
} else {
total.requested = {
currency: rate.requestedPrice.currency,
price: rate.requestedPrice.pricePerStay,
}
}
}
return total
},
{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
},
requested: undefined,
}
)
if (totalPrice.local.regularPrice) {
const totalPriceWithRegularPrice = roomRates.reduce(
(total, roomRate, idx) => {
const isMainRoom = idx === 0
let rate
if (isMainRoom && isMember && "member" in roomRate && roomRate.member) {
rate = roomRate.member
} else if ("public" in roomRate && roomRate.public) {
rate = roomRate.public
}
if (!rate) {
return total
}
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice =
total.local.regularPrice + rate.localPrice.regularPricePerStay
} else {
total.local.regularPrice =
total.local.regularPrice + rate.localPrice.pricePerStay
}
return total
},
{
...totalPrice,
local: {
...totalPrice.local,
regularPrice: 0,
},
}
)
if (
totalPriceWithRegularPrice.local.price ===
totalPriceWithRegularPrice.local.regularPrice
) {
totalPriceWithRegularPrice.local.regularPrice = 0
}
return totalPriceWithRegularPrice
}
return totalPrice
}
export function calculateVoucherPrice(
roomRates: RoomRate[],
packages: Package[]
) {
return roomRates.reduce<Price>(
(total, room) => {
if (!("voucher" in room)) {
return total
}
const pkgsSum = sumPackages(packages)
return {
local: {
additionalPrice: pkgsSum.price,
additionalPriceCurrency: pkgsSum.currency,
currency: total.local.currency,
price: total.local.price + room.voucher.numberOfVouchers,
},
requested: undefined,
}
},
{
local: {
currency: CurrencyEnum.Voucher,
price: 0,
},
requested: undefined,
}
)
}
export function calculateCorporateChequePrice(roomRates: RoomRate[]) {
return roomRates.reduce<Price>(
(total, room) => {
if (!("corporateCheque" in room)) {
return total
}
const rate = room.corporateCheque
total.local.price = add(
total.local.price,
rate.localPrice.numberOfCheques
)
if (rate.localPrice.additionalPricePerStay) {
total.local.additionalPrice = add(
total.local.additionalPrice,
rate.localPrice.additionalPricePerStay
)
}
if (rate.localPrice.currency) {
total.local.additionalPriceCurrency = rate.localPrice.currency
}
if (rate.requestedPrice) {
if (total.requested) {
total.requested.price = add(
total.requested.price,
rate.requestedPrice.numberOfCheques
)
} else {
total.requested = {
currency: CurrencyEnum.CC,
price: rate.requestedPrice.numberOfCheques,
}
}
if (rate.requestedPrice.additionalPricePerStay) {
total.requested.additionalPrice = add(
total.requested.additionalPrice,
rate.requestedPrice.additionalPricePerStay
)
}
if (rate.requestedPrice.currency) {
total.requested.additionalPriceCurrency = rate.requestedPrice.currency
}
}
return total
},
{
local: {
currency: CurrencyEnum.CC,
price: 0,
},
requested: undefined,
}
)
}
export function calcTotalPrice(
rooms: RoomState[],
currency: Price["local"]["currency"],
isMember: boolean,
nights: number
) {
return rooms.reduce<Price>(
(acc, { room }, index) => {
const isFirstRoomAndMember = index === 0 && isMember
const join = Boolean(room.guest.join || room.guest.membershipNo)
const roomPrice = getRoomPrice(
room.roomRate,
isFirstRoomAndMember || join
)
if (!roomPrice) {
return acc
}
const isSpecialRate =
"corporateCheque" in room.roomRate ||
"redemption" in room.roomRate ||
"voucher" in room.roomRate
const breakfastRequestedPrice = room.breakfast
? (room.breakfast.requestedPrice?.price ?? 0)
: 0
const breakfastLocalPrice = room.breakfast
? (room.breakfast.localPrice?.price ?? 0)
: 0
const pkgsSum = sumPackages(room.roomFeatures)
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
const breakfastRequestedTotalPrice =
breakfastRequestedPrice * room.adults * nights
if (roomPrice.perStay.requested) {
if (!acc.requested) {
acc.requested = {
currency: roomPrice.perStay.requested.currency,
price: 0,
}
}
if (isSpecialRate) {
acc.requested.price = add(
acc.requested.price,
roomPrice.perStay.requested.price
)
acc.requested.additionalPrice = add(
breakfastRequestedTotalPrice,
pkgsSumRequested.price
)
if (!acc.requested.additionalPriceCurrency) {
if (roomPrice.perStay.requested.additionalPriceCurrency) {
acc.requested.additionalPriceCurrency =
roomPrice.perStay.requested.additionalPriceCurrency
} else if (room.breakfast) {
acc.requested.additionalPriceCurrency =
room.breakfast.localPrice.currency
} else if (pkgsSumRequested.currency) {
acc.requested.additionalPriceCurrency = pkgsSumRequested.currency
}
}
} else {
acc.requested.price = add(
acc.requested.price,
roomPrice.perStay.requested.price,
breakfastRequestedTotalPrice,
pkgsSumRequested.price
)
}
}
const breakfastLocalTotalPrice =
breakfastLocalPrice * room.adults * nights
if (isSpecialRate) {
acc.local.price = add(acc.local.price, roomPrice.perStay.local.price)
if (
roomPrice.perStay.local.additionalPrice ||
breakfastLocalTotalPrice ||
pkgsSum.price
) {
acc.local.additionalPrice = add(
acc.local.additionalPrice,
roomPrice.perStay.local.additionalPrice,
breakfastLocalTotalPrice,
pkgsSum.price
)
}
if (!acc.local.additionalPriceCurrency) {
if (roomPrice.perStay.local.additionalPriceCurrency) {
acc.local.additionalPriceCurrency =
roomPrice.perStay.local.additionalPriceCurrency
} else if (room.breakfast) {
acc.local.additionalPriceCurrency =
room.breakfast.localPrice.currency
} else if (pkgsSum.currency) {
acc.local.additionalPriceCurrency = pkgsSum.currency
}
}
} else {
acc.local.price = add(
acc.local.price,
roomPrice.perStay.local.price,
breakfastLocalTotalPrice,
pkgsSum.price
)
if (roomPrice.perStay.local.regularPrice) {
acc.local.regularPrice = add(
acc.local.regularPrice,
roomPrice.perStay.local.regularPrice,
breakfastLocalTotalPrice,
pkgsSum.price
)
}
}
return acc
},
{
requested: undefined,
local: { currency, price: 0 },
}
)
}
export const checkRoomProgress = (steps: RoomState["steps"]) => {
return Object.values(steps)
.filter(Boolean)
@@ -602,3 +300,373 @@ export function clearSessionStorage() {
}
sessionStorage.removeItem(detailsStorageName)
}
function getAdditionalPrice(
total: Price,
adults: number,
breakfast: BreakfastPackage | false | undefined,
nights: number,
packages: Packages | null,
additionalPrice = 0,
additionalPriceCurrency?: CurrencyEnum | null | undefined
) {
const breakfastLocalPrice =
(breakfast ? breakfast.localPrice.price : 0) * nights * adults
const pkgsSum = sumPackages(packages)
total.local.additionalPrice = add(
total.local.additionalPrice,
additionalPrice,
breakfastLocalPrice,
pkgsSum.price
)
if (!total.local.additionalPriceCurrency) {
if (additionalPriceCurrency) {
total.local.additionalPriceCurrency = additionalPriceCurrency
} else if (breakfast && breakfast.localPrice.currency) {
total.local.additionalPriceCurrency = breakfast.localPrice.currency
} else if (pkgsSum.currency) {
total.local.additionalPriceCurrency = pkgsSum.currency
}
}
}
function getRequestedAdditionalPrice(
total: Price,
adults: number,
breakfast: BreakfastPackage | false | undefined,
nights: number,
packages: Packages | null,
additionalPrice = 0,
additionalPriceCurrency: CurrencyEnum | null | undefined
) {
if (!total.requested) {
total.requested = {
currency: CurrencyEnum.CC,
price: 0,
}
}
const breakfastRequestedPrice =
(breakfast ? breakfast.requestedPrice?.price || 0 : 0) * nights * adults
const pkgsSumRequested = sumPackagesRequestedPrice(packages)
total.requested.additionalPrice = add(
total.requested.additionalPrice,
additionalPrice,
breakfastRequestedPrice,
pkgsSumRequested.price
)
if (!total.requested.additionalPriceCurrency) {
if (additionalPriceCurrency) {
total.requested.additionalPriceCurrency = additionalPriceCurrency
} else if (pkgsSumRequested.currency) {
total.requested.additionalPriceCurrency = pkgsSumRequested.currency
} else if (breakfast && breakfast.requestedPrice) {
total.requested.additionalPriceCurrency =
breakfast.requestedPrice.currency
}
}
}
interface TRoom
extends Pick<
RoomState["room"],
"adults" | "breakfast" | "guest" | "roomFeatures" | "roomRate"
> {}
interface TRoomCorporateCheque extends TRoom {
roomRate: CorporateChequeProduct
}
export function getCorporateChequePrice(rooms: TRoom[], nights: number) {
return rooms
.filter(
(room): room is TRoomCorporateCheque => "corporateCheque" in room.roomRate
)
.reduce<Price>(
(total, room) => {
const corporateCheque = room.roomRate.corporateCheque
total.local.price = add(
total.local.price,
corporateCheque.localPrice.numberOfCheques
)
getAdditionalPrice(
total,
room.adults,
room.breakfast,
nights,
room.roomFeatures,
corporateCheque.localPrice.additionalPricePerStay,
corporateCheque.localPrice.currency
)
if (corporateCheque.requestedPrice) {
getRequestedAdditionalPrice(
total,
room.adults,
room.breakfast,
nights,
room.roomFeatures,
corporateCheque.requestedPrice?.additionalPricePerStay,
corporateCheque.requestedPrice?.currency
)
}
return total
},
{
local: {
currency: CurrencyEnum.CC,
price: 0,
},
requested: undefined,
}
)
}
interface TRoomVoucher extends TRoom {
roomRate: VoucherProduct
}
export function getVoucherPrice(rooms: TRoom[], nights: number) {
return rooms
.filter((room): room is TRoomVoucher => "voucher" in room.roomRate)
.reduce<Price>(
(total, room) => {
const voucher = room.roomRate.voucher
total.local.price = add(total.local.price, voucher.numberOfVouchers)
getAdditionalPrice(
total,
room.adults,
room.breakfast,
nights,
room.roomFeatures
)
return total
},
{
local: {
currency: CurrencyEnum.Voucher,
price: 0,
},
requested: undefined,
}
)
}
interface TRoomRedemption extends TRoom {
roomRate: RedemptionProduct
}
export function getRedemptionPrice(rooms: TRoom[], nights: number) {
return rooms
.filter((room): room is TRoomRedemption => "redemption" in room.roomRate)
.reduce<Price>(
(total, room) => {
const redemption = room.roomRate.redemption
total.local.price = add(
total.local.price,
redemption.localPrice.pointsPerStay
)
getAdditionalPrice(
total,
room.adults,
room.breakfast,
nights,
room.roomFeatures,
redemption.localPrice.additionalPricePerStay,
redemption.localPrice.currency
)
return total
},
{
local: {
currency: CurrencyEnum.POINTS,
price: 0,
},
requested: undefined,
}
)
}
interface TRoomPriceProduct extends TRoom {
roomRate: PriceProduct
}
export function getRegularPrice(
rooms: TRoom[],
isMember: boolean,
nights: number
) {
const totalPrice = rooms
.filter(
(room): room is TRoomPriceProduct =>
"member" in room.roomRate || "public" in room.roomRate
)
.reduce<Price>(
(total, room, idx) => {
const isMainRoomAndMember = idx === 0 && isMember
const join = Boolean(room.guest.join || room.guest.membershipNo)
const getMemberRate = isMainRoomAndMember || join
const memberRate = "member" in room.roomRate && room.roomRate.member
const publicRate = "public" in room.roomRate && room.roomRate.public
let rate
if (getMemberRate && memberRate) {
rate = memberRate
} else if (publicRate) {
rate = publicRate
}
if (!rate) {
return total
}
const breakfastLocalPrice =
(room.breakfast ? room.breakfast.localPrice.price || 0 : 0) *
nights *
room.adults
const pkgsSum = sumPackages(room.roomFeatures)
const additionalCost = breakfastLocalPrice + pkgsSum.price
total.local.currency = rate.localPrice.currency
total.local.price = add(
total.local.price,
rate.localPrice.pricePerStay,
additionalCost
)
if (rate.requestedPrice) {
if (!total.requested) {
total.requested = {
currency: rate.requestedPrice.currency,
price: 0,
}
}
const breakfastRequestedPrice =
(room.breakfast ? (room.breakfast.requestedPrice?.price ?? 0) : 0) *
nights *
room.adults
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
total.requested.price = add(
total.requested.price,
rate.requestedPrice.pricePerStay,
breakfastRequestedPrice,
pkgsSumRequested.price
)
}
// Legend:
// - total.local.price = Total Price = Black price, what the user pays
// - total.local.regularPrice = Regular Price = Strikethrough price (could potentially be none)
// - total.requested.price = Requested Price = EUR approx price
// We sometimes don't get all the required data to calculate the correct strikethrough total.
// Therefore we try these different approach to get a number that is close
// enough to the real number if all data would've been present.
if (getMemberRate && memberRate) {
if (publicRate) {
// #1 Member price uses public price as strikethrough
total.local.regularPrice = add(
total.local.regularPrice,
publicRate.localPrice.pricePerStay,
additionalCost
)
} else if (memberRate.localPrice.regularPricePerStay) {
// #2 Member price uses member regular price as strikethrough
total.local.regularPrice = add(
total.local.regularPrice,
memberRate.localPrice.regularPricePerStay,
additionalCost
)
} else {
// #3 Member price uses member price as strikethrough
// NOTE: If all rooms end up using this, no strikethrough price is shown.
total.local.regularPrice = add(
total.local.regularPrice,
memberRate.localPrice.pricePerStay,
additionalCost
)
}
} else if (publicRate) {
if (publicRate.localPrice.regularPricePerStay) {
// #1 Public price uses public regular price as strikethrough
total.local.regularPrice = add(
total.local.regularPrice,
publicRate.localPrice.regularPricePerStay,
additionalCost
)
} else {
// #2 Public price uses public price as strikethrough
// NOTE: If all rooms end up using this, no strikethrough price is shown.
total.local.regularPrice = add(
total.local.regularPrice,
publicRate.localPrice.pricePerStay,
additionalCost
)
}
} else {
// We cannot do anything, too much data is missing.
return total
}
return total
},
{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
regularPrice: 0,
},
requested: undefined,
}
)
if (
totalPrice.local.regularPrice &&
totalPrice.local.price >= totalPrice.local.regularPrice
) {
totalPrice.local.regularPrice = 0
}
return totalPrice
}
export function getTotalPrice(
rooms: TRoom[],
isMember: boolean,
nights: number
) {
const hasCorpChqRates = rooms.some(
(room) => "corporateCheque" in room.roomRate
)
if (hasCorpChqRates) {
return getCorporateChequePrice(rooms, nights)
}
const hasRedemptionRates = rooms.some((room) => "redemption" in room.roomRate)
if (hasRedemptionRates) {
return getRedemptionPrice(rooms, nights)
}
const hasVoucherRates = rooms.some((room) => "voucher" in room.roomRate)
if (hasVoucherRates) {
return getVoucherPrice(rooms, nights)
}
return getRegularPrice(rooms, isMember, nights)
}

View File

@@ -3,21 +3,12 @@ import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { REDEMPTION } from "@/constants/booking"
import { getDefaultCountryFromLang } from "@/constants/languages"
import { dt } from "@/lib/dt"
import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { DetailsContext } from "@/contexts/Details"
import {
add,
calcTotalPrice,
calculateCorporateChequePrice,
calculateVoucherPrice,
checkRoomProgress,
extractGuestFromUser,
getRoomPrice,
@@ -27,7 +18,6 @@ import {
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
import type { Price } from "@/types/components/hotelReservation/price"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step"
import type {
DetailsState,
@@ -60,89 +50,37 @@ export function createDetailsStore(
lang: Lang
) {
const isMember = !!user
const isRedemption =
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION
const isVoucher = initialState.rooms.some(
(room) => "voucher" in room.roomRate
)
const isCorpChq = initialState.rooms.some(
(room) => "corporateCheque" in room.roomRate
const nights = dt(initialState.booking.toDate).diff(
initialState.booking.fromDate,
"days"
)
let initialTotalPrice: Price
const roomOneRoomRate = initialState.rooms[0].roomRate
const initialRoomRates = initialState.rooms.map((r) => r.roomRate)
if (isRedemption && "redemption" in roomOneRoomRate) {
initialTotalPrice = {
local: {
currency: CurrencyEnum.POINTS,
price: roomOneRoomRate.redemption.localPrice.pointsPerStay,
const initialRooms = initialState.rooms.map((room, idx) => {
return {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
!breakfastPackages.length || room.breakfastIncluded
? (false as const)
: undefined,
guest:
isMember && idx === 0
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: {
...defaultGuestState,
phoneNumberCC: getDefaultCountryFromLang(lang),
},
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
specialRequest: {
comment: "",
},
}
if (roomOneRoomRate.redemption.localPrice.currency) {
initialTotalPrice.local.additionalPriceCurrency =
roomOneRoomRate.redemption.localPrice.currency
}
if (roomOneRoomRate.redemption.localPrice.additionalPricePerStay) {
initialTotalPrice.local.additionalPrice =
roomOneRoomRate.redemption.localPrice.additionalPricePerStay
}
} else if (isVoucher) {
const pkgs = initialState.rooms.flatMap((room) => room.roomFeatures || [])
initialTotalPrice = calculateVoucherPrice(initialRoomRates, pkgs)
} else if (isCorpChq) {
initialTotalPrice = calculateCorporateChequePrice(initialRoomRates)
} else {
initialTotalPrice = getTotalPrice(initialRoomRates, isMember)
}
initialState.rooms.forEach((room) => {
if (room.roomFeatures) {
const pkgsSum = sumPackages(room.roomFeatures)
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
if ("corporateCheque" in room.roomRate || "redemption" in room.roomRate) {
initialTotalPrice.local.additionalPrice = add(
initialTotalPrice.local.additionalPrice,
pkgsSum.price
)
if (
!initialTotalPrice.local.additionalPriceCurrency &&
pkgsSum.currency
) {
initialTotalPrice.local.additionalPriceCurrency = pkgsSum.currency
}
if (initialTotalPrice.requested) {
initialTotalPrice.requested.additionalPrice = add(
initialTotalPrice.requested.additionalPrice,
pkgsSumRequested.price
)
if (
!initialTotalPrice.requested.additionalPriceCurrency &&
pkgsSumRequested.currency
) {
initialTotalPrice.requested.additionalPriceCurrency =
pkgsSumRequested.currency
}
}
} else if ("public" in room.roomRate) {
if (initialTotalPrice.requested) {
initialTotalPrice.requested.price = add(
initialTotalPrice.requested.price,
pkgsSumRequested.price
)
}
initialTotalPrice.local.price = add(
initialTotalPrice.local.price,
pkgsSum.price
)
}
}
})
const initialTotalPrice: Price = getTotalPrice(initialRooms, isMember, nights)
const availableBeds = initialState.rooms.reduce<
DetailsState["availableBeds"]
>((total, room) => {
@@ -162,7 +100,7 @@ export function createDetailsStore(
isSubmitting: false,
isSummaryOpen: false,
lastRoom: initialState.booking.rooms.length - 1,
rooms: initialState.rooms.map((room, idx) => {
rooms: initialRooms.map((room, idx) => {
const steps: RoomState["steps"] = {
[StepEnum.selectBed]: {
step: StepEnum.selectBed,
@@ -235,9 +173,8 @@ export function createDetailsStore(
"days"
)
state.totalPrice = calcTotalPrice(
state.rooms,
currentRoom.room.roomPrice.perStay.local.currency,
state.totalPrice = getTotalPrice(
state.rooms.map((r) => r.room),
isMember,
nights
)
@@ -275,9 +212,8 @@ export function createDetailsStore(
"days"
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice.local.currency,
state.totalPrice = getTotalPrice(
state.rooms.map((r) => r.room),
isMember,
nights
)
@@ -307,9 +243,8 @@ export function createDetailsStore(
"days"
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice.local.currency,
state.totalPrice = getTotalPrice(
state.rooms.map((r) => r.room),
isMember,
nights
)
@@ -368,9 +303,8 @@ export function createDetailsStore(
"days"
)
state.totalPrice = calcTotalPrice(
state.rooms,
state.totalPrice.local.currency,
state.totalPrice = getTotalPrice(
state.rooms.map((r) => r.room),
isMember,
nights
)
@@ -390,27 +324,7 @@ export function createDetailsStore(
)
},
},
room: {
...room,
adults: initialState.booking.rooms[idx].adults,
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
bedType: room.bedType,
breakfast:
!breakfastPackages.length || room.breakfastIncluded
? false
: undefined,
guest:
isMember && idx === 0
? deepmerge(defaultGuestState, extractGuestFromUser(user))
: {
...defaultGuestState,
phoneNumberCC: getDefaultCountryFromLang(lang),
},
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
specialRequest: {
comment: "",
},
},
room,
isComplete: false,
steps,
}
@@ -429,14 +343,6 @@ export function createDetailsStore(
})
)
},
setTotalPrice(totalPrice) {
return set(
produce((state: DetailsState) => {
state.totalPrice.requested = totalPrice.requested
state.totalPrice.local = totalPrice.local
})
)
},
toggleSummaryOpen() {
return set(
produce((state: DetailsState) => {

View File

@@ -1,3 +1,7 @@
import type { Room } from "@/types/stores/booking-confirmation"
export interface BookingConfirmationReceiptRoomProps {
roomIndex: number
room: Room
roomNumber: number
roomCount: number
}

View File

@@ -4,5 +4,4 @@ export interface MobileSummaryProps {
isAllRoomsSelected: boolean
isUserLoggedIn: boolean
totalPriceToShow: Price
showMemberDiscountBanner: boolean
}

View File

@@ -84,7 +84,6 @@ export type InitialState = {
export interface DetailsState {
actions: {
setIsSubmitting: (isSubmitting: boolean) => void
setTotalPrice: (totalPrice: Price) => void
toggleSummaryOpen: () => void
updateSeachParamString: (searchParamString: string) => void
addPreSubmitCallback: (name: string, callback: () => void) => void

View File

@@ -34,13 +34,13 @@ export const Default: Story = {
paymentTerm: 'PAY NOW',
rate: {
label: 'Standard Price',
price: '198',
unit: 'EUR/NIGHT',
price: '1980',
unit: 'SEK/NIGHT',
},
memberRate: {
label: 'Member Price',
price: '190',
unit: 'EUR/NIGHT',
price: '1900',
unit: 'SEK/NIGHT',
},
approximateRate: {
price: '198',
@@ -49,8 +49,8 @@ export const Default: Story = {
},
omnibusRate: {
label: 'Lowest past price (last 30 days)',
price: '169',
unit: 'EUR',
price: '1690',
unit: 'SEK/NIGHT',
},
rateTermDetails: [
{
@@ -70,13 +70,13 @@ export const Selected: Story = {
paymentTerm: 'PAY NOW',
rate: {
label: 'Standard Price',
price: '198',
unit: 'EUR/NIGHT',
price: '1980',
unit: 'SEK/NIGHT',
},
memberRate: {
label: 'Member Price',
price: '190',
unit: 'EUR/NIGHT',
price: '1900',
unit: 'SEK/NIGHT',
},
approximateRate: {
price: '198',
@@ -92,7 +92,7 @@ export const Selected: Story = {
},
}
export const HidePublicRate: Story = {
export const MemberRateActive: Story = {
args: {
name: 'regular',
value: 'regular',
@@ -100,20 +100,20 @@ export const HidePublicRate: Story = {
paymentTerm: 'PAY NOW',
rate: {
label: 'Standard Price',
price: '198',
unit: 'EUR/NIGHT',
price: '1980',
unit: 'SEK',
},
memberRate: {
label: 'Member Price',
price: '190',
unit: 'EUR/NIGHT',
price: '1900',
unit: 'SEK/NIGHT',
},
approximateRate: {
price: '198',
price: '190',
label: 'Approx.',
unit: 'EUR',
},
hidePublicRate: true,
isMemberRateActive: true,
rateTermDetails: [
{
title: 'Rate definition 1',

View File

@@ -17,7 +17,7 @@ interface RegularRateCardProps {
memberRate?: Rate
omnibusRate?: Rate
approximateRate?: Rate
hidePublicRate?: boolean
isMemberRateActive?: boolean
handleChange: () => void
rateTermDetails: RateTermDetails[]
}
@@ -32,7 +32,7 @@ export default function RegularRateCard({
omnibusRate,
rate,
memberRate,
hidePublicRate,
isMemberRateActive,
handleChange,
rateTermDetails,
}: RegularRateCardProps) {
@@ -97,7 +97,7 @@ export default function RegularRateCard({
</div>
</header>
<div>
{!hidePublicRate && rate ? (
{!isMemberRateActive && rate ? (
<div className={styles.rateRow}>
<Typography variant="Body/Supporting text (caption)/smBold">
<p>{rate.label}</p>
@@ -118,15 +118,29 @@ export default function RegularRateCard({
<p>{memberRate.label}</p>
</Typography>
<Typography variant="Title/Subtitle/md">
<p>
<span>
{`${memberRate.price} `}
<Typography variant="Body/Supporting text (caption)/smBold">
<span>{memberRate.unit}</span>
</Typography>
</p>
</span>
</Typography>
</div>
) : null}
{isMemberRateActive && rate ? (
<Typography variant="Body/Paragraph/mdRegular">
<div
className={`${styles.rateRow} ${styles.strikeThroughRate}`}
>
<s>
{`${rate.price} `}
<Typography variant="Tag/sm">
<span>{rate.unit}</span>
</Typography>
</s>
</div>
</Typography>
) : null}
{approximateRate ? (
<div className={`${styles.rateRow} ${styles.approximateRate}`}>
<Typography variant="Body/Supporting text (caption)/smRegular">

View File

@@ -90,6 +90,13 @@ label:not(:has(.radio:checked)) .checkIcon {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--Space-x1);
&.strikeThroughRate {
grid-template-columns: 1fr;
justify-items: end;
text-decoration: line-through;
color: var(--Text-Secondary);
}
}
.highlightedRate {