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

@@ -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]: {