From 85acd3453d37ca3281fb066e30bcb8bd91107d27 Mon Sep 17 00:00:00 2001 From: "Simon.Emanuelsson" Date: Fri, 13 Jun 2025 12:01:16 +0000 Subject: [PATCH] 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 --- .../Receipt/Room/index.tsx | 340 +++++---- .../Receipt/Room/room.module.css | 64 +- .../Receipt/TotalPrice/index.tsx | 57 +- .../Receipt/TotalPrice/totalPrice.module.css | 29 +- .../BookingConfirmation/Receipt/index.tsx | 73 +- .../Receipt/receipt.module.css | 17 +- .../Mobile/BottomSheet/bottomSheet.module.css | 83 +- .../Summary/Mobile/BottomSheet/index.tsx | 120 ++- .../EnterDetails/Summary/Mobile/index.tsx | 2 +- .../{ => Room}/Breakfast/breakfast.module.css | 0 .../Summary/UI/{ => Room}/Breakfast/index.tsx | 0 .../EnterDetails/Summary/UI/Room/index.tsx | 305 ++++++++ .../Summary/UI/Room/room.module.css | 57 ++ .../EnterDetails/Summary/UI/index.tsx | 409 +++------- .../EnterDetails/Summary/UI/mapToPrice.ts | 58 +- .../EnterDetails/Summary/UI/ui.module.css | 27 +- .../EnterDetails/Summary/UI/utils.ts | 25 + .../MyStay/PriceDetails/mapToPrice.ts | 3 + .../MyStay/Rooms/MultiRoom/index.tsx | 10 +- .../Rooms/MultiRoom/multiRoom.module.css | 11 +- .../MyStay/Rooms/MultiRoom/room.module.css | 3 + .../PriceDetailsTable/Row/Bold.tsx | 20 +- .../Row/DiscountedRegularPrice.tsx | 51 -- .../PriceDetailsTable/Row/Large.tsx | 67 +- .../PriceDetailsTable/Row/Price/Regular.tsx | 13 + .../PriceDetailsTable/Row/row.module.css | 13 + .../PriceDetailsTable/index.tsx | 43 +- .../MobileSummary/Content/index.tsx | 193 +++++ .../Content/summaryContent.module.css | 59 ++ .../RateSummary/MobileSummary/Room/index.tsx | 277 +++++++ .../MobileSummary/Room/room.module.css | 57 ++ .../RateSummary/MobileSummary/Summary.tsx | 2 +- .../RateSummary/MobileSummary/index.tsx | 146 ++-- .../RateSummary/MobileSummary/mapToPrice.ts | 7 +- .../MobileSummary/mobileSummary.module.css | 107 ++- .../{isBookingCodeRate.ts => utils.ts} | 13 + .../RoomsContainer/RateSummary/index.tsx | 8 +- .../RoomsContainer/RateSummary/utils.ts | 13 +- .../SelectedRoomPanel/index.tsx | 4 +- .../Rooms/RoomsHeader/index.tsx | 3 +- .../RoomsList/RoomListItem/Rates/Regular.tsx | 11 +- .../SidePanel/sidePanel.module.css | 2 +- .../providers/EnterDetailsProvider.tsx | 24 +- .../server/routers/hotels/utils.ts | 4 + .../stores/enter-details/helpers.ts | 716 ++++++++++-------- .../scandic-web/stores/enter-details/index.ts | 164 +--- .../bookingConfirmation/receipt.ts | 6 +- .../selectRate/rateSummary.ts | 1 - .../scandic-web/types/stores/enter-details.ts | 1 - .../RateCard/Regular/Regular.stories.tsx | 34 +- .../lib/components/RateCard/Regular/index.tsx | 24 +- .../components/RateCard/rate-card.module.css | 7 + 52 files changed, 2403 insertions(+), 1380 deletions(-) rename apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/{ => Room}/Breakfast/breakfast.module.css (100%) rename apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/{ => Room}/Breakfast/index.tsx (100%) create mode 100644 apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/Room/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/Room/room.module.css create mode 100644 apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/utils.ts delete mode 100644 apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/DiscountedRegularPrice.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Content/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Content/summaryContent.module.css create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/index.tsx create mode 100644 apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/room.module.css rename apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/{isBookingCodeRate.ts => utils.ts} (56%) diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/index.tsx index 2d4c2e14e..835eb59ec 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/index.tsx @@ -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 ( -
-
- -

{room.name}

-
- {room.rateDefinition.isMemberRate ? ( -
- -

{room.formattedRoomCost}

-
-
- ) : ( - -

- {room.formattedRoomCost} -

-
- )} - -

{guests}

-
- -

- {room.rateDefinition.cancellationText} -

-
- - - {intl.formatMessage({ - defaultMessage: "Reservation policy", - })} - - - - } - title={ - (isVatCurrency - ? room.rateDefinition.cancellationText - : room.rateDefinition.title) || "" - } - subtitle={ - room.rateDefinition.cancellationRule === - CancellationRuleEnum.CancellableBefore6PM - ? intl.formatMessage({ - defaultMessage: "Pay later", - }) - : intl.formatMessage({ - defaultMessage: "Pay now", - }) - } - > -
- {room.rateDefinition.generalTerms?.map((info) => ( - - - - {info} - - - ))} -
-
-
- {room.roomFeatures - ? room.roomFeatures.map((feature) => ( -
-
- -

- {feature.description} -

-
-
- - -

- {formatPrice(intl, feature.totalPrice, feature.currency)} -

-
-
- )) - : null} -
- -

