Merged in feat/SW-1078-rate-terms-scenarios (pull request #1500)

feat(SW-1078): mixed rate scenario

* feat(SW-1078): mixed rate scenario

* fix: change from css string modification to array join

* refactor: split out big reduce function into smaller parts

* fix: minor fixes and improvments

* fix: added room index map to localization string


Approved-by: Christian Andolf
This commit is contained in:
Tobias Johansson
2025-03-12 10:34:35 +00:00
parent 01740e3300
commit ad05f792fb
18 changed files with 264 additions and 10 deletions

View File

@@ -93,6 +93,7 @@ export default async function DetailsPage({
rooms.push({
bedTypes: roomAvailability.bedTypes,
breakfastIncluded: roomAvailability.breakfastIncluded,
cancellationRule: roomAvailability.cancellationRule,
cancellationText: roomAvailability.cancellationText,
mustBeGuaranteed: roomAvailability.mustBeGuaranteed,
packages,
@@ -196,7 +197,6 @@ export default async function DetailsPage({
hotel.merchantInformationData.alternatePaymentOptions
}
supportedCards={hotel.merchantInformationData.cards}
mustBeGuaranteed={isCardOnlyPayment}
/>
</Suspense>
</div>

View File

@@ -0,0 +1,136 @@
import React from "react"
import { useIntl } from "react-intl"
import Body from "@/components/TempDesignSystem/Text/Body"
import Caption from "@/components/TempDesignSystem/Text/Caption"
import { formatPrice } from "@/utils/numberFormatting"
import {
calculateTotalRoomPrice,
hasFlexibleRate,
hasPrepaidRate,
} from "../helpers"
import styles from "./mixedRatePaymentBreakdown.module.css"
import type { RoomState } from "@/types/stores/enter-details"
type PaymentBreakdownState = {
roomsWithPrepaidRate: number[]
roomsWithFlexRate: number[]
payNowPrice: number
payNowComparisonPrice: number
payAtCheckInPrice: number
payAtCheckInComparisonPrice: number
}
interface MixedRatePaymentBreakdownProps {
rooms: RoomState[]
currency: string
}
export default function MixedRatePaymentBreakdown({
rooms,
currency,
}: MixedRatePaymentBreakdownProps) {
const intl = useIntl()
const payNowTitle = intl.formatMessage({ id: "Pay now" })
const payAtCheckInTitle = intl.formatMessage({ id: "Pay at check-in" })
const initialState: PaymentBreakdownState = {
roomsWithPrepaidRate: [],
roomsWithFlexRate: [],
payNowPrice: 0,
payNowComparisonPrice: 0,
payAtCheckInPrice: 0,
payAtCheckInComparisonPrice: 0,
}
const {
roomsWithPrepaidRate,
roomsWithFlexRate,
payNowPrice,
payNowComparisonPrice,
payAtCheckInPrice,
payAtCheckInComparisonPrice,
} = rooms.reduce((acc, room, idx) => {
if (hasPrepaidRate(room)) {
acc.roomsWithPrepaidRate.push(idx)
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
acc.payNowPrice += totalPrice
acc.payNowComparisonPrice += comparisonPrice
}
if (hasFlexibleRate(room)) {
acc.roomsWithFlexRate.push(idx)
const { totalPrice, comparisonPrice } = calculateTotalRoomPrice(room)
acc.payAtCheckInPrice += totalPrice
acc.payAtCheckInComparisonPrice += comparisonPrice
}
return acc
}, initialState)
return (
<div className={styles.container}>
<PaymentCard
title={payNowTitle}
price={payNowPrice}
comparisonPrice={payNowComparisonPrice}
currency={currency}
roomIndexes={roomsWithPrepaidRate}
/>
<PaymentCard
title={payAtCheckInTitle}
price={payAtCheckInPrice}
comparisonPrice={payAtCheckInComparisonPrice}
currency={currency}
roomIndexes={roomsWithFlexRate}
/>
</div>
)
}
interface PaymentCardProps {
title: string
price: number
comparisonPrice: number
currency: string
roomIndexes: number[]
}
function PaymentCard({
title,
price,
comparisonPrice,
currency,
roomIndexes,
}: PaymentCardProps) {
const intl = useIntl()
const isMemberRateApplied = price < comparisonPrice
return (
<div className={styles.card}>
<Caption
type="bold"
textTransform="uppercase"
className={styles.cardTitle}
>
{title}{" "}
<span>
/{" "}
{intl.formatMessage(
{ id: "Room {roomIndex}" },
{
roomIndex: roomIndexes.map((idx) => idx + 1).join(" & "),
}
)}
</span>
</Caption>
<Body textTransform="bold" className={styles.priceItem}>
{formatPrice(intl, price, currency)}
{isMemberRateApplied && comparisonPrice ? (
<span>{formatPrice(intl, comparisonPrice, currency)}</span>
) : null}
</Body>
</div>
)
}

View File

@@ -0,0 +1,36 @@
.container {
display: flex;
gap: var(--Spacing-x1);
}
.card {
display: flex;
flex-direction: column;
flex-grow: 1;
background-color: var(--Scandic-Blue-00);
padding: var(--Spacing-x-one-and-half);
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: var(--Corner-radius-Medium);
}
.cardTitle {
text-transform: uppercase;
}
.cardTitle > span {
color: var(--UI-Text-Placeholder);
}
.card.inactive {
background-color: transparent;
}
.priceItem {
display: flex;
gap: var(--Spacing-x1);
}
.priceItem > span {
font-weight: 400;
text-decoration: line-through;
}

View File

@@ -38,6 +38,8 @@ import { trackPaymentEvent } from "@/utils/tracking"
import { bedTypeMap } from "../../utils"
import PriceChangeDialog from "../PriceChangeDialog"
import GuaranteeDetails from "./GuaranteeDetails"
import { hasFlexibleRate, hasPrepaidRate, isPaymentMethodEnum } from "./helpers"
import MixedRatePaymentBreakdown from "./MixedRatePaymentBreakdown"
import PaymentOption from "./PaymentOption"
import { type PaymentFormData, paymentSchema } from "./schema"
@@ -51,14 +53,9 @@ const retryInterval = 2000
export const formId = "submit-booking"
function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values(PaymentMethodEnum).includes(value as PaymentMethodEnum)
}
export default function PaymentClient({
otherPaymentOptions,
savedCreditCards,
mustBeGuaranteed,
}: PaymentClientProps) {
const router = useRouter()
const lang = useLang()
@@ -90,6 +87,11 @@ export default function PaymentClient({
const { toDate, fromDate, hotelId } = booking
const mustBeGuaranteed = rooms.every((r) => r.room.mustBeGuaranteed)
const hasPrepaidRates = rooms.some(hasPrepaidRate)
const hasFlexRates = rooms.some(hasFlexibleRate)
const hasMixedRates = hasPrepaidRates && hasFlexRates
usePaymentFailedToast()
const methods = useForm<PaymentFormData>({
@@ -351,6 +353,15 @@ export default function PaymentClient({
<GuaranteeDetails />
</section>
) : null}
{hasMixedRates ? (
<Body>
{intl.formatMessage({
id: "As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.",
})}
</Body>
) : null}
{savedCreditCards?.length ? (
<section className={styles.section}>
<Body color="uiTextHighContrast" textTransform="bold">
@@ -374,6 +385,7 @@ export default function PaymentClient({
</div>
</section>
) : null}
<section className={styles.section}>
{savedCreditCards?.length ? (
<Body color="uiTextHighContrast" textTransform="bold">
@@ -399,7 +411,14 @@ export default function PaymentClient({
/>
))}
</div>
{hasMixedRates ? (
<MixedRatePaymentBreakdown
rooms={rooms}
currency={totalPrice.local.currency}
/>
) : null}
</section>
<section className={styles.section}>
<Caption>
{intl.formatMessage(

View File

@@ -0,0 +1,44 @@
import { CancellationRuleEnum, PaymentMethodEnum } from "@/constants/booking"
import type { RoomState } from "@/types/stores/enter-details"
export function isPaymentMethodEnum(value: string): value is PaymentMethodEnum {
return Object.values<string>(PaymentMethodEnum).includes(value)
}
export function hasFlexibleRate({ room }: RoomState): boolean {
return room.cancellationRule === CancellationRuleEnum.CancellableBefore6PM
}
export function hasPrepaidRate({ room }: RoomState): boolean {
return room.cancellationRule !== CancellationRuleEnum.CancellableBefore6PM
}
export function calculateTotalRoomPrice({ room }: RoomState) {
let totalPrice = room.roomPrice.perStay.local.price
if (room.breakfast) {
totalPrice += Number(room.breakfast.localPrice.totalPrice) * room.adults
}
if (room.roomFeatures) {
room.roomFeatures.forEach((pkg) => {
totalPrice += Number(pkg.localPrice.price)
})
}
let comparisonPrice = totalPrice
const isMember = room.guest.join || room.guest.membershipNo
if (isMember) {
const publicPrice = room.roomRate.publicRate?.localPrice.pricePerStay ?? 0
const memberPrice = room.roomRate.memberRate?.localPrice.pricePerStay ?? 0
const diff = publicPrice - memberPrice
comparisonPrice = totalPrice + diff
}
return {
totalPrice,
comparisonPrice,
}
}

View File

@@ -6,7 +6,6 @@ import type { PaymentProps } from "@/types/components/hotelReservation/enterDeta
export default async function Payment({
otherPaymentOptions,
mustBeGuaranteed,
supportedCards,
}: PaymentProps) {
const savedCreditCards = await getSavedPaymentCardsSafely({
@@ -17,7 +16,6 @@ export default async function Payment({
<PaymentClient
otherPaymentOptions={otherPaymentOptions}
savedCreditCards={savedCreditCards}
mustBeGuaranteed={mustBeGuaranteed}
/>
)
}

View File

@@ -54,6 +54,7 @@ const rooms: RoomState[] = [
bedTypes: [],
breakfast: breakfastPackage,
breakfastIncluded: false,
cancellationRule: "",
cancellationText: "Non-refundable",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
guest: guestDetailsNonMember,
@@ -64,6 +65,7 @@ const rooms: RoomState[] = [
roomType: "Standard",
roomTypeCode: "QS",
isAvailable: true,
mustBeGuaranteed: false,
},
steps: {
[StepEnum.selectBed]: {
@@ -92,6 +94,7 @@ const rooms: RoomState[] = [
bedTypes: [],
breakfast: undefined,
breakfastIncluded: false,
cancellationRule: "",
cancellationText: "Non-refundable",
childrenInRoom: [],
guest: guestDetailsMember,
@@ -102,6 +105,7 @@ const rooms: RoomState[] = [
roomType: "Standard",
roomTypeCode: "QS",
isAvailable: true,
mustBeGuaranteed: false,
},
steps: {
[StepEnum.selectBed]: {

View File

@@ -65,6 +65,7 @@
"As our {level}": "Som vores {level}",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "Da dette er et ophold med flere værelser, skal annullereringen gennemføres af personen, der har booket opholdet. Bedes du ringe til vores kundeservice på 08-517 517 00, hvis du har brug for yderligere hjælp.",
"At a cost": "Mod betaling",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din booking inkluderer værelser med forskellige vilkår, vi vil blive opkræve en del af bookingen nu og resten vil blive indsamlet ved check-in.",
"At latest": "Senest",
"At the hotel": "På hotellet",
"Attractions": "Attraktioner",
@@ -493,6 +494,7 @@
"Parking / Garage": "Parkering / Garage",
"Parking can be reserved in advance": "Parkering kan reserveres på forhånd",
"Password": "Adgangskode",
"Pay at check-in": "Betal ved check-in",
"Pay later": "Betal senere",
"Pay now": "Betal nu",
"Pay the member price of {amount} for Room {roomNr}": "Betal medlemsprisen på {amount} til værelse {roomNr}",

View File

@@ -65,6 +65,7 @@
"As our {level}": "Als unser {level}",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "Da dies ein Mehrzimmer-Aufenthalt ist, muss die Stornierung von der Person, die die Buchung getätigt hat, durchgeführt werden. Bitte rufen Sie uns unter der Telefonnummer 08-517 517 00 an, wenn Sie weitere Hilfe benötigen.",
"At a cost": "Gegen Gebühr",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Ihre Buchung enthält Zimmer mit unterschiedlichen Bedingungen, wir werden einen Teil der Buchung jetzt belasten und den Rest bei der Anreise durch die Reception erheben.",
"At latest": "Spätestens",
"At the hotel": "Im Hotel",
"Attraction": "Attraktion",
@@ -493,6 +494,7 @@
"Parking / Garage": "Parken / Garage",
"Parking can be reserved in advance": "Parkplätze können im Voraus reserviert werden",
"Password": "Passwort",
"Pay at check-in": "Beim Check-in bezahlen",
"Pay later": "Später bezahlen",
"Pay now": "Jetzt bezahlen",
"Pay the member price of {amount} for Room {roomNr}": "Zahlen Sie den Mitgliedspreis von {amount} für Zimmer {roomNr}",

View File

@@ -68,6 +68,7 @@
"As our {level}": "As our {level}",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.",
"At a cost": "At a cost",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.",
"At latest": "At latest",
"At the hotel": "At the hotel",
"Attractions": "Attractions",
@@ -499,6 +500,7 @@
"Parking / Garage": "Parking / Garage",
"Parking can be reserved in advance": "Parking can be reserved in advance",
"Password": "Password",
"Pay at check-in": "Pay at check-in",
"Pay later": "Pay later",
"Pay now": "Pay now",
"Pay the member price of {amount} for Room {roomNr}": "Pay the member price of {amount} for Room {roomNr}",

View File

@@ -64,6 +64,7 @@
"As our {level}": "{level}-etu",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "Koska tämä on monihuoneinen majoitus, peruutus on tehtävä henkilölle, joka teki varauksen. Ota yhteyttä asiakaspalveluun apua varten, jos tarvitset lisää apua.",
"At a cost": "Maksua vastaan",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Sinun varauksessasi on huoneita eri hinnoilla, me veloitamme osan varauksesta nyt ja loput tarkistukseen tapahtuvan tarkistuksen yhteydessä.",
"At latest": "Viimeistään",
"At the hotel": "Hotellissa",
"Attractions": "Nähtävyydet",
@@ -492,6 +493,7 @@
"Parking / Garage": "Pysäköinti / Autotalli",
"Parking can be reserved in advance": "Pysäköintipaikan voi varata etukäteen",
"Password": "Salasana",
"Pay at check-in": "Maksa tarkistuksessa",
"Pay later": "Maksa myöhemmin",
"Pay now": "Maksa nyt",
"Pay the member price of {amount} for Room {roomNr}": "Maksa jäsenhinta {amount} varten Huone {roomNr}",

View File

@@ -64,6 +64,7 @@
"As our {level}": "Som vår {level}",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "Som dette er et ophold med flere rom, må annullereringen gjøres av personen som booket opholdet. Vennligst ring 08-517 517 00 til vår kundeservice hvis du trenger mer hjelp.",
"At a cost": "Mot betaling",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din bestilling inkluderer rom med ulike vilkår, vi vil belaste en del av bestillingen nå og den resterende vil bli samlet inn ved check-in.",
"At latest": "Senest",
"At the hotel": "På hotellet",
"Attractions": "Attraksjoner",
@@ -491,6 +492,7 @@
"Parking / Garage": "Parkering / Garasje",
"Parking can be reserved in advance": "Parkering kan reserveres på forhånd",
"Password": "Passord",
"Pay at check-in": "Betal ved check-in",
"Pay later": "Betal senere",
"Pay now": "Betal nå",
"Pay the member price of {amount} for Room {roomNr}": "Betal medlemsprisen på {amount} for rom {roomNr}",

View File

@@ -64,6 +64,7 @@
"As our {level}": "Som vår {level}",
"As this is a multiroom stay, the cancellation has to be done by the person who made the booking. Please call 08-517 517 00 to talk to our customer service if you would need further assistance.": "Då detta är en vistelse med flera rum måste avbokningen göras av personen som bokade vistelsen. Kontakta vår kundsupport på 08-517 517 00 om du behöver mer hjälp.",
"At a cost": "Mot en kostnad",
"As your booking includes rooms with different terms, we will be charging part of the booking now and the remainder will be collected by the reception at check-in.": "Din bokning innehåller rum med olika villkor, vi kommer att debitera en del av bokningen nu och resten kommer att samlas in vid check-in.",
"At latest": "Senast",
"At the hotel": "På hotellet",
"Attractions": "Sevärdheter",
@@ -491,6 +492,7 @@
"Parking / Garage": "Parkering / Garage",
"Parking can be reserved in advance": "Parkering kan reserveras i förväg",
"Password": "Lösenord",
"Pay at check-in": "Betala vid check-in",
"Pay later": "Betala senare",
"Pay now": "Betala nu",
"Pay the member price of {amount} for Room {roomNr}": "Betala medlemspriset på {amount} för rum {roomNr}",

View File

@@ -39,6 +39,7 @@ export default function EnterDetailsProvider({
.map((room) => ({
isAvailable: room.isAvailable,
breakfastIncluded: !!room.breakfastIncluded,
cancellationRule: room.cancellationRule,
cancellationText: room.cancellationText,
rateDetails: room.rateDetails,
rateTitle: room.rateTitle,
@@ -54,6 +55,7 @@ export default function EnterDetailsProvider({
description: room.bedTypes[0].description,
}
: undefined,
mustBeGuaranteed: room.mustBeGuaranteed,
})),
vat,
}

View File

@@ -788,6 +788,7 @@ export const hotelQueryRouter = router({
return {
selectedRoom,
rateDetails: rateDefinition?.generalTerms,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,

View File

@@ -3,7 +3,6 @@ import type { PaymentMethodEnum } from "@/constants/booking"
export interface PaymentProps {
otherPaymentOptions: PaymentMethodEnum[]
mustBeGuaranteed: boolean
supportedCards: PaymentMethodEnum[]
}

View File

@@ -5,8 +5,9 @@ import type { Packages } from "@/types/requests/packages"
export interface Room {
bedTypes?: BedTypeSelection[]
breakfastIncluded?: boolean
cancellationRule?: string
cancellationText: string
mustBeGuaranteed?: boolean
mustBeGuaranteed: boolean
packages: Packages | null
rateDetails: string[]
rateTitle?: string

View File

@@ -27,12 +27,14 @@ export interface InitialRoomData {
bedTypes: BedTypeSelection[]
breakfastIncluded: boolean
cancellationText: string
cancellationRule?: string
rateDetails: string[] | undefined
rateTitle?: string
roomFeatures: Packages | null
roomRate: RoomRate
roomType: string
roomTypeCode: string
mustBeGuaranteed: boolean
}
export type RoomStep = {