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" "use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl" 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 { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" 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 { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Modal from "@/components/Modal" import Modal from "@/components/Modal"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import Breakfast from "./Breakfast" import Breakfast from "./Breakfast"
@@ -21,12 +22,13 @@ import styles from "./room.module.css"
import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt" import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
export default function ReceiptRoom({ export default function ReceiptRoom({
roomIndex, room,
roomNumber,
roomCount,
}: BookingConfirmationReceiptRoomProps) { }: BookingConfirmationReceiptRoomProps) {
const intl = useIntl() const intl = useIntl()
const { room, currencyCode, isVatCurrency } = useBookingConfirmationStore( const { currencyCode, isVatCurrency } = useBookingConfirmationStore(
(state) => ({ (state) => ({
room: state.rooms[roomIndex],
currencyCode: state.currencyCode, currencyCode: state.currencyCode,
isVatCurrency: state.isVatCurrency, isVatCurrency: state.isVatCurrency,
}) })
@@ -64,49 +66,69 @@ export default function ReceiptRoom({
} }
const guests = guestsParts.join(", ") const guests = guestsParts.join(", ")
const showDiscounted = room.rateDefinition.isMemberRate
return ( return (
<article className={styles.room}> <>
<header className={styles.roomHeader}> <div className={styles.room}>
<Typography variant="Body/Paragraph/mdRegular"> <div>
<p className={styles.uiTextHighContrast}>{room.name}</p> {roomCount > 1 ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</p>
</Typography> </Typography>
{room.rateDefinition.isMemberRate ? ( ) : null}
<div className={styles.memberPrice}> <div className={styles.entry}>
<Typography variant="Body/Paragraph/mdRegular"> <div>
<p className={styles.red}>{room.formattedRoomCost}</p> <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> </Typography>
</div> </div>
) : (
<Typography variant="Body/Paragraph/mdRegular"> <Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextHighContrast}> <div className={styles.prices}>
<p
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{room.formattedRoomCost} {room.formattedRoomCost}
</p> </p>
{/* TODO: add original price, we're currently not receiving this value from API */}
</div>
</Typography> </Typography>
)} </div>
<Typography variant="Body/Paragraph/mdRegular"> {room.rateDefinition.generalTerms ? (
<p className={styles.uiTextMediumContrast}>{guests}</p> <div className={styles.ctaWrapper}>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.uiTextMediumContrast}>
{room.rateDefinition.cancellationText}
</p>
</Typography>
<Modal <Modal
trigger={ trigger={
<Button intent="text" className={styles.termsLink}> <Button
<Link className={styles.termsButton}
color="Text/Interactive/Secondary" variant="Text"
href="" typography="Body/Supporting text (caption)/smBold"
size="small" wrapping={false}
textDecoration="underline"
variant="icon"
> >
{intl.formatMessage({ {intl.formatMessage({
defaultMessage: "Reservation policy", defaultMessage: "Reservation policy",
})} })}
<MaterialIcon icon="info" color="CurrentColor" /> <MaterialIcon
</Link> icon="chevron_right"
size={20}
color="CurrentColor"
/>
</Button> </Button>
} }
title={ title={
@@ -145,7 +167,10 @@ export default function ReceiptRoom({
))} ))}
</div> </div>
</Modal> </Modal>
</header> </div>
) : null}
</div>
{room.roomFeatures {room.roomFeatures
? room.roomFeatures.map((feature) => ( ? room.roomFeatures.map((feature) => (
<div className={styles.entry} key={feature.code}> <div className={styles.entry} key={feature.code}>
@@ -175,11 +200,12 @@ export default function ReceiptRoom({
</p> </p>
</Typography> </Typography>
</div> </div>
{childBedCrib ? ( {childBedCrib ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}> <div className={styles.entry}>
<div> <div>
<Typography variant="Body/Paragraph/mdRegular"> <p>
<p className={styles.uiTextHighContrast}>
{intl.formatMessage( {intl.formatMessage(
{ {
defaultMessage: "Crib (child) × {count}", defaultMessage: "Crib (child) × {count}",
@@ -187,7 +213,6 @@ export default function ReceiptRoom({
{ count: childBedCrib.quantity } { count: childBedCrib.quantity }
)} )}
</p> </p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular"> <Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.uiTextHighContrast}> <p className={styles.uiTextHighContrast}>
{intl.formatMessage({ {intl.formatMessage({
@@ -196,18 +221,19 @@ export default function ReceiptRoom({
</p> </p>
</Typography> </Typography>
</div> </div>
<Typography variant="Body/Paragraph/mdRegular"> <div className={styles.prices}>
<p className={styles.uiTextHighContrast}> <span className={styles.price}>
{formatPrice(intl, 0, currencyCode)} {formatPrice(intl, 0, currencyCode)}
</p> </span>
</Typography>
</div> </div>
</div>
</Typography>
) : null} ) : null}
{childBedExtraBed ? ( {childBedExtraBed ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}> <div className={styles.entry}>
<div> <div>
<Typography variant="Body/Paragraph/mdRegular"> <p>
<p className={styles.uiTextHighContrast}>
{intl.formatMessage( {intl.formatMessage(
{ {
defaultMessage: "Extra bed (child) × {count}", defaultMessage: "Extra bed (child) × {count}",
@@ -217,20 +243,22 @@ export default function ReceiptRoom({
} }
)} )}
</p> </p>
</Typography>
</div> </div>
<Typography variant="Body/Paragraph/mdRegular"> <div className={styles.prices}>
<p className={styles.uiTextHighContrast}> <span className={styles.price}>
{formatPrice(intl, 0, currencyCode)} {formatPrice(intl, 0, currencyCode)}
</p> </span>
</Typography>
</div> </div>
</div>
</Typography>
) : null} ) : null}
<Breakfast <Breakfast
breakfast={room.breakfast} breakfast={room.breakfast}
breakfastIncluded={room.breakfastIncluded} breakfastIncluded={room.breakfastIncluded}
guests={guests} guests={guests}
/> />
</article> </div>
<Divider color="Border/Divider/Subtle" />
</>
) )
} }

View File

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

View File

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

View File

@@ -1,12 +1,33 @@
.entry { .entry {
display: flex; display: flex;
gap: var(--Space-x05);
justify-content: space-between; justify-content: space-between;
margin-bottom: var(--Space-x15);
} }
.price button.btn { .prices {
padding: 0; justify-items: flex-end;
flex-shrink: 0;
display: grid;
} }
.priceDetailsLoader { .price {
padding-top: var(--Spacing-x1); 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 { 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 { useBookingConfirmationStore } from "@/stores/booking-confirmation"
import Body from "@/components/TempDesignSystem/Text/Body" import useLang from "@/hooks/useLang"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Room from "./Room" import Room from "./Room"
import TotalPrice from "./TotalPrice" import TotalPrice from "./TotalPrice"
@@ -13,31 +17,56 @@ import TotalPrice from "./TotalPrice"
import styles from "./receipt.module.css" import styles from "./receipt.module.css"
export default function Receipt() { export default function Receipt() {
const lang = useLang()
const intl = useIntl() 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 ( return (
<section className={styles.receipt}> <section className={styles.receipt}>
<Subtitle type="two"> <header>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({ {intl.formatMessage({
defaultMessage: "Booking summary", defaultMessage: "Booking summary",
})} })}
</Subtitle> </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) => ( <Divider color="Border/Divider/Subtle" />
<div key={room ? room.confirmationNumber : `loader-${idx}`}>
{rooms.length > 1 ? ( {filteredRooms.map((room, idx) => (
<Body color="uiTextHighContrast" textTransform={"bold"}> <Room
{intl.formatMessage( key={room ? room.confirmationNumber : `loader-${idx}`}
{ room={room}
defaultMessage: "Room {roomIndex}", roomNumber={idx + 1}
}, roomCount={rooms.length}
{ roomIndex: idx + 1 } />
)}
</Body>
) : null}
<Room roomIndex={idx} />
</div>
))} ))}
<TotalPrice /> <TotalPrice />

View File

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

View File

@@ -1,8 +1,7 @@
.wrapper { .wrapper {
display: grid; display: grid;
grid-template-rows: 0fr 7.5em; grid-template-rows: 0fr auto;
transition: all 0.5s ease-in-out;
transition: 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle); border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal); background: var(--Base-Surface-Primary-light-Normal);
align-content: end; align-content: end;
@@ -10,24 +9,22 @@
.bottomSheet { .bottomSheet {
display: grid; display: grid;
grid-template-columns: 1fr auto; 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; align-items: flex-start;
transition: 0.5s ease-in-out; transition: all 0.5s ease-in-out;
max-width: var(--max-width-page); width: 100vw;
width: 100%;
margin: 0 auto;
} }
.priceDetailsButton { .priceDetailsButton {
display: block; border-width: 0;
border: none; background-color: transparent;
background: none;
text-align: start; text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer; cursor: pointer;
white-space: nowrap;
padding: 0; padding: 0;
display: grid;
overflow: hidden;
transition: all 0.3s ease-in-out;
} }
.wrapper[data-open="true"] { .wrapper[data-open="true"] {
@@ -51,35 +48,47 @@
opacity: 1; opacity: 1;
} }
.priceDetailsButton {
overflow: hidden;
}
.content { .content {
max-height: 50dvh; max-height: 50dvh;
overflow-y: auto; 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) { @media screen and (min-width: 768px) {
.bottomSheet { .bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7); padding: var(--Space-x2) 0 var(--Space-x7);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
} }
} }

View File

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

View File

@@ -51,7 +51,7 @@ export default function MobileSummary({ isMember }: SummaryProps) {
/> />
)} )}
<SummaryBottomSheet> <SummaryBottomSheet isMember={isMember}>
<div className={styles.wrapper}> <div className={styles.wrapper}>
<SummaryUI <SummaryUI
booking={booking} 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" "use client"
import { Fragment } from "react" import { cx } from "class-variance-authority"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { useMediaQuery } from "usehooks-ts" import { useMediaQuery } from "usehooks-ts"
import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import BookingCodeChip from "@/components/BookingCodeChip" import BookingCodeChip from "@/components/BookingCodeChip"
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal" import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop" import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
import Modal from "@/components/Modal"
import Body from "@/components/TempDesignSystem/Text/Body" import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle" import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import Breakfast from "./Breakfast"
import { mapToPrice } from "./mapToPrice" import { mapToPrice } from "./mapToPrice"
import Room from "./Room"
import { getMemberPrice } from "./utils"
import styles from "./ui.module.css" import styles from "./ui.module.css"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary" import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
export default function SummaryUI({ 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 roomOneGuest = rooms[0].room.guest
const showSignupPromo = const showSignupPromo =
rooms.length === 1 && rooms.length === 1 &&
@@ -85,10 +71,6 @@ export default function SummaryUI({
"redemption" in roomOneRoomRate || "redemption" in roomOneRoomRate ||
"voucher" in roomOneRoomRate "voucher" in roomOneRoomRate
const isSameCurrency = totalPrice.requested
? totalPrice.requested.currency === totalPrice.local.currency
: false
const priceDetailsRooms = mapToPrice(rooms, isMember) const priceDetailsRooms = mapToPrice(rooms, isMember)
const isAllCampaignRate = rooms.every( const isAllCampaignRate = rooms.every(
(room) => room.room.roomRate.rateDefinition.isCampaignRate (room) => room.room.roomRate.rateDefinition.isCampaignRate
@@ -96,6 +78,10 @@ export default function SummaryUI({
const isAllBreakfastIncluded = rooms.every( const isAllBreakfastIncluded = rooms.every(
(room) => room.room.roomRate.rateDefinition.breakfastIncluded (room) => room.room.roomRate.rateDefinition.breakfastIncluded
) )
const containsBookingCodeRate = rooms.find(
(r) => r && isBookingCodeRate(r.room.roomRate)
)
const showDiscounted = containsBookingCodeRate || isMember
return ( return (
<section className={styles.summary}> <section className={styles.summary}>
@@ -127,279 +113,41 @@ export default function SummaryUI({
/> />
</header> </header>
<Divider color="Border/Divider/Subtle" /> <Divider color="Border/Divider/Subtle" />
{rooms.map(({ room }, idx) => { {rooms.map(({ room }, idx) => (
const roomNumber = idx + 1 <Room
const adults = room.adults key={idx}
const childrenInRoom = room.childrenInRoom defaultCurrency={defaultCurrency}
room={room}
const childrenBeds = childrenInRoom?.reduce( roomNumber={idx + 1}
(acc, value) => { roomCount={rooms.length}
const bedType = Number(value.bed) isMember={isMember}
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) { isSpecialRate={isSpecialRate}
return acc nightsCount={nights}
}
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"> <div>
{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 className={styles.entry}>
<div> <div>
<Body color="uiTextHighContrast"> <Typography variant="Body/Paragraph/mdRegular">
{intl.formatMessage( <p>
{
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 className={styles.entry}>
<div>
<Body>
{intl.formatMessage( {intl.formatMessage(
{ {
defaultMessage: "<b>Total price</b> (incl VAT)", defaultMessage: "<b>Total price</b> (incl VAT)",
}, },
{ b: (str) => <b>{str}</b> } {
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)} )}
</Body> </p>
<PriceDetailsModal </Typography>
bookingCode={booking.bookingCode} {totalPrice.requested ? (
fromDate={booking.fromDate} <Typography variant="Body/Supporting text (caption)/smRegular">
isCampaignRate={isAllCampaignRate} <p className={styles.approxPrice}>
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">
{intl.formatMessage( {intl.formatMessage(
{ {
defaultMessage: "Approx. {value}", defaultMessage: "Approx. {value}",
@@ -408,12 +156,57 @@ export default function SummaryUI({
value: formatPrice( value: formatPrice(
intl, intl,
totalPrice.requested.price, totalPrice.requested.price,
totalPrice.requested.currency totalPrice.requested.currency,
totalPrice.requested.additionalPrice,
totalPrice.requested.additionalPriceCurrency
), ),
} }
)} )}
</Caption> </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>
<div className={styles.ctaWrapper}>
<PriceDetailsModal
bookingCode={booking.bookingCode}
defaultCurrency={defaultCurrency}
fromDate={booking.fromDate}
rooms={priceDetailsRooms}
toDate={booking.toDate}
totalPrice={totalPrice}
vat={vat}
/>
</div> </div>
</div> </div>
<BookingCodeChip <BookingCodeChip
@@ -422,11 +215,7 @@ export default function SummaryUI({
isBreakfastIncluded={isAllBreakfastIncluded} isBreakfastIncluded={isAllBreakfastIncluded}
alignCenter alignCenter
/> />
<Divider <Divider className={styles.bottomDivider} color="Border/Divider/Subtle" />
className={styles.bottomDivider}
color="Border/Divider/Subtle"
/>
</div>
{showSignupPromo && roomOneMemberPrice && !isMember ? ( {showSignupPromo && roomOneMemberPrice && !isMember ? (
<SignupPromoDesktop <SignupPromoDesktop
memberPrice={roomOneMemberPrice} memberPrice={roomOneMemberPrice}

View File

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

View File

@@ -53,9 +53,32 @@
gap: var(--Spacing-x-half); gap: var(--Spacing-x-half);
justify-content: space-between; justify-content: space-between;
} }
.prices {
.entry > :last-child {
justify-items: flex-end; 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 { .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, currency: room.currencyCode,
pricePerNight: room.roomPrice.perNight.local.price, pricePerNight: room.roomPrice.perNight.local.price,
pricePerStay: room.roomPrice.perStay.local.price, pricePerStay: room.roomPrice.perStay.local.price,
regularPricePerStay:
room.roomPrice.perStay.local.regularPrice ||
room.roomPrice.perStay.local.price,
}, },
} }
case PriceTypeEnum.points: case PriceTypeEnum.points:

View File

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

View File

@@ -22,15 +22,6 @@
width: 100%; width: 100%;
} }
.roomWrapper {
min-width: 0;
width: 100%;
}
.roomWrapper > * {
width: 100%;
}
.totalContainer { .totalContainer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -49,7 +40,7 @@
grid-template-columns: repeat(2, 1fr); 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); grid-template-columns: repeat(3, 1fr);
} }

View File

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

View File

@@ -1,3 +1,5 @@
import { cx } from "class-variance-authority"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import styles from "./row.module.css" import styles from "./row.module.css"
@@ -5,9 +7,16 @@ import styles from "./row.module.css"
interface RowProps { interface RowProps {
label: string label: string
value: 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 ( return (
<tr className={styles.row}> <tr className={styles.row}>
<td> <td>
@@ -16,8 +25,15 @@ export default function BoldRow({ label, value }: RowProps) {
</Typography> </Typography>
</td> </td>
<td className={styles.price}> <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"> <Typography variant="Body/Supporting text (caption)/smBold">
<span>{value}</span> <span className={cx({ [styles.discounted]: isDiscounted })}>
{value}
</span>
</Typography> </Typography>
</td> </td>
</tr> </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 { Typography } from "@scandic-hotels/design-system/Typography"
import { formatPrice } from "@/utils/numberFormatting"
import styles from "./row.module.css" import styles from "./row.module.css"
import type { Price } from "@/types/components/hotelReservation/price"
interface RowProps { interface RowProps {
allPricesIsDiscounted: boolean
label: string 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 ( return (
<Typography variant="Body/Paragraph/mdBold">
<tr className={styles.row}> <tr className={styles.row}>
<td> <td>
<Typography variant="Body/Paragraph/mdBold">
<span>{label}</span> <span>{label}</span>
</Typography>
</td> </td>
<td className={styles.price}> <td className={styles.price}>
<Typography variant="Body/Paragraph/mdBold"> {isDiscounted && regularPrice ? (
<span>{value}</span> <>
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>{regularPrice}</s>
</Typography> </Typography>
</>
) : null}
<span className={cx({ [styles.discounted]: isDiscounted })}>
{totalPrice}
</span>
</td> </td>
</tr> </tr>
</Typography>
) )
} }

View File

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

View File

@@ -1,8 +1,21 @@
.row { .row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
color: var(--Text-Default);
} }
.price { .price {
text-align: end; 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 { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting"
import BookingCodeRow from "./Row/BookingCode" import BookingCodeRow from "./Row/BookingCode"
import DiscountedRegularPriceRow from "./Row/DiscountedRegularPrice"
import HeaderRow from "./Row/Header" import HeaderRow from "./Row/Header"
import LargeRow from "./Row/Large" import LargeRow from "./Row/Large"
import CorporateChequePrice, { import CorporateChequePrice, {
@@ -32,7 +30,8 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
import type { Price } from "@/types/components/hotelReservation/price" import type { Price } from "@/types/components/hotelReservation/price"
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate" import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { CurrencyEnum } from "@/types/enums/currency" 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 = type RoomPrice =
| CorporateChequePriceType | CorporateChequePriceType
@@ -49,6 +48,7 @@ export interface Room {
childrenInRoom: Child[] | undefined childrenInRoom: Child[] | undefined
packages: Packages | null packages: Packages | null
price: RoomPrice price: RoomPrice
rateDefinition: Pick<RateDefinition, "isMemberRate">
roomType: string roomType: string
} }
@@ -86,11 +86,22 @@ export default function PriceDetailsTable({
const departue = dt(toDate).locale(lang).format("ddd, D MMM") const departue = dt(toDate).locale(lang).format("ddd, D MMM")
const duration = ` ${arrival} - ${departue} (${nightsMsg})` 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 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 ( return (
<table className={styles.priceDetailsTable}> <table className={styles.priceDetailsTable}>
{rooms.map((room, idx) => { {rooms.map((room, idx) => {
@@ -104,10 +115,12 @@ export default function PriceDetailsTable({
} }
} }
let isMemberRate = false
let price: RegularPriceType["regular"] | undefined let price: RegularPriceType["regular"] | undefined
if ("regular" in room.price && room.price.regular) { if ("regular" in room.price && room.price.regular) {
price = room.price.regular price = room.price.regular
currency = room.price.regular.currency currency = room.price.regular.currency
isMemberRate = room.rateDefinition.isMemberRate
} }
let redemptionPrice: RedemptionPriceType["redemption"] | undefined let redemptionPrice: RedemptionPriceType["redemption"] | undefined
@@ -153,6 +166,7 @@ export default function PriceDetailsTable({
<RegularPrice <RegularPrice
bedType={room.bedType} bedType={room.bedType}
packages={room.packages} packages={room.packages}
isMemberRate={isMemberRate}
nights={nights} nights={nights}
price={price} price={price}
/> />
@@ -197,20 +211,9 @@ export default function PriceDetailsTable({
<VatRow totalPrice={totalPrice} vat={vat} /> <VatRow totalPrice={totalPrice} vat={vat} />
<LargeRow <LargeRow
allPricesIsDiscounted={allPricesIsDiscounted}
label={intl.formatMessage({ defaultMessage: "Price including VAT" })} label={intl.formatMessage({ defaultMessage: "Price including VAT" })}
value={formatPrice( price={totalPrice}
intl,
totalPrice.local.price,
totalPrice.local.currency,
totalPrice.local.additionalPrice,
totalPrice.local.additionalPriceCurrency
)}
/>
<DiscountedRegularPriceRow
currency={totalPrice.local.currency}
packages={allRoomsPackages}
regularPrice={totalPrice.local.regularPrice}
/> />
<BookingCodeRow <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 useLang from "@/hooks/useLang"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import { isBookingCodeRate } from "./isBookingCodeRate"
import { mapToPrice } from "./mapToPrice" import { mapToPrice } from "./mapToPrice"
import { isBookingCodeRate } from "./utils"
import styles from "./summary.module.css" import styles from "./summary.module.css"

View File

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

View File

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

View File

@@ -1,13 +1,30 @@
.wrapper { .wrapper {
position: relative; position: relative;
display: grid; display: grid;
grid-template-rows: 0fr 7.5em; grid-template-rows: 0fr auto;
transition: all 0.5s ease-in-out;
transition: 0.5s ease-in-out;
border-top: 1px solid var(--Base-Border-Subtle); border-top: 1px solid var(--Base-Border-Subtle);
background: var(--Base-Surface-Primary-light-Normal); background: var(--Base-Surface-Primary-light-Normal);
align-content: end; align-content: end;
z-index: var(--default-modal-z-index); 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 { .signupPromoWrapper {
@@ -28,46 +45,21 @@
.bottomSheet { .bottomSheet {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; 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; align-items: flex-start;
transition: 0.5s ease-in-out; transition: all 0.5s ease-in-out;
max-width: var(--max-width-page); width: 100vw;
width: 100%;
margin: 0 auto;
} }
.priceDetailsButton { .priceDetailsButton {
display: block; border-width: 0;
border: none; background-color: transparent;
background: none;
text-align: start; text-align: start;
transition: padding 0.5s ease-in-out;
cursor: pointer; cursor: pointer;
white-space: nowrap;
padding: 0; padding: 0;
} display: grid;
.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 {
overflow: hidden; overflow: hidden;
transition: all 0.3s ease-in-out;
} }
.content { .content {
@@ -84,30 +76,33 @@
z-index: 10; z-index: 10;
} }
.wrappedText { .priceLabel {
white-space: normal; 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) { @media screen and (min-width: 768px) {
.bottomSheet { .bottomSheet {
padding: var(--Spacing-x2) 0 var(--Spacing-x7); padding: var(--Space-x2) 0 var(--Space-x7);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ import { useEffect, useRef, useState } from "react"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import { createDetailsStore } from "@/stores/enter-details" import { createDetailsStore } from "@/stores/enter-details"
import { import {
calcTotalPrice,
checkIsSameBooking as checkIsSameBooking, checkIsSameBooking as checkIsSameBooking,
clearSessionStorage, clearSessionStorage,
getTotalPrice,
readFromSessionStorage, readFromSessionStorage,
writeToSessionStorage, writeToSessionStorage,
} from "@/stores/enter-details/helpers" } from "@/stores/enter-details/helpers"
@@ -18,7 +18,6 @@ import LoadingSpinner from "@/components/LoadingSpinner"
import { DetailsContext } from "@/contexts/Details" import { DetailsContext } from "@/contexts/Details"
import type { DetailsStore } from "@/types/contexts/enter-details" import type { DetailsStore } from "@/types/contexts/enter-details"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step" import { StepEnum } from "@/types/enums/step"
import type { DetailsProviderProps } from "@/types/providers/enter-details" import type { DetailsProviderProps } from "@/types/providers/enter-details"
import type { InitialState, RoomState } from "@/types/stores/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") const nights = dt(booking.toDate).diff(booking.fromDate, "days")
// We only extract the first room for its currency, const totalPrice = getTotalPrice(
// the value is the same for the rest of the rooms filteredOutMissingRooms.map((r) => r.room),
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,
!!user, !!user,
nights nights
) )

View File

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

View File

@@ -8,12 +8,20 @@ import {
import { detailsStorageName } from "." import { detailsStorageName } from "."
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details" import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
import type { Price } from "@/types/components/hotelReservation/price" import type { Price } from "@/types/components/hotelReservation/price"
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency" 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 { 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" import type { SafeUser } from "@/types/user"
export function extractGuestFromUser(user: NonNullable<SafeUser>) { export function extractGuestFromUser(user: NonNullable<SafeUser>) {
@@ -75,6 +83,13 @@ export function add(...nums: (number | string | undefined)[]) {
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) { export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
if (isMember && "member" in roomRate && roomRate.member) { if (isMember && "member" in roomRate && roomRate.member) {
let publicRate
if (
"public" in roomRate &&
roomRate.public?.rateType === RateTypeEnum.Regular
) {
publicRate = roomRate.public
}
return { return {
perNight: { perNight: {
requested: roomRate.member.requestedPrice requested: roomRate.member.requestedPrice
@@ -86,6 +101,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
local: { local: {
currency: roomRate.member.localPrice.currency, currency: roomRate.member.localPrice.currency,
price: roomRate.member.localPrice.pricePerNight, price: roomRate.member.localPrice.pricePerNight,
regularPrice:
publicRate?.localPrice.pricePerStay ||
roomRate.member.localPrice.regularPricePerNight,
}, },
}, },
perStay: { perStay: {
@@ -98,6 +116,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
local: { local: {
currency: roomRate.member.localPrice.currency, currency: roomRate.member.localPrice.currency,
price: roomRate.member.localPrice.pricePerStay, 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"]) => { export const checkRoomProgress = (steps: RoomState["steps"]) => {
return Object.values(steps) return Object.values(steps)
.filter(Boolean) .filter(Boolean)
@@ -602,3 +300,373 @@ export function clearSessionStorage() {
} }
sessionStorage.removeItem(detailsStorageName) 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 { useContext } from "react"
import { create, useStore } from "zustand" import { create, useStore } from "zustand"
import { REDEMPTION } from "@/constants/booking"
import { getDefaultCountryFromLang } from "@/constants/languages" import { getDefaultCountryFromLang } from "@/constants/languages"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import {
sumPackages,
sumPackagesRequestedPrice,
} from "@/components/HotelReservation/utils"
import { DetailsContext } from "@/contexts/Details" import { DetailsContext } from "@/contexts/Details"
import { import {
add,
calcTotalPrice,
calculateCorporateChequePrice,
calculateVoucherPrice,
checkRoomProgress, checkRoomProgress,
extractGuestFromUser, extractGuestFromUser,
getRoomPrice, getRoomPrice,
@@ -27,7 +18,6 @@ import {
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast" import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
import type { Price } from "@/types/components/hotelReservation/price" import type { Price } from "@/types/components/hotelReservation/price"
import { CurrencyEnum } from "@/types/enums/currency"
import { StepEnum } from "@/types/enums/step" import { StepEnum } from "@/types/enums/step"
import type { import type {
DetailsState, DetailsState,
@@ -60,89 +50,37 @@ export function createDetailsStore(
lang: Lang lang: Lang
) { ) {
const isMember = !!user const isMember = !!user
const isRedemption = const nights = dt(initialState.booking.toDate).diff(
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION initialState.booking.fromDate,
"days"
const isVoucher = initialState.rooms.some(
(room) => "voucher" in room.roomRate
)
const isCorpChq = initialState.rooms.some(
(room) => "corporateCheque" in room.roomRate
) )
let initialTotalPrice: Price const initialRooms = initialState.rooms.map((room, idx) => {
const roomOneRoomRate = initialState.rooms[0].roomRate return {
const initialRoomRates = initialState.rooms.map((r) => r.roomRate) ...room,
if (isRedemption && "redemption" in roomOneRoomRate) { adults: initialState.booking.rooms[idx].adults,
initialTotalPrice = { childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
local: { bedType: room.bedType,
currency: CurrencyEnum.POINTS, breakfast:
price: roomOneRoomRate.redemption.localPrice.pointsPerStay, !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< const availableBeds = initialState.rooms.reduce<
DetailsState["availableBeds"] DetailsState["availableBeds"]
>((total, room) => { >((total, room) => {
@@ -162,7 +100,7 @@ export function createDetailsStore(
isSubmitting: false, isSubmitting: false,
isSummaryOpen: false, isSummaryOpen: false,
lastRoom: initialState.booking.rooms.length - 1, lastRoom: initialState.booking.rooms.length - 1,
rooms: initialState.rooms.map((room, idx) => { rooms: initialRooms.map((room, idx) => {
const steps: RoomState["steps"] = { const steps: RoomState["steps"] = {
[StepEnum.selectBed]: { [StepEnum.selectBed]: {
step: StepEnum.selectBed, step: StepEnum.selectBed,
@@ -235,9 +173,8 @@ export function createDetailsStore(
"days" "days"
) )
state.totalPrice = calcTotalPrice( state.totalPrice = getTotalPrice(
state.rooms, state.rooms.map((r) => r.room),
currentRoom.room.roomPrice.perStay.local.currency,
isMember, isMember,
nights nights
) )
@@ -275,9 +212,8 @@ export function createDetailsStore(
"days" "days"
) )
state.totalPrice = calcTotalPrice( state.totalPrice = getTotalPrice(
state.rooms, state.rooms.map((r) => r.room),
state.totalPrice.local.currency,
isMember, isMember,
nights nights
) )
@@ -307,9 +243,8 @@ export function createDetailsStore(
"days" "days"
) )
state.totalPrice = calcTotalPrice( state.totalPrice = getTotalPrice(
state.rooms, state.rooms.map((r) => r.room),
state.totalPrice.local.currency,
isMember, isMember,
nights nights
) )
@@ -368,9 +303,8 @@ export function createDetailsStore(
"days" "days"
) )
state.totalPrice = calcTotalPrice( state.totalPrice = getTotalPrice(
state.rooms, state.rooms.map((r) => r.room),
state.totalPrice.local.currency,
isMember, isMember,
nights nights
) )
@@ -390,27 +324,7 @@ export function createDetailsStore(
) )
}, },
}, },
room: { 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: "",
},
},
isComplete: false, isComplete: false,
steps, 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() { toggleSummaryOpen() {
return set( return set(
produce((state: DetailsState) => { produce((state: DetailsState) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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