{room.bedDescription}

-
- -

- {formatPrice(intl, 0, currencyCode)} -

-
-
- {childBedCrib ? ( -
-
- -

+ <> +

+
+ {roomCount > 1 ? ( + +

{intl.formatMessage( { - defaultMessage: "Crib (child) × {count}", - }, - { count: childBedCrib.quantity } - )} -

-
- -

- {intl.formatMessage({ - defaultMessage: "Based on availability", - })} -

-
-
- -

- {formatPrice(intl, 0, currencyCode)} -

-
-
- ) : null} - {childBedExtraBed ? ( -
-
- -

- {intl.formatMessage( - { - defaultMessage: "Extra bed (child) × {count}", + defaultMessage: "Room {roomIndex}", }, { - count: childBedExtraBed.quantity, + roomIndex: roomNumber, } )}

+ ) : null} +
+
+ +

{room.name}

+
+ +
+

{guestsParts.join(", ")}

+

{room.rateDefinition.cancellationText}

+
+
+
+ +
+

+ {room.formattedRoomCost} +

+ {/* TODO: add original price, we're currently not receiving this value from API */} +
+
+ {room.rateDefinition.generalTerms ? ( +
+ + {intl.formatMessage({ + defaultMessage: "Reservation policy", + })} + + + } + title={ + (isVatCurrency + ? room.rateDefinition.cancellationText + : room.rateDefinition.title) || "" + } + subtitle={ + room.rateDefinition.cancellationRule === + CancellationRuleEnum.CancellableBefore6PM + ? intl.formatMessage({ + defaultMessage: "Pay later", + }) + : intl.formatMessage({ + defaultMessage: "Pay now", + }) + } + > +
+ {room.rateDefinition.generalTerms?.map((info) => ( + + + + {info} + + + ))} +
+
+
+ ) : null} +
+ + {room.roomFeatures + ? room.roomFeatures.map((feature) => ( +
+
+ +

+ {feature.description} +

+
+
+ + +

+ {formatPrice(intl, feature.totalPrice, feature.currency)} +

+
+
+ )) + : null} +
+ +

{room.bedDescription}

+

{formatPrice(intl, 0, currencyCode)}

- ) : null} - -
+ + {childBedCrib ? ( + +
+
+

+ {intl.formatMessage( + { + defaultMessage: "Crib (child) × {count}", + }, + { count: childBedCrib.quantity } + )} +

+ +

+ {intl.formatMessage({ + defaultMessage: "Based on availability", + })} +

+
+
+
+ + {formatPrice(intl, 0, currencyCode)} + +
+
+
+ ) : null} + {childBedExtraBed ? ( + +
+
+

+ {intl.formatMessage( + { + defaultMessage: "Extra bed (child) × {count}", + }, + { + count: childBedExtraBed.quantity, + } + )} +

+
+
+ + {formatPrice(intl, 0, currencyCode)} + +
+
+
+ ) : null} + + + + ) } diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/room.module.css b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/room.module.css index 68e58ada4..078305d0c 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/room.module.css +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/Room/room.module.css @@ -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); } diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/index.tsx index 551d8bb38..dee720bf3 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/index.tsx @@ -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 ( <>
- -

- {intl.formatMessage({ - defaultMessage: "Total price", - })} -

-
- {hasAllRoomsLoaded ? ( - -

{formattedTotalCost}

+
+ +

+ {intl.formatMessage( + { + defaultMessage: "Total price (incl VAT)", + }, + { + b: (str) => ( + + {str} + + ), + } + )} +

- ) : ( - - )} + {/* TODO: Add approx price, we're currently not receiving this value from API */} +
+
+ {hasAllRoomsLoaded ? ( + + + {formattedTotalCost} + + + ) : ( + + )} +
+
+
{hasAllRoomsLoaded ? ( ) : ( -
- -
+ )}
+ {bookingCode && } ) diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/totalPrice.module.css b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/totalPrice.module.css index c4f76f9c6..b8cb276e3 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/totalPrice.module.css +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/TotalPrice/totalPrice.module.css @@ -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); } diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/index.tsx b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/index.tsx index 50d026cc0..29ef63257 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/index.tsx +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/index.tsx @@ -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 => !!room + ) return (
- - {intl.formatMessage({ - defaultMessage: "Booking summary", - })} - +
+ +

+ {intl.formatMessage({ + defaultMessage: "Booking summary", + })} +

+
+ +

+ {dt(fromDate).locale(lang).format("ddd, D MMM")} + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + {dt(toDate).locale(lang).format("ddd, D MMM")} ({nights}) +

+
+
- {rooms.map((room, idx) => ( -
- {rooms.length > 1 ? ( - - {intl.formatMessage( - { - defaultMessage: "Room {roomIndex}", - }, - { roomIndex: idx + 1 } - )} - - ) : null} - -
+ + + {filteredRooms.map((room, idx) => ( + ))} diff --git a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css index 097ced210..27caa4c87 100644 --- a/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css +++ b/apps/scandic-web/components/HotelReservation/BookingConfirmation/Receipt/receipt.module.css @@ -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); } } diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/bottomSheet.module.css b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/bottomSheet.module.css index 763237464..1a3ce34cf 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/bottomSheet.module.css +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/bottomSheet.module.css @@ -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); } } diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx index a637f4d9d..e66547283 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/Mobile/BottomSheet/index.tsx @@ -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 (
{children}
- + + + {intl.formatMessage({ + defaultMessage: "Total price", + })} + + + + + {formatPrice( + intl, + totalPrice.local.price, + totalPrice.local.currency, + totalPrice.local.additionalPrice, + totalPrice.local.additionalPriceCurrency + )} + + + {showDiscounted && totalPrice.local.regularPrice ? ( + + + {formatPrice( + intl, + totalPrice.local.regularPrice, + totalPrice.local.currency + )} + + + ) : null} + + + + + {intl.formatMessage({ + defaultMessage: "See details", + })} + + + + + + } + title={room.cancellationText} + > +
+ {rateDetails.map((info) => ( + +

+ + {info} +

+
+ ))} +
+ +
+ ) : null} +
+ + {room.roomFeatures + ? room.roomFeatures.map((feature) => ( + +
+

{feature.description}

+ +
+ + {formatPrice( + intl, + feature.localPrice.price, + feature.localPrice.currency + )} + +
+
+
+ )) + : null} + + {room.bedType ? ( + +
+

{room.bedType.description}

+ +
+ {zeroPrice} +
+
+
+ ) : null} + + {childBedCrib ? ( + +
+
+

+ {intl.formatMessage( + { + defaultMessage: "Crib (child) × {count}", + }, + { count: childBedCrib } + )} +

+ +

+ {intl.formatMessage({ + defaultMessage: "Based on availability", + })} +

+
+
+
+ + {formatPrice(intl, 0, room.roomPrice.perStay.local.currency)} + +
+
+
+ ) : null} + + {childBedExtraBed ? ( + +
+

+ {intl.formatMessage( + { + defaultMessage: "Extra bed (child) × {count}", + }, + { + count: childBedExtraBed, + } + )} +

+
+ + {formatPrice(intl, 0, room.roomPrice.perStay.local.currency)} + +
+
+
+ ) : null} + + + + + + + ) +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/Room/room.module.css b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/Room/room.module.css new file mode 100644 index 000000000..085bec231 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/Room/room.module.css @@ -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); +} diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx index ad805af80..24788bfe2 100644 --- a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/index.tsx @@ -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 (
@@ -127,306 +113,109 @@ export default function SummaryUI({ /> - {rooms.map(({ room }, idx) => { - const roomNumber = idx + 1 - const adults = room.adults - const childrenInRoom = room.childrenInRoom + {rooms.map(({ room }, idx) => ( + + ))} - 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.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 ( - -
-
- {rooms.length > 1 ? ( - - {intl.formatMessage( - { - defaultMessage: "Room {roomIndex}", - }, - { - roomIndex: roomNumber, - } - )} - - ) : null} -
- {room.roomType} - - {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 - )} - -
- {guests} - - {room.cancellationText} - - {rateDetails ? ( - - {intl.formatMessage({ - defaultMessage: "Rate details", - })} - - - } - title={ - room.rateTitle ? room.rateTitle : room.cancellationText - } - subtitle={ - room.rateTitle ? room.cancellationText : undefined - } - > -
- {rateDetails.map((info) => { - return ( - - - {info} - - ) - })} -
-
- ) : null} -
- {room.roomFeatures - ? room.roomFeatures.map((feature) => ( -
-
- - {feature.description} - -
- - - {formatPrice( - intl, - feature.localPrice.price, - feature.localPrice.currency - )} - -
- )) - : null} - {room.bedType ? ( -
- - {room.bedType.description} - - - {zeroPrice} -
- ) : null} - {childBedCrib ? ( -
-
- - {intl.formatMessage( - { - defaultMessage: "Crib (child) × {count}", - }, - { count: childBedCrib } - )} - - - {intl.formatMessage({ - defaultMessage: "Based on availability", - })} - -
- {zeroPrice} -
- ) : null} - {childBedExtraBed ? ( -
-
- - {intl.formatMessage( - { - defaultMessage: "Extra bed (child) × {count}", - }, - { - count: childBedExtraBed, - } - )} - -
- {zeroPrice} -
- ) : null} - -
- -
- ) - })} -
+
- - {intl.formatMessage( - { - defaultMessage: "Total price (incl VAT)", - }, - { b: (str) => {str} } - )} - - -
-
- - {formatPrice( - intl, - totalPrice.local.price, - totalPrice.local.currency, - totalPrice.local.additionalPrice, - totalPrice.local.additionalPriceCurrency - )} - - {totalPrice.local.regularPrice ? ( - - {formatPrice( - intl, - totalPrice.local.regularPrice, - totalPrice.local.currency - )} - - ) : null} - {totalPrice.requested && !isSpecialRate && !isSameCurrency && ( - + +

{intl.formatMessage( { - defaultMessage: "Approx. {value}", + defaultMessage: "Total price (incl VAT)", }, { - value: formatPrice( - intl, - totalPrice.requested.price, - totalPrice.requested.currency + b: (str) => ( + + {str} + ), } )} - - )} +

+
+ {totalPrice.requested ? ( + +

+ {intl.formatMessage( + { + defaultMessage: "Approx. {value}", + }, + { + value: formatPrice( + intl, + totalPrice.requested.price, + totalPrice.requested.currency, + totalPrice.requested.additionalPrice, + totalPrice.requested.additionalPriceCurrency + ), + } + )} +

+
+ ) : null} +
+
+ + + {formatPrice( + intl, + totalPrice.local.price, + totalPrice.local.currency, + totalPrice.local.additionalPrice, + totalPrice.local.additionalPriceCurrency + )} + + + {showDiscounted && totalPrice.local.regularPrice ? ( + + + {formatPrice( + intl, + totalPrice.local.regularPrice, + totalPrice.local.currency + )} + + + ) : null}
- - + +
+ +
+ + {showSignupPromo && roomOneMemberPrice && !isMember ? ( :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 { diff --git a/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/utils.ts b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/utils.ts new file mode 100644 index 000000000..a3ef58802 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/EnterDetails/Summary/UI/utils.ts @@ -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 +} diff --git a/apps/scandic-web/components/HotelReservation/MyStay/PriceDetails/mapToPrice.ts b/apps/scandic-web/components/HotelReservation/MyStay/PriceDetails/mapToPrice.ts index 44e6506b3..46113f0e9 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/PriceDetails/mapToPrice.ts +++ b/apps/scandic-web/components/HotelReservation/MyStay/PriceDetails/mapToPrice.ts @@ -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: diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/index.tsx b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/index.tsx index 821ce78b6..f3764aebd 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/index.tsx +++ b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/index.tsx @@ -40,12 +40,12 @@ export default function MultiRoom(props: MultiRoomProps) {
{rooms.map((booking, index) => ( -
- -
+ {...props} + booking={booking} + roomNr={index + 1} + /> ))}
diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/multiRoom.module.css b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/multiRoom.module.css index 973b4489c..760dce0b6 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/multiRoom.module.css +++ b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/multiRoom.module.css @@ -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); } diff --git a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/room.module.css b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/room.module.css index 99dd992af..f16b95254 100644 --- a/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/room.module.css +++ b/apps/scandic-web/components/HotelReservation/MyStay/Rooms/MultiRoom/room.module.css @@ -110,6 +110,9 @@ @media (min-width: 768px) { .multiRoom { + display: grid; + grid-row: span 3; + grid-template-rows: subgrid; padding: 0; } } diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Bold.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Bold.tsx index be3d431df..ee8fc8636 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Bold.tsx +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Bold.tsx @@ -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 ( @@ -16,8 +25,15 @@ export default function BoldRow({ label, value }: RowProps) { + {isDiscounted && regularValue ? ( + + {regularValue} + + ) : null} - {value} + + {value} + diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/DiscountedRegularPrice.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/DiscountedRegularPrice.tsx deleted file mode 100644 index 378585eac..000000000 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/DiscountedRegularPrice.tsx +++ /dev/null @@ -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 ( - - - - - - {price} - - - - - - ) -} diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Large.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Large.tsx index fdfeefc95..2c36960f8 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Large.tsx +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Large.tsx @@ -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 ( - - - + + + {label} - - - - - {value} - - - + + + {isDiscounted && regularPrice ? ( + <> + + {regularPrice} + + + ) : null} + + {totalPrice} + + + + ) } diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Price/Regular.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Price/Regular.tsx index ff0526037..3ae29e13f 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Price/Regular.tsx +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/Price/Regular.tsx @@ -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 ( <> {nights > 1 ? ( diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/row.module.css b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/row.module.css index 9963233a4..b38fa52e7 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/row.module.css +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/Row/row.module.css @@ -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); } diff --git a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx index ffe1df8f0..9a4cb9ad5 100644 --- a/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx +++ b/apps/scandic-web/components/HotelReservation/PriceDetailsModal/PriceDetailsTable/index.tsx @@ -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 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 ( {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({ @@ -197,20 +211,9 @@ export default function PriceDetailsTable({ - - ({ + 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 => !!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 ( +
+
+
+ +

+ {intl.formatMessage({ + defaultMessage: "Booking summary", + })} +

+
+ + + +
+ +

+ {dt(booking.fromDate).locale(lang).format("ddd, D MMM")} + + {/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */} + {dt(booking.toDate).locale(lang).format("ddd, D MMM")} ({nights}) +

+
+
+ + + + {filteredRooms.map((room, idx) => ( + + ))} + +
+
+
+ +

+ {intl.formatMessage( + { + defaultMessage: "Total price (incl VAT)", + }, + { + b: (str) => ( + + {str} + + ), + } + )} +

+
+ {totalPrice.requested ? ( + +

+ {intl.formatMessage( + { + defaultMessage: "Approx. {value}", + }, + { + value: formatPrice( + intl, + totalPrice.requested.price, + totalPrice.requested.currency, + totalPrice.requested.additionalPrice, + totalPrice.requested.additionalPriceCurrency + ), + } + )} +

+
+ ) : null} +
+
+ + + {formatPrice( + intl, + totalPrice.local.price, + totalPrice.local.currency, + totalPrice.local.additionalPrice, + totalPrice.local.additionalPriceCurrency + )} + + + {showDiscounted && totalPrice.local.regularPrice ? ( + + + {formatPrice( + intl, + totalPrice.local.regularPrice, + totalPrice.local.currency + )} + + + ) : null} +
+
+ + +
+ {!isMember && memberPrice ? ( + + ) : null} +
+ ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Content/summaryContent.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Content/summaryContent.module.css new file mode 100644 index 000000000..bbc85e092 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Content/summaryContent.module.css @@ -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); +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/index.tsx new file mode 100644 index 000000000..f9908f8bc --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/index.tsx @@ -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.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 ( + <> +
+
+ {roomCount > 1 ? ( + +

+ {intl.formatMessage( + { + defaultMessage: "Room {roomIndex}", + }, + { + roomIndex: roomNumber, + } + )} +

+
+ ) : null} +
+
+ +

{room.roomType}

+
+ +
+

{guestsParts.join(", ")}

+

{room.cancellationText}

+
+
+
+ +
+

+ {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 + )} +

+ {showDiscounted && room.roomPrice.perStay.local.price ? ( + + {formatPrice( + intl, + room.roomPrice.perStay.local.price, + room.roomPrice.perStay.local.currency + )} + + ) : null} +
+
+
+ {room.rateDetails?.length ? ( +
+ + {intl.formatMessage({ + defaultMessage: "Rate details", + })} + + + } + title={room.cancellationText} + > +
+ {room.rateDetails.map((info) => ( + +

+ + {info} +

+
+ ))} +
+
+
+ ) : null} +
+ + {childBedCrib ? ( + +
+
+

+ {intl.formatMessage( + { + defaultMessage: "Crib (child) × {count}", + }, + { count: childBedCrib } + )} +

+ +

+ {intl.formatMessage({ + defaultMessage: "Based on availability", + })} +

+
+
+
+ + {formatPrice(intl, 0, room.roomPrice.perStay.local.currency)} + +
+
+
+ ) : null} + {childBedExtraBed ? ( + +
+
+

+ {intl.formatMessage( + { + defaultMessage: "Extra bed (child) × {count}", + }, + { + count: childBedExtraBed, + } + )} +

+ +

+ {intl.formatMessage({ + defaultMessage: "Based on availability", + })} +

+
+
+
+ + {formatPrice(intl, 0, room.roomPrice.perStay.local.currency)} + +
+
+
+ ) : null} + {roomPackages?.map((pkg) => ( + +
+

{pkg.description}

+
+ + {formatPrice( + intl, + pkg.localPrice.price, + pkg.localPrice.currency + )} + +
+
+
+ ))} +
+ + + ) +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/room.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/room.module.css new file mode 100644 index 000000000..085bec231 --- /dev/null +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Room/room.module.css @@ -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); +} diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx index a2b207e0f..8f046d0dc 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/Summary.tsx @@ -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" diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx index a26817511..3f49e9044 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/index.tsx @@ -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 && ( -
- + + + {formatPrice( intl, @@ -134,27 +123,48 @@ export default function MobileSummary({ totalPriceToShow.local.additionalPrice, totalPriceToShow.local.additionalPriceCurrency )} - - - - - + + + {showDiscounted && totalPriceToShow.local.regularPrice ? ( + + + {formatPrice( + intl, + totalPriceToShow.local.regularPrice, + totalPriceToShow.local.currency + )} + + + ) : null} + + + + + {intl.formatMessage({ + defaultMessage: "See details", + })} + + + + + + - + ) } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapToPrice.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapToPrice.ts index 81b8bc594..08057f94b 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapToPrice.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mapToPrice.ts @@ -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 = { diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mobileSummary.module.css b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mobileSummary.module.css index f6e50e596..0daf39bb1 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mobileSummary.module.css +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/mobileSummary.module.css @@ -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); } } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/isBookingCodeRate.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils.ts similarity index 56% rename from apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/isBookingCodeRate.ts rename to apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils.ts index cf946a3d0..c18caec68 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/isBookingCodeRate.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/MobileSummary/utils.ts @@ -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 || diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx index d3cda8b0f..7c58de316 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/index.tsx @@ -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 )} - {bookingCode && totalPriceToShow.local.regularPrice && ( + {showStrikedThroughPrice && + totalPriceToShow.local.regularPrice ? (
+ +
+ + + {intl.formatMessage({ defaultMessage: "Total price", })} -
- {intl.formatMessage({ - defaultMessage: "See details", - })} - - )} + ) : null} {totalPriceToShow.requested ? ( {intl.formatMessage( @@ -410,7 +413,6 @@ export default function RateSummary() { isAllRoomsSelected={isAllRoomsSelected} isUserLoggedIn={isUserLoggedIn} totalPriceToShow={totalPriceToShow} - showMemberDiscountBanner={showMemberDiscountBanner} /> diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts index 3d4dfef0b..e41d54e8b 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/RateSummary/utils.ts @@ -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 } diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx index 78ae4e17d..3fb431093 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/MultiRoomWrapper/SelectedRoomPanel/index.tsx @@ -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", diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx index da28e05ac..b0b46514c 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsHeader/index.tsx @@ -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() diff --git a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Regular.tsx b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Regular.tsx index d0afc3e17..2ba1be463 100644 --- a/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Regular.tsx +++ b/apps/scandic-web/components/HotelReservation/SelectRate/RoomsContainer/Rooms/RoomsList/RoomListItem/Rates/Regular.tsx @@ -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} diff --git a/apps/scandic-web/components/HotelReservation/SidePanel/sidePanel.module.css b/apps/scandic-web/components/HotelReservation/SidePanel/sidePanel.module.css index 47fce51d6..a56f2e575 100644 --- a/apps/scandic-web/components/HotelReservation/SidePanel/sidePanel.module.css +++ b/apps/scandic-web/components/HotelReservation/SidePanel/sidePanel.module.css @@ -20,7 +20,7 @@ } .receipt .hider { - background-color: var(--Main-Grey-White); + background-color: transparent; height: 150px; margin-top: -78px; top: -40px; diff --git a/apps/scandic-web/providers/EnterDetailsProvider.tsx b/apps/scandic-web/providers/EnterDetailsProvider.tsx index 1b4dbac04..e3d965937 100644 --- a/apps/scandic-web/providers/EnterDetailsProvider.tsx +++ b/apps/scandic-web/providers/EnterDetailsProvider.tsx @@ -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 ) diff --git a/apps/scandic-web/server/routers/hotels/utils.ts b/apps/scandic-web/server/routers/hotels/utils.ts index 9e22fb9b2..08056cf85 100644 --- a/apps/scandic-web/server/routers/hotels/utils.ts +++ b/apps/scandic-web/server/routers/hotels/utils.ts @@ -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) diff --git a/apps/scandic-web/stores/enter-details/helpers.ts b/apps/scandic-web/stores/enter-details/helpers.ts index 063ef322d..c1b756bab 100644 --- a/apps/scandic-web/stores/enter-details/helpers.ts +++ b/apps/scandic-web/stores/enter-details/helpers.ts @@ -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) { @@ -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( - (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( - (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( - (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( - (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( + (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( + (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( + (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( + (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) +} diff --git a/apps/scandic-web/stores/enter-details/index.ts b/apps/scandic-web/stores/enter-details/index.ts index c06f1afc1..1eeb964df 100644 --- a/apps/scandic-web/stores/enter-details/index.ts +++ b/apps/scandic-web/stores/enter-details/index.ts @@ -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) => { diff --git a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/receipt.ts b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/receipt.ts index 09985fa83..cb0743510 100644 --- a/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/receipt.ts +++ b/apps/scandic-web/types/components/hotelReservation/bookingConfirmation/receipt.ts @@ -1,3 +1,7 @@ +import type { Room } from "@/types/stores/booking-confirmation" + export interface BookingConfirmationReceiptRoomProps { - roomIndex: number + room: Room + roomNumber: number + roomCount: number } diff --git a/apps/scandic-web/types/components/hotelReservation/selectRate/rateSummary.ts b/apps/scandic-web/types/components/hotelReservation/selectRate/rateSummary.ts index 5c4a7db79..2c8a370d0 100644 --- a/apps/scandic-web/types/components/hotelReservation/selectRate/rateSummary.ts +++ b/apps/scandic-web/types/components/hotelReservation/selectRate/rateSummary.ts @@ -4,5 +4,4 @@ export interface MobileSummaryProps { isAllRoomsSelected: boolean isUserLoggedIn: boolean totalPriceToShow: Price - showMemberDiscountBanner: boolean } diff --git a/apps/scandic-web/types/stores/enter-details.ts b/apps/scandic-web/types/stores/enter-details.ts index 03adbf1d6..79c0f6536 100644 --- a/apps/scandic-web/types/stores/enter-details.ts +++ b/apps/scandic-web/types/stores/enter-details.ts @@ -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 diff --git a/packages/design-system/lib/components/RateCard/Regular/Regular.stories.tsx b/packages/design-system/lib/components/RateCard/Regular/Regular.stories.tsx index 4fd2f73e8..3fe347452 100644 --- a/packages/design-system/lib/components/RateCard/Regular/Regular.stories.tsx +++ b/packages/design-system/lib/components/RateCard/Regular/Regular.stories.tsx @@ -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', diff --git a/packages/design-system/lib/components/RateCard/Regular/index.tsx b/packages/design-system/lib/components/RateCard/Regular/index.tsx index 7dfe483f2..bf50ae152 100644 --- a/packages/design-system/lib/components/RateCard/Regular/index.tsx +++ b/packages/design-system/lib/components/RateCard/Regular/index.tsx @@ -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({
- {!hidePublicRate && rate ? ( + {!isMemberRateActive && rate ? (

{rate.label}

@@ -118,15 +118,29 @@ export default function RegularRateCard({

{memberRate.label}

-

+ {`${memberRate.price} `} {memberRate.unit} -

+
) : null} + {isMemberRateActive && rate ? ( + +
+ + {`${rate.price} `} + + {rate.unit} + + +
+
+ ) : null} {approximateRate ? (
diff --git a/packages/design-system/lib/components/RateCard/rate-card.module.css b/packages/design-system/lib/components/RateCard/rate-card.module.css index 1d2d8a741..d2670fcab 100644 --- a/packages/design-system/lib/components/RateCard/rate-card.module.css +++ b/packages/design-system/lib/components/RateCard/rate-card.module.css @@ -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 {