Merged in feat/SW-1719-strikethrough-rates (pull request #2266)
Feat/SW-1719 strikethrough rates * feat(SW-1719): Strikethrough rate if logged in on regular rate cards * feat(SW-1719): Strikethrough rate if logged in on rate summary * feat(SW-1719): Strikethrough rate if logged in on mobile rate summary * feat(SW-1719): Strikethrough rate if logged in on enter details * feat(SW-1719): Strikethrough rate support for multiple rooms * feat(SW-1719): booking receipt fixes on confirmation page * feat(SW-1719): improve initial total price calculation * feat: harmonize enter details total price to use one and the same function Approved-by: Michael Zetterberg
This commit is contained in:
committed by
Michael Zetterberg
parent
e1ede52014
commit
85acd3453d
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
@@ -9,8 +12,6 @@ import { CancellationRuleEnum, ChildBedTypeEnum } from "@/constants/booking"
|
||||
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Link from "@/components/TempDesignSystem/Link"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import Breakfast from "./Breakfast"
|
||||
@@ -21,12 +22,13 @@ import styles from "./room.module.css"
|
||||
import type { BookingConfirmationReceiptRoomProps } from "@/types/components/hotelReservation/bookingConfirmation/receipt"
|
||||
|
||||
export default function ReceiptRoom({
|
||||
roomIndex,
|
||||
room,
|
||||
roomNumber,
|
||||
roomCount,
|
||||
}: BookingConfirmationReceiptRoomProps) {
|
||||
const intl = useIntl()
|
||||
const { room, currencyCode, isVatCurrency } = useBookingConfirmationStore(
|
||||
const { currencyCode, isVatCurrency } = useBookingConfirmationStore(
|
||||
(state) => ({
|
||||
room: state.rooms[roomIndex],
|
||||
currencyCode: state.currencyCode,
|
||||
isVatCurrency: state.isVatCurrency,
|
||||
})
|
||||
@@ -64,173 +66,199 @@ export default function ReceiptRoom({
|
||||
}
|
||||
|
||||
const guests = guestsParts.join(", ")
|
||||
const showDiscounted = room.rateDefinition.isMemberRate
|
||||
|
||||
return (
|
||||
<article className={styles.room}>
|
||||
<header className={styles.roomHeader}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>{room.name}</p>
|
||||
</Typography>
|
||||
{room.rateDefinition.isMemberRate ? (
|
||||
<div className={styles.memberPrice}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.red}>{room.formattedRoomCost}</p>
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{room.formattedRoomCost}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextMediumContrast}>{guests}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextMediumContrast}>
|
||||
{room.rateDefinition.cancellationText}
|
||||
</p>
|
||||
</Typography>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text" className={styles.termsLink}>
|
||||
<Link
|
||||
color="Text/Interactive/Secondary"
|
||||
href=""
|
||||
size="small"
|
||||
textDecoration="underline"
|
||||
variant="icon"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Reservation policy",
|
||||
})}
|
||||
<MaterialIcon icon="info" color="CurrentColor" />
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
(isVatCurrency
|
||||
? room.rateDefinition.cancellationText
|
||||
: room.rateDefinition.title) || ""
|
||||
}
|
||||
subtitle={
|
||||
room.rateDefinition.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDefinition.generalTerms?.map((info) => (
|
||||
<Typography
|
||||
key={info}
|
||||
className={styles.termsText}
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</span>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</header>
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<div className={styles.entry} key={feature.code}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{feature.description}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{formatPrice(intl, feature.totalPrice, feature.currency)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div className={styles.entry}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>{room.bedDescription}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{formatPrice(intl, 0, currencyCode)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
<>
|
||||
<div className={styles.room}>
|
||||
<div>
|
||||
{roomCount > 1 ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.roomTitle}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib.quantity }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Based on availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{formatPrice(intl, 0, currencyCode)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed.quantity,
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{room.name}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.additionalInformation}>
|
||||
<p>{guestsParts.join(", ")}</p>
|
||||
<p>{room.rateDefinition.cancellationText}</p>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.prices}>
|
||||
<p
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{room.formattedRoomCost}
|
||||
</p>
|
||||
{/* TODO: add original price, we're currently not receiving this value from API */}
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
{room.rateDefinition.generalTerms ? (
|
||||
<div className={styles.ctaWrapper}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
className={styles.termsButton}
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Reservation policy",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
(isVatCurrency
|
||||
? room.rateDefinition.cancellationText
|
||||
: room.rateDefinition.title) || ""
|
||||
}
|
||||
subtitle={
|
||||
room.rateDefinition.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM
|
||||
? intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDefinition.generalTerms?.map((info) => (
|
||||
<Typography
|
||||
key={info}
|
||||
className={styles.termsText}
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
>
|
||||
<span>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</span>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<div className={styles.entry} key={feature.code}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{feature.description}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{formatPrice(intl, feature.totalPrice, feature.currency)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<div className={styles.entry}>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>{room.bedDescription}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{formatPrice(intl, 0, currencyCode)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
<Breakfast
|
||||
breakfast={room.breakfast}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
guests={guests}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{childBedCrib ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib.quantity }
|
||||
)}
|
||||
</p>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.uiTextHighContrast}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Based on availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed.quantity,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Breakfast
|
||||
breakfast={room.breakfast}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
guests={guests}
|
||||
/>
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +1,56 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
gap: var(--Space-x15);
|
||||
overflow-y: auto;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.roomHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
.roomTitle,
|
||||
.additionalInformation {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.roomHeader :nth-child(n + 3) {
|
||||
grid-column: 1/-1;
|
||||
.terms {
|
||||
margin-top: var(--Space-x3);
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.memberPrice {
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
align-items: center;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Space-x1);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.termsLink {
|
||||
justify-self: flex-start;
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.terms {
|
||||
padding-top: var(--Spacing-x3);
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.terms .termsIcon {
|
||||
padding-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--Scandic-Brand-Scandic-Red);
|
||||
}
|
||||
|
||||
.uiTextHighContrast {
|
||||
color: var(--UI-Text-High-contrast);
|
||||
}
|
||||
|
||||
.uiTextMediumContrast {
|
||||
color: var(--UI-Text-Medium-contrast);
|
||||
.ctaWrapper {
|
||||
margin-top: var(--Space-x15);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
@@ -18,6 +19,7 @@ export default function TotalPrice() {
|
||||
const intl = useIntl()
|
||||
const { rooms, formattedTotalCost } = useBookingConfirmationStore(
|
||||
(state) => ({
|
||||
bookingCode: state.bookingCode,
|
||||
rooms: state.rooms,
|
||||
formattedTotalCost: state.formattedTotalCost,
|
||||
})
|
||||
@@ -25,35 +27,58 @@ export default function TotalPrice() {
|
||||
|
||||
const hasAllRoomsLoaded = rooms.every((room) => room)
|
||||
const bookingCode = rooms.find((room) => room?.bookingCode)?.bookingCode
|
||||
const isMemberRate = rooms.some((room) => room?.rateDefinition.isMemberRate)
|
||||
const showDiscounted = bookingCode || isMemberRate
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
<div className={styles.price}>
|
||||
<div className={styles.entry}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
{hasAllRoomsLoaded ? (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{formattedTotalCost}</p>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : (
|
||||
<SkeletonShimmer width={"25%"} />
|
||||
)}
|
||||
{/* TODO: Add approx price, we're currently not receiving this value from API */}
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
{hasAllRoomsLoaded ? (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{formattedTotalCost}
|
||||
</span>
|
||||
</Typography>
|
||||
) : (
|
||||
<SkeletonShimmer width={"25%"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.ctaWrapper}>
|
||||
{hasAllRoomsLoaded ? (
|
||||
<PriceDetails />
|
||||
) : (
|
||||
<div className={styles.priceDetailsLoader}>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bookingCode && <BookingCodeChip bookingCode={bookingCode} alignCenter />}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--Space-x15);
|
||||
}
|
||||
|
||||
.price button.btn {
|
||||
padding: 0;
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.priceDetailsLoader {
|
||||
padding-top: var(--Spacing-x1);
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.approxPrice {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
margin-top: var(--Space-x15);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useBookingConfirmationStore } from "@/stores/booking-confirmation"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import Room from "./Room"
|
||||
import TotalPrice from "./TotalPrice"
|
||||
@@ -13,31 +17,56 @@ import TotalPrice from "./TotalPrice"
|
||||
import styles from "./receipt.module.css"
|
||||
|
||||
export default function Receipt() {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const rooms = useBookingConfirmationStore((state) => state.rooms)
|
||||
const { rooms, fromDate, toDate } = useBookingConfirmationStore((state) => ({
|
||||
rooms: state.rooms,
|
||||
fromDate: state.fromDate,
|
||||
toDate: state.toDate,
|
||||
}))
|
||||
|
||||
const totalNights = dt(toDate).diff(fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights }
|
||||
)
|
||||
|
||||
const filteredRooms = rooms.filter(
|
||||
(room): room is NonNullable<typeof room> => !!room
|
||||
)
|
||||
|
||||
return (
|
||||
<section className={styles.receipt}>
|
||||
<Subtitle type="two">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Booking summary",
|
||||
})}
|
||||
</Subtitle>
|
||||
<header>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3 className={styles.heading}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Booking summary",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p className={styles.dates}>
|
||||
{dt(fromDate).locale(lang).format("ddd, D MMM")}
|
||||
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
{dt(toDate).locale(lang).format("ddd, D MMM")} ({nights})
|
||||
</p>
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
{rooms.map((room, idx) => (
|
||||
<div key={room ? room.confirmationNumber : `loader-${idx}`}>
|
||||
{rooms.length > 1 ? (
|
||||
<Body color="uiTextHighContrast" textTransform={"bold"}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: idx + 1 }
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<Room roomIndex={idx} />
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
|
||||
{filteredRooms.map((room, idx) => (
|
||||
<Room
|
||||
key={room ? room.confirmationNumber : `loader-${idx}`}
|
||||
room={room}
|
||||
roomNumber={idx + 1}
|
||||
roomCount={rooms.length}
|
||||
/>
|
||||
))}
|
||||
|
||||
<TotalPrice />
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
.receipt {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
}
|
||||
|
||||
.heading {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
justify-content: flex-start;
|
||||
color: var(--Text-Accent-Secondary);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
.receipt {
|
||||
padding: var(--Spacing-x3);
|
||||
padding: var(--Space-x3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
.wrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr 7.5em;
|
||||
|
||||
transition: 0.5s ease-in-out;
|
||||
grid-template-rows: 0fr auto;
|
||||
transition: all 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
@@ -10,24 +9,22 @@
|
||||
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
|
||||
align-items: flex-start;
|
||||
transition: 0.5s ease-in-out;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
transition: all 0.5s ease-in-out;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: block;
|
||||
border: none;
|
||||
background: none;
|
||||
border-width: 0;
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
transition: padding 0.5s ease-in-out;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] {
|
||||
@@ -51,35 +48,47 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-height: 50dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.summaryAccordion {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-color: var(--Primary-Light-On-Surface-Divider-subtle);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
border-bottom: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.priceLabel {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.seeDetails {
|
||||
margin-top: var(--Space-x15);
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
align-items: center;
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
padding: var(--Space-x2) 0 var(--Space-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { type PropsWithChildren, useEffect, useRef } from "react"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useEnterDetailsStore } from "@/stores/enter-details"
|
||||
|
||||
import { formId } from "@/components/HotelReservation/EnterDetails/Payment/PaymentClient"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
interface SummaryBottomSheetProps
|
||||
extends PropsWithChildren<{
|
||||
isMember: boolean
|
||||
}> { }
|
||||
|
||||
export default function SummaryBottomSheet({
|
||||
children,
|
||||
isMember,
|
||||
}: SummaryBottomSheetProps) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
const searchParams = useSearchParams()
|
||||
const errorCode = searchParams.get("errorCode")
|
||||
|
||||
const { isSummaryOpen, toggleSummaryOpen, totalPrice, isSubmitting } =
|
||||
useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
isSubmitting: state.isSubmitting,
|
||||
}))
|
||||
const {
|
||||
isSummaryOpen,
|
||||
toggleSummaryOpen,
|
||||
totalPrice,
|
||||
isSubmitting,
|
||||
rooms,
|
||||
} = useEnterDetailsStore((state) => ({
|
||||
isSummaryOpen: state.isSummaryOpen,
|
||||
toggleSummaryOpen: state.actions.toggleSummaryOpen,
|
||||
totalPrice: state.totalPrice,
|
||||
isSubmitting: state.isSubmitting,
|
||||
rooms: state.rooms,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSummaryOpen) {
|
||||
@@ -53,43 +69,77 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
}
|
||||
}, [isSummaryOpen, errorCode])
|
||||
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(r) => r && isBookingCodeRate(r.room.roomRate)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<button
|
||||
<ButtonRAC
|
||||
data-open={isSummaryOpen}
|
||||
onClick={toggleSummaryOpen}
|
||||
onPress={toggleSummaryOpen}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</Caption>
|
||||
<Subtitle>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See details",
|
||||
})}
|
||||
</Caption>
|
||||
</button>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span className={styles.priceLabel}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted && totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.regularPrice,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.seeDetails}>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See details",
|
||||
})}
|
||||
</span>
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
</Typography>
|
||||
</ButtonRAC>
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Primary"
|
||||
size="Large"
|
||||
type="submit"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
isDisabled={isSubmitting}
|
||||
isPending={isSubmitting}
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
form={formId}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Complete booking",
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function MobileSummary({ isMember }: SummaryProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SummaryBottomSheet>
|
||||
<SummaryBottomSheet isMember={isMember}>
|
||||
<div className={styles.wrapper}>
|
||||
<SummaryUI
|
||||
booking={booking}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { getMemberPrice, getPublicPrice } from "../utils"
|
||||
import Breakfast from "./Breakfast"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Room as RoomType } from "@/types/stores/enter-details"
|
||||
|
||||
interface RoomProps {
|
||||
room: RoomType
|
||||
roomNumber: number
|
||||
roomCount: number
|
||||
isMember: boolean
|
||||
isSpecialRate: boolean
|
||||
nightsCount: number
|
||||
defaultCurrency: CurrencyEnum
|
||||
}
|
||||
|
||||
export default function Room({
|
||||
room,
|
||||
roomNumber,
|
||||
roomCount,
|
||||
isMember,
|
||||
isSpecialRate,
|
||||
nightsCount,
|
||||
defaultCurrency,
|
||||
}: RoomProps) {
|
||||
const intl = useIntl()
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const publicPrice = getPublicPrice(room.roomRate)
|
||||
|
||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||
const isOrWillBecomeMember = !!(
|
||||
room.guest.join ||
|
||||
room.guest.membershipNo ||
|
||||
isFirstRoomMember
|
||||
)
|
||||
const showMemberPrice = !!(isOrWillBecomeMember && memberPrice)
|
||||
const showDiscounted = isSpecialRate || showMemberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
let rateDetails = room.rateDetails
|
||||
if (room.memberRateDetails) {
|
||||
if (isMember || room.guest.join) {
|
||||
rateDetails = room.memberRateDetails
|
||||
}
|
||||
}
|
||||
|
||||
const guests = guestsParts.join(", ")
|
||||
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
|
||||
<div>
|
||||
{roomCount > 1 ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p className={styles.roomTitle}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p>{room.roomType}</p>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<div className={styles.additionalInformation}>
|
||||
<p>{guests}</p>
|
||||
<p>{room.cancellationText}</p>
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.prices}>
|
||||
<p
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{showMemberPrice
|
||||
? formatPrice(
|
||||
intl,
|
||||
memberPrice.amount,
|
||||
memberPrice.currency
|
||||
)
|
||||
: formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</p>
|
||||
{showDiscounted && publicPrice ? (
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
publicPrice.amount,
|
||||
publicPrice.currency
|
||||
)}
|
||||
</s>
|
||||
) : null}
|
||||
</div>
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{rateDetails?.length ? (
|
||||
<div className={styles.ctaWrapper}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
className={styles.termsButton}
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rate details",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{rateDetails.map((info) => (
|
||||
<Typography key={info} variant="Body/Paragraph/mdRegular">
|
||||
<p className={styles.termsText}>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</p>
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<Typography key={feature.code} variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>{feature.description}</p>
|
||||
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
feature.localPrice.price,
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
))
|
||||
: null}
|
||||
|
||||
{room.bedType ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>{room.bedType.description}</p>
|
||||
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>{zeroPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{childBedCrib ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</p>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Based on availability",
|
||||
})}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{childBedExtraBed ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div className={styles.entry}>
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.prices}>
|
||||
<span className={styles.price}>
|
||||
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Breakfast
|
||||
adults={room.adults}
|
||||
breakfast={room.breakfast}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
guests={guests}
|
||||
nights={nightsCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x15);
|
||||
overflow-y: auto;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.roomTitle,
|
||||
.additionalInformation {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Space-x3);
|
||||
margin-bottom: var(--Space-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Space-x1);
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
margin-top: var(--Space-x15);
|
||||
}
|
||||
@@ -1,32 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment } from "react"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import BookingCodeChip from "@/components/BookingCodeChip"
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import { isBookingCodeRate } from "@/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import Modal from "@/components/Modal"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import Breakfast from "./Breakfast"
|
||||
import { mapToPrice } from "./mapToPrice"
|
||||
import Room from "./Room"
|
||||
import { getMemberPrice } from "./utils"
|
||||
|
||||
import styles from "./ui.module.css"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function SummaryUI({
|
||||
@@ -57,18 +55,6 @@ export default function SummaryUI({
|
||||
}
|
||||
}
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
if ("member" in roomRate && roomRate.member) {
|
||||
return {
|
||||
amount: roomRate.member.localPrice.pricePerStay,
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const roomOneGuest = rooms[0].room.guest
|
||||
const showSignupPromo =
|
||||
rooms.length === 1 &&
|
||||
@@ -85,10 +71,6 @@ export default function SummaryUI({
|
||||
"redemption" in roomOneRoomRate ||
|
||||
"voucher" in roomOneRoomRate
|
||||
|
||||
const isSameCurrency = totalPrice.requested
|
||||
? totalPrice.requested.currency === totalPrice.local.currency
|
||||
: false
|
||||
|
||||
const priceDetailsRooms = mapToPrice(rooms, isMember)
|
||||
const isAllCampaignRate = rooms.every(
|
||||
(room) => room.room.roomRate.rateDefinition.isCampaignRate
|
||||
@@ -96,6 +78,10 @@ export default function SummaryUI({
|
||||
const isAllBreakfastIncluded = rooms.every(
|
||||
(room) => room.room.roomRate.rateDefinition.breakfastIncluded
|
||||
)
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(r) => r && isBookingCodeRate(r.room.roomRate)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
@@ -127,306 +113,109 @@ export default function SummaryUI({
|
||||
/>
|
||||
</header>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
{rooms.map(({ room }, idx) => {
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
{rooms.map(({ room }, idx) => (
|
||||
<Room
|
||||
key={idx}
|
||||
defaultCurrency={defaultCurrency}
|
||||
room={room}
|
||||
roomNumber={idx + 1}
|
||||
roomCount={rooms.length}
|
||||
isMember={isMember}
|
||||
isSpecialRate={isSpecialRate}
|
||||
nightsCount={nights}
|
||||
/>
|
||||
))}
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
|
||||
const isFirstRoomMember = roomNumber === 1 && isMember
|
||||
const isOrWillBecomeMember = !!(
|
||||
room.guest.join ||
|
||||
room.guest.membershipNo ||
|
||||
isFirstRoomMember
|
||||
)
|
||||
const showMemberPrice = !!(isOrWillBecomeMember && memberPrice)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
const guests = guestsParts.join(", ")
|
||||
|
||||
let rateDetails = room.rateDetails
|
||||
if (room.memberRateDetails) {
|
||||
if (isMember || room.guest.join) {
|
||||
rateDetails = room.memberRateDetails
|
||||
}
|
||||
}
|
||||
|
||||
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
>
|
||||
<div>
|
||||
{rooms.length > 1 ? (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showMemberPrice ? "red" : "uiTextHighContrast"}>
|
||||
{showMemberPrice
|
||||
? formatPrice(
|
||||
intl,
|
||||
memberPrice.amount,
|
||||
memberPrice.currency
|
||||
)
|
||||
: formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">{guests}</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
{rateDetails ? (
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rate details",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={
|
||||
room.rateTitle ? room.rateTitle : room.cancellationText
|
||||
}
|
||||
subtitle={
|
||||
room.rateTitle ? room.cancellationText : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{rateDetails.map((info) => {
|
||||
return (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</Body>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
{room.roomFeatures
|
||||
? room.roomFeatures.map((feature) => (
|
||||
<div className={styles.entry} key={feature.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{feature.description}
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
feature.localPrice.price,
|
||||
feature.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
{room.bedType ? (
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">
|
||||
{room.bedType.description}
|
||||
</Body>
|
||||
|
||||
<Body color="uiTextHighContrast">{zeroPrice}</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Based on availability",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">{zeroPrice}</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">{zeroPrice}</Body>
|
||||
</div>
|
||||
) : null}
|
||||
<Breakfast
|
||||
adults={room.adults}
|
||||
breakfast={room.breakfast}
|
||||
breakfastIncluded={room.breakfastIncluded}
|
||||
guests={guests}
|
||||
nights={nights}
|
||||
/>
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<PriceDetailsModal
|
||||
bookingCode={booking.bookingCode}
|
||||
fromDate={booking.fromDate}
|
||||
isCampaignRate={isAllCampaignRate}
|
||||
rooms={priceDetailsRooms}
|
||||
toDate={booking.toDate}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
defaultCurrency={defaultCurrency}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold" data-testid="total-price">
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.local.regularPrice ? (
|
||||
<Caption color="uiTextMediumContrast" striked={true}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.regularPrice,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
{totalPrice.requested && !isSpecialRate && !isSameCurrency && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<p>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{totalPrice.requested ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.approxPrice}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency,
|
||||
totalPrice.requested.additionalPrice,
|
||||
totalPrice.requested.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.prices}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
data-testid="total-price"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted && totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.regularPrice,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<BookingCodeChip
|
||||
isCampaign={isAllCampaignRate}
|
||||
bookingCode={booking.bookingCode}
|
||||
isBreakfastIncluded={isAllBreakfastIncluded}
|
||||
alignCenter
|
||||
/>
|
||||
<Divider
|
||||
className={styles.bottomDivider}
|
||||
color="Border/Divider/Subtle"
|
||||
/>
|
||||
|
||||
<div className={styles.ctaWrapper}>
|
||||
<PriceDetailsModal
|
||||
bookingCode={booking.bookingCode}
|
||||
defaultCurrency={defaultCurrency}
|
||||
fromDate={booking.fromDate}
|
||||
rooms={priceDetailsRooms}
|
||||
toDate={booking.toDate}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BookingCodeChip
|
||||
isCampaign={isAllCampaignRate}
|
||||
bookingCode={booking.bookingCode}
|
||||
isBreakfastIncluded={isAllBreakfastIncluded}
|
||||
alignCenter
|
||||
/>
|
||||
<Divider className={styles.bottomDivider} color="Border/Divider/Subtle" />
|
||||
{showSignupPromo && roomOneMemberPrice && !isMember ? (
|
||||
<SignupPromoDesktop
|
||||
memberPrice={roomOneMemberPrice}
|
||||
|
||||
@@ -10,14 +10,21 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
|
||||
const pkgsSum = sumPackages(room.roomFeatures)
|
||||
|
||||
const roomWithoutPrice = {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
rateDefinition: {
|
||||
isMemberRate: false,
|
||||
},
|
||||
}
|
||||
|
||||
if ("corporateCheque" in room.roomRate) {
|
||||
if (
|
||||
room.roomRate.corporateCheque.localPrice.additionalPricePerStay ||
|
||||
pkgsSum.price
|
||||
) {
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
corporateCheque: {
|
||||
...room.roomRate.corporateCheque.localPrice,
|
||||
@@ -29,8 +36,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
corporateCheque: room.roomRate.corporateCheque.localPrice,
|
||||
},
|
||||
@@ -43,8 +49,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
pkgsSum.price
|
||||
) {
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
redemption: {
|
||||
...room.roomRate.redemption.localPrice,
|
||||
@@ -56,8 +61,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
redemption: room.roomRate.redemption.localPrice,
|
||||
},
|
||||
@@ -66,8 +70,7 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
|
||||
if ("voucher" in room.roomRate) {
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
voucher: room.roomRate.voucher,
|
||||
},
|
||||
@@ -79,22 +82,35 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
if ("member" in room.roomRate && room.roomRate.member) {
|
||||
if (pkgsSum.price) {
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
rateDefinition: {
|
||||
isMemberRate: true,
|
||||
},
|
||||
price: {
|
||||
regular: {
|
||||
...room.roomRate.member.localPrice,
|
||||
pricePerNight: room.roomRate.member.localPrice.pricePerNight,
|
||||
pricePerStay: room.roomRate.member.localPrice.pricePerStay,
|
||||
regularPricePerStay:
|
||||
(room.roomRate.public?.localPrice.pricePerStay ||
|
||||
room.roomRate.member.localPrice.pricePerStay) +
|
||||
pkgsSum.price,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
rateDefinition: {
|
||||
isMemberRate: true,
|
||||
},
|
||||
price: {
|
||||
regular: room.roomRate.member.localPrice,
|
||||
regular: {
|
||||
...room.roomRate.member.localPrice,
|
||||
regularPricePerStay:
|
||||
room.roomRate.public?.localPrice.pricePerStay ||
|
||||
room.roomRate.member.localPrice.pricePerStay,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -103,20 +119,14 @@ export function mapToPrice(rooms: RoomState[], isMember: boolean) {
|
||||
if ("public" in room.roomRate && room.roomRate.public) {
|
||||
if (pkgsSum.price) {
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
regular: {
|
||||
...room.roomRate.public.localPrice,
|
||||
pricePerNight: room.roomRate.public.localPrice.pricePerNight,
|
||||
pricePerStay: room.roomRate.public.localPrice.pricePerStay,
|
||||
},
|
||||
regular: room.roomRate.public.localPrice,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
...room,
|
||||
packages: room.roomFeatures,
|
||||
...roomWithoutPrice,
|
||||
price: {
|
||||
regular: room.roomRate.public.localPrice,
|
||||
},
|
||||
|
||||
@@ -53,9 +53,32 @@
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry > :last-child {
|
||||
.prices {
|
||||
justify-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through !important;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.approxPrice {
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.ctaWrapper {
|
||||
margin-top: var(--Space-x15);
|
||||
}
|
||||
|
||||
.total {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export function mapToPrice(room: Room) {
|
||||
currency: room.currencyCode,
|
||||
pricePerNight: room.roomPrice.perNight.local.price,
|
||||
pricePerStay: room.roomPrice.perStay.local.price,
|
||||
regularPricePerStay:
|
||||
room.roomPrice.perStay.local.regularPrice ||
|
||||
room.roomPrice.perStay.local.price,
|
||||
},
|
||||
}
|
||||
case PriceTypeEnum.points:
|
||||
|
||||
@@ -40,12 +40,12 @@ export default function MultiRoom(props: MultiRoomProps) {
|
||||
<div className={styles.container}>
|
||||
<div className={styles.roomsContainer}>
|
||||
{rooms.map((booking, index) => (
|
||||
<div
|
||||
<Room
|
||||
key={booking.confirmationNumber}
|
||||
className={styles.roomWrapper}
|
||||
>
|
||||
<Room {...props} booking={booking} roomNr={index + 1} />
|
||||
</div>
|
||||
{...props}
|
||||
booking={booking}
|
||||
roomNr={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,15 +22,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.roomWrapper {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.roomWrapper > * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.totalContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -49,7 +40,7 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.roomsContainer:has(> *:nth-child(3):last-child) {
|
||||
.roomsContainer:has(> *:nth-of-type(3):last-child) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.multiRoom {
|
||||
display: grid;
|
||||
grid-row: span 3;
|
||||
grid-template-rows: subgrid;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
@@ -5,9 +7,16 @@ import styles from "./row.module.css"
|
||||
interface RowProps {
|
||||
label: string
|
||||
value: string
|
||||
regularValue?: string
|
||||
isDiscounted?: boolean
|
||||
}
|
||||
|
||||
export default function BoldRow({ label, value }: RowProps) {
|
||||
export default function BoldRow({
|
||||
label,
|
||||
value,
|
||||
regularValue,
|
||||
isDiscounted = false,
|
||||
}: RowProps) {
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
@@ -16,8 +25,15 @@ export default function BoldRow({ label, value }: RowProps) {
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
{isDiscounted && regularValue ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<s className={styles.strikeThroughRate}>{regularValue}</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{value}</span>
|
||||
<span className={cx({ [styles.discounted]: isDiscounted })}>
|
||||
{value}
|
||||
</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,66 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./row.module.css"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
|
||||
interface RowProps {
|
||||
allPricesIsDiscounted: boolean
|
||||
label: string
|
||||
value: string
|
||||
price: Price
|
||||
}
|
||||
|
||||
export default function LargeRow({ label, value }: RowProps) {
|
||||
export default function LargeRow({
|
||||
allPricesIsDiscounted,
|
||||
label,
|
||||
price,
|
||||
}: RowProps) {
|
||||
const intl = useIntl()
|
||||
const totalPrice = formatPrice(
|
||||
intl,
|
||||
price.local.price,
|
||||
price.local.currency,
|
||||
price.local.additionalPrice,
|
||||
price.local.additionalPriceCurrency
|
||||
)
|
||||
const regularPrice = price.local.regularPrice
|
||||
? formatPrice(
|
||||
intl,
|
||||
price.local.regularPrice,
|
||||
price.local.currency,
|
||||
price.local.additionalPrice,
|
||||
price.local.additionalPriceCurrency
|
||||
)
|
||||
: null
|
||||
|
||||
const isDiscounted =
|
||||
allPricesIsDiscounted ||
|
||||
(price.local.regularPrice !== undefined &&
|
||||
price.local.regularPrice > price.local.price)
|
||||
return (
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<tr className={styles.row}>
|
||||
<td>
|
||||
<span>{label}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<span>{value}</span>
|
||||
</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
<td className={styles.price}>
|
||||
{isDiscounted && regularPrice ? (
|
||||
<>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>{regularPrice}</s>
|
||||
</Typography>
|
||||
</>
|
||||
) : null}
|
||||
<span className={cx({ [styles.discounted]: isDiscounted })}>
|
||||
{totalPrice}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,15 +16,18 @@ export interface RegularPriceType {
|
||||
currency: CurrencyEnum
|
||||
pricePerNight: number
|
||||
pricePerStay: number
|
||||
regularPricePerStay: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RegularPriceProps extends SharedPriceRowProps {
|
||||
isMemberRate: boolean
|
||||
price: RegularPriceType["regular"]
|
||||
}
|
||||
|
||||
export default function RegularPrice({
|
||||
bedType,
|
||||
isMemberRate,
|
||||
nights,
|
||||
packages,
|
||||
price,
|
||||
@@ -47,11 +50,21 @@ export default function RegularPrice({
|
||||
|
||||
const roomCharge = formatPrice(intl, price.pricePerStay, price.currency)
|
||||
|
||||
const regularPriceIsHigherThanPrice =
|
||||
price.regularPricePerStay > price.pricePerStay
|
||||
let regularCharge = undefined
|
||||
if (regularPriceIsHigherThanPrice) {
|
||||
regularCharge = formatPrice(intl, price.regularPricePerStay, price.currency)
|
||||
}
|
||||
const isDiscounted = isMemberRate || regularPriceIsHigherThanPrice
|
||||
|
||||
return (
|
||||
<>
|
||||
<BoldRow
|
||||
label={intl.formatMessage({ defaultMessage: "Room charge" })}
|
||||
value={roomCharge}
|
||||
regularValue={regularCharge}
|
||||
isDiscounted={isDiscounted}
|
||||
/>
|
||||
{nights > 1 ? (
|
||||
<RegularRow label={averagePriceTitle} value={avgeragePricePerNight} />
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.price {
|
||||
text-align: end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--Space-x1);
|
||||
}
|
||||
|
||||
.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
|
||||
.price .strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import BookingCodeRow from "./Row/BookingCode"
|
||||
import DiscountedRegularPriceRow from "./Row/DiscountedRegularPrice"
|
||||
import HeaderRow from "./Row/Header"
|
||||
import LargeRow from "./Row/Large"
|
||||
import CorporateChequePrice, {
|
||||
@@ -32,7 +30,8 @@ import type { BedTypeSchema } from "@/types/components/hotelReservation/enterDet
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { Child } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Package, Packages } from "@/types/requests/packages"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
type RoomPrice =
|
||||
| CorporateChequePriceType
|
||||
@@ -49,6 +48,7 @@ export interface Room {
|
||||
childrenInRoom: Child[] | undefined
|
||||
packages: Packages | null
|
||||
price: RoomPrice
|
||||
rateDefinition: Pick<RateDefinition, "isMemberRate">
|
||||
roomType: string
|
||||
}
|
||||
|
||||
@@ -86,11 +86,22 @@ export default function PriceDetailsTable({
|
||||
const departue = dt(toDate).locale(lang).format("ddd, D MMM")
|
||||
const duration = ` ${arrival} - ${departue} (${nightsMsg})`
|
||||
|
||||
const allRoomsPackages: Package[] = rooms
|
||||
.flatMap((r) => r.packages)
|
||||
.filter((r): r is Package => !!r)
|
||||
|
||||
const isAllBreakfastIncluded = rooms.every((room) => room.breakfastIncluded)
|
||||
|
||||
const allPricesIsDiscounted = rooms.every((room) => {
|
||||
if (!("regular" in room.price)) {
|
||||
return false
|
||||
}
|
||||
if (room.rateDefinition.isMemberRate) {
|
||||
return true
|
||||
}
|
||||
if (!room.price.regular) {
|
||||
return false
|
||||
}
|
||||
|
||||
return room.price.regular.pricePerStay > room.price.regular.pricePerStay
|
||||
})
|
||||
|
||||
return (
|
||||
<table className={styles.priceDetailsTable}>
|
||||
{rooms.map((room, idx) => {
|
||||
@@ -104,10 +115,12 @@ export default function PriceDetailsTable({
|
||||
}
|
||||
}
|
||||
|
||||
let isMemberRate = false
|
||||
let price: RegularPriceType["regular"] | undefined
|
||||
if ("regular" in room.price && room.price.regular) {
|
||||
price = room.price.regular
|
||||
currency = room.price.regular.currency
|
||||
isMemberRate = room.rateDefinition.isMemberRate
|
||||
}
|
||||
|
||||
let redemptionPrice: RedemptionPriceType["redemption"] | undefined
|
||||
@@ -153,6 +166,7 @@ export default function PriceDetailsTable({
|
||||
<RegularPrice
|
||||
bedType={room.bedType}
|
||||
packages={room.packages}
|
||||
isMemberRate={isMemberRate}
|
||||
nights={nights}
|
||||
price={price}
|
||||
/>
|
||||
@@ -197,20 +211,9 @@ export default function PriceDetailsTable({
|
||||
<VatRow totalPrice={totalPrice} vat={vat} />
|
||||
|
||||
<LargeRow
|
||||
allPricesIsDiscounted={allPricesIsDiscounted}
|
||||
label={intl.formatMessage({ defaultMessage: "Price including VAT" })}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
/>
|
||||
|
||||
<DiscountedRegularPriceRow
|
||||
currency={totalPrice.local.currency}
|
||||
packages={allRoomsPackages}
|
||||
regularPrice={totalPrice.local.regularPrice}
|
||||
price={totalPrice}
|
||||
/>
|
||||
|
||||
<BookingCodeRow
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||
import { mapToPrice } from "./mapToPrice"
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
"use client"
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import SignupPromoMobile from "@/components/HotelReservation/SignupPromo/Mobile"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||
import SummaryContent from "./Content"
|
||||
import { mapRate } from "./mapRate"
|
||||
import Summary from "./Summary"
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./mobileSummary.module.css"
|
||||
|
||||
@@ -23,7 +25,6 @@ export default function MobileSummary({
|
||||
isAllRoomsSelected,
|
||||
isUserLoggedIn,
|
||||
totalPriceToShow,
|
||||
showMemberDiscountBanner,
|
||||
}: MobileSummaryProps) {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
@@ -62,6 +63,7 @@ export default function MobileSummary({
|
||||
return () => {
|
||||
document.body.style.position = ""
|
||||
document.body.style.top = ""
|
||||
document.body.style.width = ""
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
@@ -82,50 +84,37 @@ export default function MobileSummary({
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSummaryOpen && (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
onClick={toggleSummaryOpen}
|
||||
/>
|
||||
)}
|
||||
{showMemberDiscountBanner ? (
|
||||
<div className={styles.signupPromoWrapper}>
|
||||
<SignupPromoMobile />
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<SummaryContent
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={isUserLoggedIn}
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<Summary
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={isUserLoggedIn}
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<button
|
||||
data-open={isSummaryOpen}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggleSummaryOpen()
|
||||
}}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>
|
||||
</div>
|
||||
<div className={styles.bottomSheet}>
|
||||
<ButtonRAC
|
||||
data-open={isSummaryOpen}
|
||||
onPress={toggleSummaryOpen}
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<span className={styles.priceLabel}>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</Caption>
|
||||
<Subtitle
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
className={styles.wrappedText}
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography variant="Title/Subtitle/lg">
|
||||
<span
|
||||
className={cx(styles.price, {
|
||||
[styles.discounted]: showDiscounted,
|
||||
})}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
@@ -134,27 +123,48 @@ export default function MobileSummary({
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See details",
|
||||
})}
|
||||
</Caption>
|
||||
</button>
|
||||
<Button
|
||||
intent="primary"
|
||||
theme="base"
|
||||
size="large"
|
||||
type="submit"
|
||||
fullWidth
|
||||
disabled={!isAllRoomsSelected}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted && totalPriceToShow.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.regularPrice,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.seeDetails}>
|
||||
<span>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "See details",
|
||||
})}
|
||||
</span>
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
color="CurrentColor"
|
||||
size={20}
|
||||
/>
|
||||
</span>
|
||||
</Typography>
|
||||
</ButtonRAC>
|
||||
<Button
|
||||
variant="Primary"
|
||||
color="Primary"
|
||||
size="Large"
|
||||
type="submit"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
isDisabled={!isAllRoomsSelected}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,12 @@ export function mapToPrice(
|
||||
const onlyMemberRate = !room.product.public && memberRate
|
||||
if ((isUserLoggedIn && isMainRoom && memberRate) || onlyMemberRate) {
|
||||
price = {
|
||||
regular: memberRate.localPrice,
|
||||
regular: {
|
||||
...memberRate.localPrice,
|
||||
regularPricePerStay:
|
||||
room.product.public?.localPrice.pricePerStay ||
|
||||
memberRate.localPrice.pricePerStay,
|
||||
},
|
||||
}
|
||||
} else if (room.product.public) {
|
||||
price = {
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 0fr 7.5em;
|
||||
|
||||
transition: 0.5s ease-in-out;
|
||||
grid-template-rows: 0fr auto;
|
||||
transition: all 0.5s ease-in-out;
|
||||
border-top: 1px solid var(--Base-Border-Subtle);
|
||||
background: var(--Base-Surface-Primary-light-Normal);
|
||||
align-content: end;
|
||||
z-index: var(--default-modal-z-index);
|
||||
|
||||
&[data-open="true"] {
|
||||
grid-template-rows: 1fr auto;
|
||||
|
||||
.bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-open="false"] .priceDetailsButton {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.signupPromoWrapper {
|
||||
@@ -28,46 +45,21 @@
|
||||
.bottomSheet {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x5);
|
||||
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
|
||||
align-items: flex-start;
|
||||
transition: 0.5s ease-in-out;
|
||||
max-width: var(--max-width-page);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
transition: all 0.5s ease-in-out;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: block;
|
||||
border: none;
|
||||
background: none;
|
||||
border-width: 0;
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
transition: padding 0.5s ease-in-out;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] {
|
||||
grid-template-rows: 1fr 7.5em;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .bottomSheet {
|
||||
grid-template-columns: 0fr auto;
|
||||
}
|
||||
|
||||
.wrapper[data-open="true"] .priceDetailsButton {
|
||||
animation: fadeOut 0.3s ease-out;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wrapper[data-open="false"] .priceDetailsButton {
|
||||
animation: fadeIn 0.8s ease-in;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.priceDetailsButton {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -84,30 +76,33 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.wrappedText {
|
||||
white-space: normal;
|
||||
.priceLabel {
|
||||
color: var(--Text-Default);
|
||||
}
|
||||
|
||||
.price {
|
||||
color: var(--Text-Default);
|
||||
|
||||
&.discounted {
|
||||
color: var(--Text-Accent-Primary);
|
||||
}
|
||||
}
|
||||
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
.seeDetails {
|
||||
margin-top: var(--Space-x15);
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
align-items: center;
|
||||
color: var(--Component-Button-Brand-Secondary-On-fill-Default);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
padding: var(--Space-x2) 0 var(--Space-x7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export function getMemberPrice(roomRate: RoomRate) {
|
||||
if ("member" in roomRate && roomRate.member) {
|
||||
return {
|
||||
amount: roomRate.member.localPrice.pricePerStay,
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isBookingCodeRate(product: Product) {
|
||||
if (
|
||||
"corporateCheque" in product ||
|
||||
@@ -188,6 +188,8 @@ export default function RateSummary() {
|
||||
mainRoomCurrency = rateProduct.public.localPrice.currency
|
||||
}
|
||||
|
||||
const showStrikedThroughPrice = bookingCode || isUserLoggedIn
|
||||
|
||||
// attribute data-footer-spacing used to add spacing
|
||||
// beneath footer to be able to show entire footer upon
|
||||
// scrolling down to the bottom of the page
|
||||
@@ -338,7 +340,8 @@ export default function RateSummary() {
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
{bookingCode && totalPriceToShow.local.regularPrice && (
|
||||
{showStrikedThroughPrice &&
|
||||
totalPriceToShow.local.regularPrice ? (
|
||||
<Caption
|
||||
textAlign="right"
|
||||
color="uiTextMediumContrast"
|
||||
@@ -350,7 +353,7 @@ export default function RateSummary() {
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
) : null}
|
||||
{totalPriceToShow.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
@@ -410,7 +413,6 @@ export default function RateSummary() {
|
||||
isAllRoomsSelected={isAllRoomsSelected}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
totalPriceToShow={totalPriceToShow}
|
||||
showMemberDiscountBanner={showMemberDiscountBanner}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { sumPackages } from "@/components/HotelReservation/utils"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
@@ -19,8 +20,10 @@ export function calculateTotalPrice(
|
||||
const roomNr = idx + 1
|
||||
const isMainRoom = roomNr === 1
|
||||
let rate
|
||||
let publicRate
|
||||
if (isUserLoggedIn && isMainRoom && room.product.member) {
|
||||
rate = room.product.member
|
||||
publicRate = room.product.public
|
||||
} else if (room.product.public) {
|
||||
rate = room.product.public
|
||||
}
|
||||
@@ -44,10 +47,16 @@ export function calculateTotalPrice(
|
||||
total.local.price =
|
||||
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
|
||||
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
if (rate.rateType === RateTypeEnum.Regular && publicRate) {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
rate.localPrice.regularPricePerStay +
|
||||
publicRate.localPrice.pricePerStay +
|
||||
packagesPrice.local
|
||||
} else {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
(rate.localPrice.regularPricePerStay ||
|
||||
rate.localPrice.pricePerStay) +
|
||||
packagesPrice.local
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,9 @@ export default function SelectedRoomPanel() {
|
||||
(total, pkg) => total + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
const selectedPackagesPricePerNight = selectedPackagesPrice / nights
|
||||
const selectedPackagesPricePerNight = Math.ceil(
|
||||
selectedPackagesPrice / nights
|
||||
)
|
||||
|
||||
const night = intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import BookingCodeFilter from "./BookingCodeFilter"
|
||||
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||
import RoomPackageFilter from "./RoomPackageFilter"
|
||||
|
||||
import styles from "./roomsHeader.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||
|
||||
export default function RoomsHeader() {
|
||||
const { isFetchingPackages, rooms, totalRooms } = useRoomContext()
|
||||
|
||||
@@ -78,9 +78,9 @@ export default function Regular({
|
||||
const isMainRoomLoggedInWithoutMember =
|
||||
isMainRoomAndLoggedIn && !product.member
|
||||
const noRateAvailable = !product.member && !product.public
|
||||
const hideStandardPrice = isMainRoomAndLoggedIn && !!member
|
||||
const isMemberRateActive = isMainRoomAndLoggedIn && !!member
|
||||
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
|
||||
const rateCode = hideStandardPrice ? member.rateCode : standard?.rateCode
|
||||
const rateCode = isMemberRateActive ? member.rateCode : standard?.rateCode
|
||||
if (
|
||||
noRateAvailable ||
|
||||
isMainRoomLoggedInWithoutMember ||
|
||||
@@ -133,10 +133,13 @@ export default function Regular({
|
||||
|
||||
let approximateStandardRatePrice = null
|
||||
if (standardPricePerNight) {
|
||||
const standardPriceUnit = isMemberRateActive
|
||||
? standard!.localPrice.currency
|
||||
: `${standard!.localPrice.currency}/${night}`
|
||||
rates.rate = {
|
||||
label: standardPriceMsg,
|
||||
price: standardPricePerNight.totalPrice,
|
||||
unit: `${standard!.localPrice.currency}/${night}`,
|
||||
unit: standardPriceUnit,
|
||||
}
|
||||
|
||||
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
|
||||
@@ -194,7 +197,7 @@ export default function Regular({
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
hidePublicRate={hideStandardPrice}
|
||||
isMemberRateActive={isMemberRateActive}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.receipt .hider {
|
||||
background-color: var(--Main-Grey-White);
|
||||
background-color: transparent;
|
||||
height: 150px;
|
||||
margin-top: -78px;
|
||||
top: -40px;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useEffect, useRef, useState } from "react"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { createDetailsStore } from "@/stores/enter-details"
|
||||
import {
|
||||
calcTotalPrice,
|
||||
checkIsSameBooking as checkIsSameBooking,
|
||||
clearSessionStorage,
|
||||
getTotalPrice,
|
||||
readFromSessionStorage,
|
||||
writeToSessionStorage,
|
||||
} from "@/stores/enter-details/helpers"
|
||||
@@ -18,7 +18,6 @@ import LoadingSpinner from "@/components/LoadingSpinner"
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
import type { DetailsStore } from "@/types/contexts/enter-details"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type { DetailsProviderProps } from "@/types/providers/enter-details"
|
||||
import type { InitialState, RoomState } from "@/types/stores/enter-details"
|
||||
@@ -174,25 +173,8 @@ export default function EnterDetailsProvider({
|
||||
|
||||
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
// We only extract the first room for its currency,
|
||||
// the value is the same for the rest of the rooms
|
||||
const product = filteredOutMissingRooms[0].room.roomRate
|
||||
let currency = CurrencyEnum.Unknown
|
||||
if ("corporateCheque" in product) {
|
||||
currency = CurrencyEnum.CC
|
||||
} else if ("redemption" in product) {
|
||||
currency = CurrencyEnum.POINTS
|
||||
} else if ("voucher" in product) {
|
||||
currency = CurrencyEnum.Voucher
|
||||
} else if ("public" in product && product.public) {
|
||||
currency = product.public.localPrice.currency
|
||||
} else if ("member" in product && product.member) {
|
||||
currency = product.member.localPrice.currency
|
||||
}
|
||||
|
||||
const totalPrice = calcTotalPrice(
|
||||
filteredOutMissingRooms,
|
||||
currency,
|
||||
const totalPrice = getTotalPrice(
|
||||
filteredOutMissingRooms.map((r) => r.room),
|
||||
!!user,
|
||||
nights
|
||||
)
|
||||
|
||||
@@ -1344,6 +1344,10 @@ export function selectRateRedirectURL(
|
||||
}
|
||||
searchParams.set(`room[${idx}].ratecode`, room.rateCode)
|
||||
searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode)
|
||||
} else {
|
||||
if (!searchParams.has("modifyRateIndex")) {
|
||||
searchParams.set("modifyRateIndex", idx.toString())
|
||||
}
|
||||
}
|
||||
if (room.bookingCode) {
|
||||
searchParams.set(`room[${idx}].bookingCode`, room.bookingCode)
|
||||
|
||||
@@ -8,12 +8,20 @@ import {
|
||||
|
||||
import { detailsStorageName } from "."
|
||||
|
||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||
import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { SelectRateBooking } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Package } from "@/types/requests/packages"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { PersistedState, RoomState } from "@/types/stores/enter-details"
|
||||
import type {
|
||||
CorporateChequeProduct,
|
||||
PriceProduct,
|
||||
RedemptionProduct,
|
||||
VoucherProduct,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { SafeUser } from "@/types/user"
|
||||
|
||||
export function extractGuestFromUser(user: NonNullable<SafeUser>) {
|
||||
@@ -75,6 +83,13 @@ export function add(...nums: (number | string | undefined)[]) {
|
||||
|
||||
export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
if (isMember && "member" in roomRate && roomRate.member) {
|
||||
let publicRate
|
||||
if (
|
||||
"public" in roomRate &&
|
||||
roomRate.public?.rateType === RateTypeEnum.Regular
|
||||
) {
|
||||
publicRate = roomRate.public
|
||||
}
|
||||
return {
|
||||
perNight: {
|
||||
requested: roomRate.member.requestedPrice
|
||||
@@ -86,6 +101,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
local: {
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
price: roomRate.member.localPrice.pricePerNight,
|
||||
regularPrice:
|
||||
publicRate?.localPrice.pricePerStay ||
|
||||
roomRate.member.localPrice.regularPricePerNight,
|
||||
},
|
||||
},
|
||||
perStay: {
|
||||
@@ -98,6 +116,9 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
local: {
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
price: roomRate.member.localPrice.pricePerStay,
|
||||
regularPrice:
|
||||
publicRate?.localPrice.pricePerStay ||
|
||||
roomRate.member.localPrice.regularPricePerStay,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -231,329 +252,6 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
|
||||
const totalPrice = roomRates.reduce<Price>(
|
||||
(total, roomRate, idx) => {
|
||||
const isMainRoom = idx === 0
|
||||
let rate
|
||||
if (isMainRoom && isMember && "member" in roomRate && roomRate.member) {
|
||||
rate = roomRate.member
|
||||
} else if ("public" in roomRate && roomRate.public) {
|
||||
rate = roomRate.public
|
||||
}
|
||||
// TODO: Handle other products?
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
total.local.currency = rate.localPrice.currency
|
||||
total.local.price = add(total.local.price, rate.localPrice.pricePerStay)
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
rate.localPrice.regularPricePerStay
|
||||
)
|
||||
}
|
||||
|
||||
if (rate.requestedPrice) {
|
||||
if (total.requested) {
|
||||
total.requested.price = add(
|
||||
total.requested.price,
|
||||
rate.requestedPrice.pricePerStay
|
||||
)
|
||||
} else {
|
||||
total.requested = {
|
||||
currency: rate.requestedPrice.currency,
|
||||
price: rate.requestedPrice.pricePerStay,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Unknown,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
if (totalPrice.local.regularPrice) {
|
||||
const totalPriceWithRegularPrice = roomRates.reduce(
|
||||
(total, roomRate, idx) => {
|
||||
const isMainRoom = idx === 0
|
||||
let rate
|
||||
if (isMainRoom && isMember && "member" in roomRate && roomRate.member) {
|
||||
rate = roomRate.member
|
||||
} else if ("public" in roomRate && roomRate.public) {
|
||||
rate = roomRate.public
|
||||
}
|
||||
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
total.local.regularPrice =
|
||||
total.local.regularPrice + rate.localPrice.regularPricePerStay
|
||||
} else {
|
||||
total.local.regularPrice =
|
||||
total.local.regularPrice + rate.localPrice.pricePerStay
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
...totalPrice,
|
||||
local: {
|
||||
...totalPrice.local,
|
||||
regularPrice: 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
totalPriceWithRegularPrice.local.price ===
|
||||
totalPriceWithRegularPrice.local.regularPrice
|
||||
) {
|
||||
totalPriceWithRegularPrice.local.regularPrice = 0
|
||||
}
|
||||
|
||||
return totalPriceWithRegularPrice
|
||||
}
|
||||
|
||||
return totalPrice
|
||||
}
|
||||
|
||||
export function calculateVoucherPrice(
|
||||
roomRates: RoomRate[],
|
||||
packages: Package[]
|
||||
) {
|
||||
return roomRates.reduce<Price>(
|
||||
(total, room) => {
|
||||
if (!("voucher" in room)) {
|
||||
return total
|
||||
}
|
||||
|
||||
const pkgsSum = sumPackages(packages)
|
||||
|
||||
return {
|
||||
local: {
|
||||
additionalPrice: pkgsSum.price,
|
||||
additionalPriceCurrency: pkgsSum.currency,
|
||||
currency: total.local.currency,
|
||||
price: total.local.price + room.voucher.numberOfVouchers,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Voucher,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function calculateCorporateChequePrice(roomRates: RoomRate[]) {
|
||||
return roomRates.reduce<Price>(
|
||||
(total, room) => {
|
||||
if (!("corporateCheque" in room)) {
|
||||
return total
|
||||
}
|
||||
|
||||
const rate = room.corporateCheque
|
||||
|
||||
total.local.price = add(
|
||||
total.local.price,
|
||||
rate.localPrice.numberOfCheques
|
||||
)
|
||||
|
||||
if (rate.localPrice.additionalPricePerStay) {
|
||||
total.local.additionalPrice = add(
|
||||
total.local.additionalPrice,
|
||||
rate.localPrice.additionalPricePerStay
|
||||
)
|
||||
}
|
||||
if (rate.localPrice.currency) {
|
||||
total.local.additionalPriceCurrency = rate.localPrice.currency
|
||||
}
|
||||
|
||||
if (rate.requestedPrice) {
|
||||
if (total.requested) {
|
||||
total.requested.price = add(
|
||||
total.requested.price,
|
||||
rate.requestedPrice.numberOfCheques
|
||||
)
|
||||
} else {
|
||||
total.requested = {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: rate.requestedPrice.numberOfCheques,
|
||||
}
|
||||
}
|
||||
|
||||
if (rate.requestedPrice.additionalPricePerStay) {
|
||||
total.requested.additionalPrice = add(
|
||||
total.requested.additionalPrice,
|
||||
rate.requestedPrice.additionalPricePerStay
|
||||
)
|
||||
}
|
||||
|
||||
if (rate.requestedPrice.currency) {
|
||||
total.requested.additionalPriceCurrency = rate.requestedPrice.currency
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function calcTotalPrice(
|
||||
rooms: RoomState[],
|
||||
currency: Price["local"]["currency"],
|
||||
isMember: boolean,
|
||||
nights: number
|
||||
) {
|
||||
return rooms.reduce<Price>(
|
||||
(acc, { room }, index) => {
|
||||
const isFirstRoomAndMember = index === 0 && isMember
|
||||
const join = Boolean(room.guest.join || room.guest.membershipNo)
|
||||
|
||||
const roomPrice = getRoomPrice(
|
||||
room.roomRate,
|
||||
isFirstRoomAndMember || join
|
||||
)
|
||||
|
||||
if (!roomPrice) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const isSpecialRate =
|
||||
"corporateCheque" in room.roomRate ||
|
||||
"redemption" in room.roomRate ||
|
||||
"voucher" in room.roomRate
|
||||
|
||||
const breakfastRequestedPrice = room.breakfast
|
||||
? (room.breakfast.requestedPrice?.price ?? 0)
|
||||
: 0
|
||||
const breakfastLocalPrice = room.breakfast
|
||||
? (room.breakfast.localPrice?.price ?? 0)
|
||||
: 0
|
||||
|
||||
const pkgsSum = sumPackages(room.roomFeatures)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
|
||||
|
||||
const breakfastRequestedTotalPrice =
|
||||
breakfastRequestedPrice * room.adults * nights
|
||||
if (roomPrice.perStay.requested) {
|
||||
if (!acc.requested) {
|
||||
acc.requested = {
|
||||
currency: roomPrice.perStay.requested.currency,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
if (isSpecialRate) {
|
||||
acc.requested.price = add(
|
||||
acc.requested.price,
|
||||
roomPrice.perStay.requested.price
|
||||
)
|
||||
|
||||
acc.requested.additionalPrice = add(
|
||||
breakfastRequestedTotalPrice,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
if (!acc.requested.additionalPriceCurrency) {
|
||||
if (roomPrice.perStay.requested.additionalPriceCurrency) {
|
||||
acc.requested.additionalPriceCurrency =
|
||||
roomPrice.perStay.requested.additionalPriceCurrency
|
||||
} else if (room.breakfast) {
|
||||
acc.requested.additionalPriceCurrency =
|
||||
room.breakfast.localPrice.currency
|
||||
} else if (pkgsSumRequested.currency) {
|
||||
acc.requested.additionalPriceCurrency = pkgsSumRequested.currency
|
||||
}
|
||||
}
|
||||
} else {
|
||||
acc.requested.price = add(
|
||||
acc.requested.price,
|
||||
roomPrice.perStay.requested.price,
|
||||
breakfastRequestedTotalPrice,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const breakfastLocalTotalPrice =
|
||||
breakfastLocalPrice * room.adults * nights
|
||||
|
||||
if (isSpecialRate) {
|
||||
acc.local.price = add(acc.local.price, roomPrice.perStay.local.price)
|
||||
|
||||
if (
|
||||
roomPrice.perStay.local.additionalPrice ||
|
||||
breakfastLocalTotalPrice ||
|
||||
pkgsSum.price
|
||||
) {
|
||||
acc.local.additionalPrice = add(
|
||||
acc.local.additionalPrice,
|
||||
roomPrice.perStay.local.additionalPrice,
|
||||
breakfastLocalTotalPrice,
|
||||
pkgsSum.price
|
||||
)
|
||||
}
|
||||
|
||||
if (!acc.local.additionalPriceCurrency) {
|
||||
if (roomPrice.perStay.local.additionalPriceCurrency) {
|
||||
acc.local.additionalPriceCurrency =
|
||||
roomPrice.perStay.local.additionalPriceCurrency
|
||||
} else if (room.breakfast) {
|
||||
acc.local.additionalPriceCurrency =
|
||||
room.breakfast.localPrice.currency
|
||||
} else if (pkgsSum.currency) {
|
||||
acc.local.additionalPriceCurrency = pkgsSum.currency
|
||||
}
|
||||
}
|
||||
} else {
|
||||
acc.local.price = add(
|
||||
acc.local.price,
|
||||
roomPrice.perStay.local.price,
|
||||
breakfastLocalTotalPrice,
|
||||
pkgsSum.price
|
||||
)
|
||||
|
||||
if (roomPrice.perStay.local.regularPrice) {
|
||||
acc.local.regularPrice = add(
|
||||
acc.local.regularPrice,
|
||||
roomPrice.perStay.local.regularPrice,
|
||||
breakfastLocalTotalPrice,
|
||||
pkgsSum.price
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{
|
||||
requested: undefined,
|
||||
local: { currency, price: 0 },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export const checkRoomProgress = (steps: RoomState["steps"]) => {
|
||||
return Object.values(steps)
|
||||
.filter(Boolean)
|
||||
@@ -602,3 +300,373 @@ export function clearSessionStorage() {
|
||||
}
|
||||
sessionStorage.removeItem(detailsStorageName)
|
||||
}
|
||||
|
||||
function getAdditionalPrice(
|
||||
total: Price,
|
||||
adults: number,
|
||||
breakfast: BreakfastPackage | false | undefined,
|
||||
nights: number,
|
||||
packages: Packages | null,
|
||||
additionalPrice = 0,
|
||||
additionalPriceCurrency?: CurrencyEnum | null | undefined
|
||||
) {
|
||||
const breakfastLocalPrice =
|
||||
(breakfast ? breakfast.localPrice.price : 0) * nights * adults
|
||||
const pkgsSum = sumPackages(packages)
|
||||
|
||||
total.local.additionalPrice = add(
|
||||
total.local.additionalPrice,
|
||||
additionalPrice,
|
||||
breakfastLocalPrice,
|
||||
pkgsSum.price
|
||||
)
|
||||
|
||||
if (!total.local.additionalPriceCurrency) {
|
||||
if (additionalPriceCurrency) {
|
||||
total.local.additionalPriceCurrency = additionalPriceCurrency
|
||||
} else if (breakfast && breakfast.localPrice.currency) {
|
||||
total.local.additionalPriceCurrency = breakfast.localPrice.currency
|
||||
} else if (pkgsSum.currency) {
|
||||
total.local.additionalPriceCurrency = pkgsSum.currency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestedAdditionalPrice(
|
||||
total: Price,
|
||||
adults: number,
|
||||
breakfast: BreakfastPackage | false | undefined,
|
||||
nights: number,
|
||||
packages: Packages | null,
|
||||
additionalPrice = 0,
|
||||
additionalPriceCurrency: CurrencyEnum | null | undefined
|
||||
) {
|
||||
if (!total.requested) {
|
||||
total.requested = {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const breakfastRequestedPrice =
|
||||
(breakfast ? breakfast.requestedPrice?.price || 0 : 0) * nights * adults
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(packages)
|
||||
|
||||
total.requested.additionalPrice = add(
|
||||
total.requested.additionalPrice,
|
||||
additionalPrice,
|
||||
breakfastRequestedPrice,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
if (!total.requested.additionalPriceCurrency) {
|
||||
if (additionalPriceCurrency) {
|
||||
total.requested.additionalPriceCurrency = additionalPriceCurrency
|
||||
} else if (pkgsSumRequested.currency) {
|
||||
total.requested.additionalPriceCurrency = pkgsSumRequested.currency
|
||||
} else if (breakfast && breakfast.requestedPrice) {
|
||||
total.requested.additionalPriceCurrency =
|
||||
breakfast.requestedPrice.currency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TRoom
|
||||
extends Pick<
|
||||
RoomState["room"],
|
||||
"adults" | "breakfast" | "guest" | "roomFeatures" | "roomRate"
|
||||
> {}
|
||||
|
||||
interface TRoomCorporateCheque extends TRoom {
|
||||
roomRate: CorporateChequeProduct
|
||||
}
|
||||
|
||||
export function getCorporateChequePrice(rooms: TRoom[], nights: number) {
|
||||
return rooms
|
||||
.filter(
|
||||
(room): room is TRoomCorporateCheque => "corporateCheque" in room.roomRate
|
||||
)
|
||||
.reduce<Price>(
|
||||
(total, room) => {
|
||||
const corporateCheque = room.roomRate.corporateCheque
|
||||
|
||||
total.local.price = add(
|
||||
total.local.price,
|
||||
corporateCheque.localPrice.numberOfCheques
|
||||
)
|
||||
|
||||
getAdditionalPrice(
|
||||
total,
|
||||
room.adults,
|
||||
room.breakfast,
|
||||
nights,
|
||||
room.roomFeatures,
|
||||
corporateCheque.localPrice.additionalPricePerStay,
|
||||
corporateCheque.localPrice.currency
|
||||
)
|
||||
|
||||
if (corporateCheque.requestedPrice) {
|
||||
getRequestedAdditionalPrice(
|
||||
total,
|
||||
room.adults,
|
||||
room.breakfast,
|
||||
nights,
|
||||
room.roomFeatures,
|
||||
corporateCheque.requestedPrice?.additionalPricePerStay,
|
||||
corporateCheque.requestedPrice?.currency
|
||||
)
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface TRoomVoucher extends TRoom {
|
||||
roomRate: VoucherProduct
|
||||
}
|
||||
|
||||
export function getVoucherPrice(rooms: TRoom[], nights: number) {
|
||||
return rooms
|
||||
.filter((room): room is TRoomVoucher => "voucher" in room.roomRate)
|
||||
.reduce<Price>(
|
||||
(total, room) => {
|
||||
const voucher = room.roomRate.voucher
|
||||
|
||||
total.local.price = add(total.local.price, voucher.numberOfVouchers)
|
||||
|
||||
getAdditionalPrice(
|
||||
total,
|
||||
room.adults,
|
||||
room.breakfast,
|
||||
nights,
|
||||
room.roomFeatures
|
||||
)
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Voucher,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface TRoomRedemption extends TRoom {
|
||||
roomRate: RedemptionProduct
|
||||
}
|
||||
|
||||
export function getRedemptionPrice(rooms: TRoom[], nights: number) {
|
||||
return rooms
|
||||
.filter((room): room is TRoomRedemption => "redemption" in room.roomRate)
|
||||
.reduce<Price>(
|
||||
(total, room) => {
|
||||
const redemption = room.roomRate.redemption
|
||||
|
||||
total.local.price = add(
|
||||
total.local.price,
|
||||
redemption.localPrice.pointsPerStay
|
||||
)
|
||||
|
||||
getAdditionalPrice(
|
||||
total,
|
||||
room.adults,
|
||||
room.breakfast,
|
||||
nights,
|
||||
room.roomFeatures,
|
||||
redemption.localPrice.additionalPricePerStay,
|
||||
redemption.localPrice.currency
|
||||
)
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
interface TRoomPriceProduct extends TRoom {
|
||||
roomRate: PriceProduct
|
||||
}
|
||||
|
||||
export function getRegularPrice(
|
||||
rooms: TRoom[],
|
||||
isMember: boolean,
|
||||
nights: number
|
||||
) {
|
||||
const totalPrice = rooms
|
||||
.filter(
|
||||
(room): room is TRoomPriceProduct =>
|
||||
"member" in room.roomRate || "public" in room.roomRate
|
||||
)
|
||||
.reduce<Price>(
|
||||
(total, room, idx) => {
|
||||
const isMainRoomAndMember = idx === 0 && isMember
|
||||
const join = Boolean(room.guest.join || room.guest.membershipNo)
|
||||
const getMemberRate = isMainRoomAndMember || join
|
||||
|
||||
const memberRate = "member" in room.roomRate && room.roomRate.member
|
||||
const publicRate = "public" in room.roomRate && room.roomRate.public
|
||||
|
||||
let rate
|
||||
if (getMemberRate && memberRate) {
|
||||
rate = memberRate
|
||||
} else if (publicRate) {
|
||||
rate = publicRate
|
||||
}
|
||||
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const breakfastLocalPrice =
|
||||
(room.breakfast ? room.breakfast.localPrice.price || 0 : 0) *
|
||||
nights *
|
||||
room.adults
|
||||
const pkgsSum = sumPackages(room.roomFeatures)
|
||||
const additionalCost = breakfastLocalPrice + pkgsSum.price
|
||||
|
||||
total.local.currency = rate.localPrice.currency
|
||||
total.local.price = add(
|
||||
total.local.price,
|
||||
rate.localPrice.pricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
|
||||
if (rate.requestedPrice) {
|
||||
if (!total.requested) {
|
||||
total.requested = {
|
||||
currency: rate.requestedPrice.currency,
|
||||
price: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const breakfastRequestedPrice =
|
||||
(room.breakfast ? (room.breakfast.requestedPrice?.price ?? 0) : 0) *
|
||||
nights *
|
||||
room.adults
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
|
||||
|
||||
total.requested.price = add(
|
||||
total.requested.price,
|
||||
rate.requestedPrice.pricePerStay,
|
||||
breakfastRequestedPrice,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
}
|
||||
|
||||
// Legend:
|
||||
// - total.local.price = Total Price = Black price, what the user pays
|
||||
// - total.local.regularPrice = Regular Price = Strikethrough price (could potentially be none)
|
||||
// - total.requested.price = Requested Price = EUR approx price
|
||||
|
||||
// We sometimes don't get all the required data to calculate the correct strikethrough total.
|
||||
// Therefore we try these different approach to get a number that is close
|
||||
// enough to the real number if all data would've been present.
|
||||
if (getMemberRate && memberRate) {
|
||||
if (publicRate) {
|
||||
// #1 Member price uses public price as strikethrough
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
publicRate.localPrice.pricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
} else if (memberRate.localPrice.regularPricePerStay) {
|
||||
// #2 Member price uses member regular price as strikethrough
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
memberRate.localPrice.regularPricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
} else {
|
||||
// #3 Member price uses member price as strikethrough
|
||||
// NOTE: If all rooms end up using this, no strikethrough price is shown.
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
memberRate.localPrice.pricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
}
|
||||
} else if (publicRate) {
|
||||
if (publicRate.localPrice.regularPricePerStay) {
|
||||
// #1 Public price uses public regular price as strikethrough
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
publicRate.localPrice.regularPricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
} else {
|
||||
// #2 Public price uses public price as strikethrough
|
||||
// NOTE: If all rooms end up using this, no strikethrough price is shown.
|
||||
total.local.regularPrice = add(
|
||||
total.local.regularPrice,
|
||||
publicRate.localPrice.pricePerStay,
|
||||
additionalCost
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// We cannot do anything, too much data is missing.
|
||||
return total
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: CurrencyEnum.Unknown,
|
||||
price: 0,
|
||||
regularPrice: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
totalPrice.local.regularPrice &&
|
||||
totalPrice.local.price >= totalPrice.local.regularPrice
|
||||
) {
|
||||
totalPrice.local.regularPrice = 0
|
||||
}
|
||||
|
||||
return totalPrice
|
||||
}
|
||||
|
||||
export function getTotalPrice(
|
||||
rooms: TRoom[],
|
||||
isMember: boolean,
|
||||
nights: number
|
||||
) {
|
||||
const hasCorpChqRates = rooms.some(
|
||||
(room) => "corporateCheque" in room.roomRate
|
||||
)
|
||||
if (hasCorpChqRates) {
|
||||
return getCorporateChequePrice(rooms, nights)
|
||||
}
|
||||
|
||||
const hasRedemptionRates = rooms.some((room) => "redemption" in room.roomRate)
|
||||
if (hasRedemptionRates) {
|
||||
return getRedemptionPrice(rooms, nights)
|
||||
}
|
||||
|
||||
const hasVoucherRates = rooms.some((room) => "voucher" in room.roomRate)
|
||||
if (hasVoucherRates) {
|
||||
return getVoucherPrice(rooms, nights)
|
||||
}
|
||||
|
||||
return getRegularPrice(rooms, isMember, nights)
|
||||
}
|
||||
|
||||
@@ -3,21 +3,12 @@ import { produce } from "immer"
|
||||
import { useContext } from "react"
|
||||
import { create, useStore } from "zustand"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { getDefaultCountryFromLang } from "@/constants/languages"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@/components/HotelReservation/utils"
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
|
||||
import {
|
||||
add,
|
||||
calcTotalPrice,
|
||||
calculateCorporateChequePrice,
|
||||
calculateVoucherPrice,
|
||||
checkRoomProgress,
|
||||
extractGuestFromUser,
|
||||
getRoomPrice,
|
||||
@@ -27,7 +18,6 @@ import {
|
||||
|
||||
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
@@ -60,89 +50,37 @@ export function createDetailsStore(
|
||||
lang: Lang
|
||||
) {
|
||||
const isMember = !!user
|
||||
const isRedemption =
|
||||
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION
|
||||
|
||||
const isVoucher = initialState.rooms.some(
|
||||
(room) => "voucher" in room.roomRate
|
||||
)
|
||||
const isCorpChq = initialState.rooms.some(
|
||||
(room) => "corporateCheque" in room.roomRate
|
||||
const nights = dt(initialState.booking.toDate).diff(
|
||||
initialState.booking.fromDate,
|
||||
"days"
|
||||
)
|
||||
|
||||
let initialTotalPrice: Price
|
||||
const roomOneRoomRate = initialState.rooms[0].roomRate
|
||||
const initialRoomRates = initialState.rooms.map((r) => r.roomRate)
|
||||
if (isRedemption && "redemption" in roomOneRoomRate) {
|
||||
initialTotalPrice = {
|
||||
local: {
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: roomOneRoomRate.redemption.localPrice.pointsPerStay,
|
||||
const initialRooms = initialState.rooms.map((room, idx) => {
|
||||
return {
|
||||
...room,
|
||||
adults: initialState.booking.rooms[idx].adults,
|
||||
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
|
||||
bedType: room.bedType,
|
||||
breakfast:
|
||||
!breakfastPackages.length || room.breakfastIncluded
|
||||
? (false as const)
|
||||
: undefined,
|
||||
guest:
|
||||
isMember && idx === 0
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: {
|
||||
...defaultGuestState,
|
||||
phoneNumberCC: getDefaultCountryFromLang(lang),
|
||||
},
|
||||
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
|
||||
specialRequest: {
|
||||
comment: "",
|
||||
},
|
||||
}
|
||||
if (roomOneRoomRate.redemption.localPrice.currency) {
|
||||
initialTotalPrice.local.additionalPriceCurrency =
|
||||
roomOneRoomRate.redemption.localPrice.currency
|
||||
}
|
||||
if (roomOneRoomRate.redemption.localPrice.additionalPricePerStay) {
|
||||
initialTotalPrice.local.additionalPrice =
|
||||
roomOneRoomRate.redemption.localPrice.additionalPricePerStay
|
||||
}
|
||||
} else if (isVoucher) {
|
||||
const pkgs = initialState.rooms.flatMap((room) => room.roomFeatures || [])
|
||||
initialTotalPrice = calculateVoucherPrice(initialRoomRates, pkgs)
|
||||
} else if (isCorpChq) {
|
||||
initialTotalPrice = calculateCorporateChequePrice(initialRoomRates)
|
||||
} else {
|
||||
initialTotalPrice = getTotalPrice(initialRoomRates, isMember)
|
||||
}
|
||||
|
||||
initialState.rooms.forEach((room) => {
|
||||
if (room.roomFeatures) {
|
||||
const pkgsSum = sumPackages(room.roomFeatures)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(room.roomFeatures)
|
||||
|
||||
if ("corporateCheque" in room.roomRate || "redemption" in room.roomRate) {
|
||||
initialTotalPrice.local.additionalPrice = add(
|
||||
initialTotalPrice.local.additionalPrice,
|
||||
pkgsSum.price
|
||||
)
|
||||
if (
|
||||
!initialTotalPrice.local.additionalPriceCurrency &&
|
||||
pkgsSum.currency
|
||||
) {
|
||||
initialTotalPrice.local.additionalPriceCurrency = pkgsSum.currency
|
||||
}
|
||||
|
||||
if (initialTotalPrice.requested) {
|
||||
initialTotalPrice.requested.additionalPrice = add(
|
||||
initialTotalPrice.requested.additionalPrice,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
if (
|
||||
!initialTotalPrice.requested.additionalPriceCurrency &&
|
||||
pkgsSumRequested.currency
|
||||
) {
|
||||
initialTotalPrice.requested.additionalPriceCurrency =
|
||||
pkgsSumRequested.currency
|
||||
}
|
||||
}
|
||||
} else if ("public" in room.roomRate) {
|
||||
if (initialTotalPrice.requested) {
|
||||
initialTotalPrice.requested.price = add(
|
||||
initialTotalPrice.requested.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
}
|
||||
|
||||
initialTotalPrice.local.price = add(
|
||||
initialTotalPrice.local.price,
|
||||
pkgsSum.price
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const initialTotalPrice: Price = getTotalPrice(initialRooms, isMember, nights)
|
||||
|
||||
const availableBeds = initialState.rooms.reduce<
|
||||
DetailsState["availableBeds"]
|
||||
>((total, room) => {
|
||||
@@ -162,7 +100,7 @@ export function createDetailsStore(
|
||||
isSubmitting: false,
|
||||
isSummaryOpen: false,
|
||||
lastRoom: initialState.booking.rooms.length - 1,
|
||||
rooms: initialState.rooms.map((room, idx) => {
|
||||
rooms: initialRooms.map((room, idx) => {
|
||||
const steps: RoomState["steps"] = {
|
||||
[StepEnum.selectBed]: {
|
||||
step: StepEnum.selectBed,
|
||||
@@ -235,9 +173,8 @@ export function createDetailsStore(
|
||||
"days"
|
||||
)
|
||||
|
||||
state.totalPrice = calcTotalPrice(
|
||||
state.rooms,
|
||||
currentRoom.room.roomPrice.perStay.local.currency,
|
||||
state.totalPrice = getTotalPrice(
|
||||
state.rooms.map((r) => r.room),
|
||||
isMember,
|
||||
nights
|
||||
)
|
||||
@@ -275,9 +212,8 @@ export function createDetailsStore(
|
||||
"days"
|
||||
)
|
||||
|
||||
state.totalPrice = calcTotalPrice(
|
||||
state.rooms,
|
||||
state.totalPrice.local.currency,
|
||||
state.totalPrice = getTotalPrice(
|
||||
state.rooms.map((r) => r.room),
|
||||
isMember,
|
||||
nights
|
||||
)
|
||||
@@ -307,9 +243,8 @@ export function createDetailsStore(
|
||||
"days"
|
||||
)
|
||||
|
||||
state.totalPrice = calcTotalPrice(
|
||||
state.rooms,
|
||||
state.totalPrice.local.currency,
|
||||
state.totalPrice = getTotalPrice(
|
||||
state.rooms.map((r) => r.room),
|
||||
isMember,
|
||||
nights
|
||||
)
|
||||
@@ -368,9 +303,8 @@ export function createDetailsStore(
|
||||
"days"
|
||||
)
|
||||
|
||||
state.totalPrice = calcTotalPrice(
|
||||
state.rooms,
|
||||
state.totalPrice.local.currency,
|
||||
state.totalPrice = getTotalPrice(
|
||||
state.rooms.map((r) => r.room),
|
||||
isMember,
|
||||
nights
|
||||
)
|
||||
@@ -390,27 +324,7 @@ export function createDetailsStore(
|
||||
)
|
||||
},
|
||||
},
|
||||
room: {
|
||||
...room,
|
||||
adults: initialState.booking.rooms[idx].adults,
|
||||
childrenInRoom: initialState.booking.rooms[idx].childrenInRoom,
|
||||
bedType: room.bedType,
|
||||
breakfast:
|
||||
!breakfastPackages.length || room.breakfastIncluded
|
||||
? false
|
||||
: undefined,
|
||||
guest:
|
||||
isMember && idx === 0
|
||||
? deepmerge(defaultGuestState, extractGuestFromUser(user))
|
||||
: {
|
||||
...defaultGuestState,
|
||||
phoneNumberCC: getDefaultCountryFromLang(lang),
|
||||
},
|
||||
roomPrice: getRoomPrice(room.roomRate, isMember && idx === 0),
|
||||
specialRequest: {
|
||||
comment: "",
|
||||
},
|
||||
},
|
||||
room,
|
||||
isComplete: false,
|
||||
steps,
|
||||
}
|
||||
@@ -429,14 +343,6 @@ export function createDetailsStore(
|
||||
})
|
||||
)
|
||||
},
|
||||
setTotalPrice(totalPrice) {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
state.totalPrice.requested = totalPrice.requested
|
||||
state.totalPrice.local = totalPrice.local
|
||||
})
|
||||
)
|
||||
},
|
||||
toggleSummaryOpen() {
|
||||
return set(
|
||||
produce((state: DetailsState) => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Room } from "@/types/stores/booking-confirmation"
|
||||
|
||||
export interface BookingConfirmationReceiptRoomProps {
|
||||
roomIndex: number
|
||||
room: Room
|
||||
roomNumber: number
|
||||
roomCount: number
|
||||
}
|
||||
|
||||
@@ -4,5 +4,4 @@ export interface MobileSummaryProps {
|
||||
isAllRoomsSelected: boolean
|
||||
isUserLoggedIn: boolean
|
||||
totalPriceToShow: Price
|
||||
showMemberDiscountBanner: boolean
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ export type InitialState = {
|
||||
export interface DetailsState {
|
||||
actions: {
|
||||
setIsSubmitting: (isSubmitting: boolean) => void
|
||||
setTotalPrice: (totalPrice: Price) => void
|
||||
toggleSummaryOpen: () => void
|
||||
updateSeachParamString: (searchParamString: string) => void
|
||||
addPreSubmitCallback: (name: string, callback: () => void) => void
|
||||
|
||||
@@ -34,13 +34,13 @@ export const Default: Story = {
|
||||
paymentTerm: 'PAY NOW',
|
||||
rate: {
|
||||
label: 'Standard Price',
|
||||
price: '198',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1980',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
memberRate: {
|
||||
label: 'Member Price',
|
||||
price: '190',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1900',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
approximateRate: {
|
||||
price: '198',
|
||||
@@ -49,8 +49,8 @@ export const Default: Story = {
|
||||
},
|
||||
omnibusRate: {
|
||||
label: 'Lowest past price (last 30 days)',
|
||||
price: '169',
|
||||
unit: 'EUR',
|
||||
price: '1690',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
rateTermDetails: [
|
||||
{
|
||||
@@ -70,13 +70,13 @@ export const Selected: Story = {
|
||||
paymentTerm: 'PAY NOW',
|
||||
rate: {
|
||||
label: 'Standard Price',
|
||||
price: '198',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1980',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
memberRate: {
|
||||
label: 'Member Price',
|
||||
price: '190',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1900',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
approximateRate: {
|
||||
price: '198',
|
||||
@@ -92,7 +92,7 @@ export const Selected: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const HidePublicRate: Story = {
|
||||
export const MemberRateActive: Story = {
|
||||
args: {
|
||||
name: 'regular',
|
||||
value: 'regular',
|
||||
@@ -100,20 +100,20 @@ export const HidePublicRate: Story = {
|
||||
paymentTerm: 'PAY NOW',
|
||||
rate: {
|
||||
label: 'Standard Price',
|
||||
price: '198',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1980',
|
||||
unit: 'SEK',
|
||||
},
|
||||
memberRate: {
|
||||
label: 'Member Price',
|
||||
price: '190',
|
||||
unit: 'EUR/NIGHT',
|
||||
price: '1900',
|
||||
unit: 'SEK/NIGHT',
|
||||
},
|
||||
approximateRate: {
|
||||
price: '198',
|
||||
price: '190',
|
||||
label: 'Approx.',
|
||||
unit: 'EUR',
|
||||
},
|
||||
hidePublicRate: true,
|
||||
isMemberRateActive: true,
|
||||
rateTermDetails: [
|
||||
{
|
||||
title: 'Rate definition 1',
|
||||
|
||||
@@ -17,7 +17,7 @@ interface RegularRateCardProps {
|
||||
memberRate?: Rate
|
||||
omnibusRate?: Rate
|
||||
approximateRate?: Rate
|
||||
hidePublicRate?: boolean
|
||||
isMemberRateActive?: boolean
|
||||
handleChange: () => void
|
||||
rateTermDetails: RateTermDetails[]
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export default function RegularRateCard({
|
||||
omnibusRate,
|
||||
rate,
|
||||
memberRate,
|
||||
hidePublicRate,
|
||||
isMemberRateActive,
|
||||
handleChange,
|
||||
rateTermDetails,
|
||||
}: RegularRateCardProps) {
|
||||
@@ -97,7 +97,7 @@ export default function RegularRateCard({
|
||||
</div>
|
||||
</header>
|
||||
<div>
|
||||
{!hidePublicRate && rate ? (
|
||||
{!isMemberRateActive && rate ? (
|
||||
<div className={styles.rateRow}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<p>{rate.label}</p>
|
||||
@@ -118,15 +118,29 @@ export default function RegularRateCard({
|
||||
<p>{memberRate.label}</p>
|
||||
</Typography>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<p>
|
||||
<span>
|
||||
{`${memberRate.price} `}
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span>{memberRate.unit}</span>
|
||||
</Typography>
|
||||
</p>
|
||||
</span>
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
{isMemberRateActive && rate ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<div
|
||||
className={`${styles.rateRow} ${styles.strikeThroughRate}`}
|
||||
>
|
||||
<s>
|
||||
{`${rate.price} `}
|
||||
<Typography variant="Tag/sm">
|
||||
<span>{rate.unit}</span>
|
||||
</Typography>
|
||||
</s>
|
||||
</div>
|
||||
</Typography>
|
||||
) : null}
|
||||
{approximateRate ? (
|
||||
<div className={`${styles.rateRow} ${styles.approximateRate}`}>
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
|
||||
@@ -90,6 +90,13 @@ label:not(:has(.radio:checked)) .checkIcon {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--Space-x1);
|
||||
|
||||
&.strikeThroughRate {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: end;
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.highlightedRate {
|
||||
|
||||
Reference in New Issue
Block a user