Merged in fix/SW-3021-vouchers (pull request #2719)

fix(SW-3021): add pluralization support for vouchers

* fix(SW-3021): add pluralization support for vouchers


Approved-by: Anton Gunnarsson
This commit is contained in:
Bianca Widstam
2025-08-28 08:27:03 +00:00
parent 997f928f2b
commit 0e00e8eaf1
17 changed files with 160 additions and 131 deletions

View File

@@ -56,7 +56,7 @@ export function mapRoomState(
formattedRoomCost = formatPrice( formattedRoomCost = formatPrice(
intl, intl,
booking.vouchers, booking.vouchers,
intl.formatMessage({ defaultMessage: "Voucher" }) CurrencyEnum.Voucher
) )
} }

View File

@@ -1,6 +1,7 @@
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting" import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Button } from "@scandic-hotels/design-system/Button" import { Button } from "@scandic-hotels/design-system/Button"
import { Divider } from "@scandic-hotels/design-system/Divider" import { Divider } from "@scandic-hotels/design-system/Divider"
@@ -16,8 +17,6 @@ import Breakfast from "./Breakfast"
import styles from "./room.module.css" import styles from "./room.module.css"
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import type { Room as RoomType } from "@/types/stores/enter-details" import type { Room as RoomType } from "@/types/stores/enter-details"
interface RoomProps { interface RoomProps {
@@ -114,14 +113,13 @@ export default function Room({
) )
let currency: string = room.roomPrice.perStay.local.currency let currency: string = room.roomPrice.perStay.local.currency
const voucherCurrency = intl.formatMessage({ defaultMessage: "Voucher" })
const isVoucher = "voucher" in room.roomRate const isVoucher = "voucher" in room.roomRate
if (isVoucher) { if (isVoucher) {
currency = voucherCurrency currency = CurrencyEnum.Voucher
price = formatPrice( price = formatPrice(
intl, intl,
room.roomPrice.perStay.local.price, room.roomPrice.perStay.local.price,
voucherCurrency, currency,
room.roomPrice.perStay.local.additionalPrice, room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency room.roomPrice.perStay.local.additionalPriceCurrency
) )

View File

@@ -86,15 +86,9 @@ export default function SummaryUI({
const showDiscounted = containsBookingCodeRate || isMember const showDiscounted = containsBookingCodeRate || isMember
const totalCurrency = isVoucherRate const totalCurrency = isVoucherRate
? intl.formatMessage({ defaultMessage: "Voucher" }) ? CurrencyEnum.Voucher
: totalPrice.local.currency : totalPrice.local.currency
if (isVoucherRate && defaultCurrency === CurrencyEnum.Voucher) {
defaultCurrency = intl.formatMessage({
defaultMessage: "Voucher",
}) as CurrencyEnum
}
return ( return (
<section className={styles.summary}> <section className={styles.summary}>
<header <header

View File

@@ -151,24 +151,45 @@ export default function HotelCardListing({
}, },
}} }}
lang={lang} lang={lang}
prices={{ fullPrice={!hotel.availability.bookingCode}
public: hotel.availability.productType?.public prices={
? { hotel.availability.productType && {
...hotel.availability.productType.public, public: hotel.availability.productType?.public
requestedPrice: ? {
hotel.availability.productType?.public.requestedPrice ?? ...hotel.availability.productType.public,
undefined, requestedPrice:
} hotel.availability.productType?.public.requestedPrice ??
: undefined, undefined,
member: hotel.availability.productType?.member }
? { : undefined,
...hotel.availability.productType.member, member: hotel.availability.productType?.member
requestedPrice: ? {
hotel.availability.productType?.member.requestedPrice ?? ...hotel.availability.productType.member,
undefined, requestedPrice:
} hotel.availability.productType?.member.requestedPrice ??
: undefined, undefined,
}} }
: undefined,
voucher: hotel.availability.productType?.voucher,
bonusCheque: hotel.availability.productType?.bonusCheque
? {
...hotel.availability.productType.bonusCheque,
requestedPrice:
hotel.availability.productType.bonusCheque
.requestedPrice ?? undefined,
}
: undefined,
redemptions: hotel.availability.productType?.redemptions?.map(
(redemption) => ({
...redemption,
localPrice: {
...redemption.localPrice,
currency: redemption.localPrice.currency,
},
})
),
}
}
onHover={() => engage(hotel.hotel.name)} onHover={() => engage(hotel.hotel.name)}
onHoverEnd={() => disengage()} onHoverEnd={() => disengage()}
onAddressClick={() => { onAddressClick={() => {

View File

@@ -1,12 +1,11 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting" import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer" import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import type { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
export default function Vouchers({ export default function Vouchers({
currencyCode, currencyCode,
isCancelled, isCancelled,
@@ -27,7 +26,7 @@ export default function Vouchers({
const totalPrice = formatPrice( const totalPrice = formatPrice(
intl, intl,
vouchers, vouchers,
intl.formatMessage({ defaultMessage: "Voucher" }), CurrencyEnum.Voucher,
price, price,
currencyCode currencyCode
) )

View File

@@ -1,7 +1,6 @@
import { cx } from "class-variance-authority" import { cx } from "class-variance-authority"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting" import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -21,14 +20,11 @@ export default function LargeRow({
price, price,
}: RowProps) { }: RowProps) {
const intl = useIntl() const intl = useIntl()
const isVoucherRate = price.local.currency === CurrencyEnum.Voucher
const currency = isVoucherRate
? intl.formatMessage({ defaultMessage: "Voucher" })
: price.local.currency
const totalPrice = formatPrice( const totalPrice = formatPrice(
intl, intl,
price.local.price, price.local.price,
currency, price.local.currency,
price.local.additionalPrice, price.local.additionalPrice,
price.local.additionalPriceCurrency price.local.additionalPriceCurrency
) )

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting" import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import BoldRow from "../Bold" import BoldRow from "../Bold"
@@ -35,19 +36,21 @@ export default function VoucherPrice({
return null return null
} }
const voucherCurrency = intl.formatMessage({ defaultMessage: "Voucher" })
const averagePriceTitle = intl.formatMessage({ const averagePriceTitle = intl.formatMessage({
defaultMessage: "Average price per night", defaultMessage: "Average price per night",
}) })
const averagePricePerNight = `${price.numberOfVouchers / nights} ${voucherCurrency}` const averagePricePerNight = formatPrice(
intl,
price.numberOfVouchers / nights,
CurrencyEnum.Voucher
)
return ( return (
<> <>
<BoldRow <BoldRow
label={intl.formatMessage({ defaultMessage: "Room charge" })} label={intl.formatMessage({ defaultMessage: "Room charge" })}
value={formatPrice(intl, price.numberOfVouchers, voucherCurrency)} value={formatPrice(intl, price.numberOfVouchers, CurrencyEnum.Voucher)}
/> />
{nights > 1 ? ( {nights > 1 ? (
<RegularRow label={averagePriceTitle} value={averagePricePerNight} /> <RegularRow label={averagePriceTitle} value={averagePricePerNight} />

View File

@@ -8,7 +8,6 @@ import type {
Product, Product,
RedemptionProduct, RedemptionProduct,
} from "@scandic-hotels/trpc/types/roomAvailability" } from "@scandic-hotels/trpc/types/roomAvailability"
import type { IntlShape } from "react-intl"
import type { Price } from "@/types/components/hotelReservation/price" import type { Price } from "@/types/components/hotelReservation/price"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
@@ -214,8 +213,7 @@ export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
export function getTotalPrice( export function getTotalPrice(
mainRoomProduct: Rate | null, mainRoomProduct: Rate | null,
rateSummary: Array<Rate | null>, rateSummary: Array<Rate | null>,
isUserLoggedIn: boolean, isUserLoggedIn: boolean
intl: IntlShape
): Price | null { ): Price | null {
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null) const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
@@ -235,9 +233,6 @@ export function getTotalPrice(
} }
if ("voucher" in product) { if ("voucher" in product) {
const voucherPrice = calculateVoucherPrice(summaryArray) const voucherPrice = calculateVoucherPrice(summaryArray)
voucherPrice.local.currency = intl.formatMessage({
defaultMessage: "Voucher",
}) as CurrencyEnum
return voucherPrice return voucherPrice
} }

View File

@@ -338,15 +338,25 @@ function VoucherCode({
const rateTermDetails = getRateTermDetails(codeProduct) const rateTermDetails = getRateTermDetails(codeProduct)
const voucherMsg = intl const voucherMsg = intl.formatMessage(
.formatMessage({ {
defaultMessage: "Voucher", defaultMessage:
}) "{numberOfVouchers, plural, one {Voucher} other {Vouchers}}",
.toUpperCase() },
let price = `${numberOfVouchers} ${voucherMsg}` {
if (packagesSum.price) { numberOfVouchers: numberOfVouchers,
price = `${price} + ${packagesSum.price}` }
} )
const { price, currency } = packagesSum.price
? {
price: `${numberOfVouchers} ${voucherMsg} + ${packagesSum.price}`,
currency: packagesSum.currency ?? "",
}
: {
price: `${numberOfVouchers} ${voucherMsg}`,
currency: "",
}
return ( return (
<CodeRateCard <CodeRateCard
key={codeProduct.rate} key={codeProduct.rate}
@@ -358,7 +368,7 @@ function VoucherCode({
rate={{ rate={{
label: codeProduct.rateDefinition?.title, label: codeProduct.rateDefinition?.title,
price, price,
unit: packagesSum.currency ?? "", unit: currency,
}} }}
rateTitle={rateTitles[codeProduct.rate].title} rateTitle={rateTitles[codeProduct.rate].title}
rateTermDetails={rateTermDetails} rateTermDetails={rateTermDetails}

View File

@@ -232,7 +232,6 @@ export function SelectRateProvider({
roomConfiguration: roomAvailability[ix]?.[0], roomConfiguration: roomAvailability[ix]?.[0],
})), })),
useMemberPrices: isUserLoggedIn, useMemberPrices: isUserLoggedIn,
intl,
}) })
const getPriceForRoom = useCallback( const getPriceForRoom = useCallback(
@@ -252,10 +251,9 @@ export function SelectRateProvider({
{ rate, roomConfiguration: roomAvailability[roomIndex]?.[0] }, { rate, roomConfiguration: roomAvailability[roomIndex]?.[0] },
], ],
useMemberPrices: isUserLoggedIn, useMemberPrices: isUserLoggedIn,
intl,
}) })
}, },
[selectedRates, roomAvailability, isUserLoggedIn, intl] [selectedRates, roomAvailability, isUserLoggedIn]
) )
const setActiveRoomIndex = useCallback( const setActiveRoomIndex = useCallback(

View File

@@ -2,20 +2,11 @@ import { describe, expect, it } from "vitest"
import { getTotalPrice } from "./getTotalPrice" import { getTotalPrice } from "./getTotalPrice"
import type { IntlShape } from "react-intl"
const mockIntl = {
formatMessage: ({ defaultMessage }: { defaultMessage: string }) => {
return defaultMessage
},
} as IntlShape
describe("getTotalPrice", () => { describe("getTotalPrice", () => {
it("should return null when no rates are selected", () => { it("should return null when no rates are selected", () => {
const result = getTotalPrice({ const result = getTotalPrice({
selectedRates: [], selectedRates: [],
useMemberPrices: false, useMemberPrices: false,
intl: mockIntl,
}) })
expect(result).toEqual({ expect(result).toEqual({

View File

@@ -3,7 +3,6 @@ import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { sumPackages } from "@/components/HotelReservation/utils" import { sumPackages } from "@/components/HotelReservation/utils"
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability" import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
import type { IntlShape } from "react-intl"
import type { import type {
AvailabilityWithRoomInfo, AvailabilityWithRoomInfo,
@@ -32,11 +31,9 @@ type SelectedRate = {
export function getTotalPrice({ export function getTotalPrice({
selectedRates, selectedRates,
useMemberPrices, useMemberPrices,
intl,
}: { }: {
selectedRates: Array<SelectedRate | null> selectedRates: Array<SelectedRate | null>
useMemberPrices: boolean useMemberPrices: boolean
intl: IntlShape
}): Price | null { }): Price | null {
const mainRoom = selectedRates[0] const mainRoom = selectedRates[0]
const mainRoomRate = mainRoom?.rate const mainRoomRate = mainRoom?.rate
@@ -63,10 +60,6 @@ export function getTotalPrice({
} }
if ("voucher" in mainRoomRate) { if ("voucher" in mainRoomRate) {
const voucherPrice = calculateVoucherPrice(summaryArray) const voucherPrice = calculateVoucherPrice(summaryArray)
// TODO: This is a workaround, should be handled where we print the price.
voucherPrice.local.currency = intl.formatMessage({
defaultMessage: "Voucher",
}) as CurrencyEnum
return voucherPrice return voucherPrice
} }

View File

@@ -63,7 +63,6 @@ export default function BookingConfirmationProvider({
) )
} else if (totalBookingVouchers) { } else if (totalBookingVouchers) {
const room = rooms?.[0] const room = rooms?.[0]
const voucherCurrency = intl.formatMessage({ defaultMessage: "Voucher" })
if (room?.packages) { if (room?.packages) {
const pkgsSum = room.packages.reduce( const pkgsSum = room.packages.reduce(
(total, pkg) => total + pkg.totalPrice, (total, pkg) => total + pkg.totalPrice,
@@ -74,7 +73,7 @@ export default function BookingConfirmationProvider({
formattedTotalCost = formatPrice( formattedTotalCost = formatPrice(
intl, intl,
totalBookingVouchers, totalBookingVouchers,
voucherCurrency, CurrencyEnum.Voucher,
pkgsSum, pkgsSum,
currency currency
) )
@@ -83,7 +82,7 @@ export default function BookingConfirmationProvider({
formattedTotalCost = formatPrice( formattedTotalCost = formatPrice(
intl, intl,
totalBookingVouchers, totalBookingVouchers,
voucherCurrency CurrencyEnum.Voucher
) )
} }
} }

View File

@@ -51,7 +51,15 @@ export function calculateTotalPrice(
} }
if (totals.vouchers) { if (totals.vouchers) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : "" const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""
totalPrice = `${appendTotalPrice}${totals.vouchers} ${intl.formatMessage({ defaultMessage: "Voucher" })}` totalPrice = `${appendTotalPrice}${totals.vouchers} ${intl.formatMessage(
{
defaultMessage:
"{numberOfVouchers, plural, one {Voucher} other {Vouchers}}",
},
{
numberOfVouchers: totals.vouchers,
}
)}`
} }
if (totals.cash) { if (totals.cash) {
const appendTotalPrice = totalPrice ? `${totalPrice} + ` : "" const appendTotalPrice = totalPrice ? `${totalPrice} + ` : ""

View File

@@ -1,3 +1,5 @@
import { CurrencyEnum } from "../constants/currency"
import type { IntlShape } from "react-intl" import type { IntlShape } from "react-intl"
/** /**
@@ -21,7 +23,7 @@ export function getSingleDecimal(n: Number | string) {
export function formatPrice( export function formatPrice(
intl: IntlShape, intl: IntlShape,
price: number, price: number,
currency: string, currency: string | CurrencyEnum,
additionalPrice?: number, additionalPrice?: number,
additionalPriceCurrency?: string additionalPriceCurrency?: string
) { ) {
@@ -37,5 +39,18 @@ export function formatPrice(
formattedAdditionalPrice = ` + ${localizedAdditionalPrice} ${additionalPriceCurrency}` formattedAdditionalPrice = ` + ${localizedAdditionalPrice} ${additionalPriceCurrency}`
} }
return `${localizedPrice} ${currency}${formattedAdditionalPrice}` const currencyText =
currency === CurrencyEnum.Voucher
? intl.formatMessage(
{
defaultMessage:
"{numberOfVouchers, plural, one {Voucher} other {Vouchers}}",
},
{
numberOfVouchers: price,
}
)
: currency
return `${localizedPrice} ${currencyText}${formattedAdditionalPrice}`
} }

View File

@@ -1,6 +1,5 @@
import { useIntl } from 'react-intl' import { useIntl } from 'react-intl'
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
import Caption from '../../Caption' import Caption from '../../Caption'
import Subtitle from '../../Subtitle' import Subtitle from '../../Subtitle'
@@ -29,7 +28,15 @@ export default function HotelVoucherCard({
{productTypeVoucher.numberOfVouchers} {productTypeVoucher.numberOfVouchers}
</Subtitle> </Subtitle>
<Caption color="uiTextHighContrast" className={styles.currency}> <Caption color="uiTextHighContrast" className={styles.currency}>
{CurrencyEnum.Voucher} {intl.formatMessage(
{
defaultMessage:
'{numberOfVouchers, plural, one {Voucher} other {Vouchers}}',
},
{
numberOfVouchers: productTypeVoucher.numberOfVouchers,
}
)}
</Caption> </Caption>
</div> </div>
</div> </div>

View File

@@ -31,10 +31,10 @@ import styles from './hotelCard.module.css'
import type { Lang } from '@scandic-hotels/common/constants/language' import type { Lang } from '@scandic-hotels/common/constants/language'
import { FacilityEnum } from '@scandic-hotels/common/constants/facilities' import { FacilityEnum } from '@scandic-hotels/common/constants/facilities'
import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType' import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType'
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
import { BookingCodeChip } from '../BookingCodeChip' import { BookingCodeChip } from '../BookingCodeChip'
import { HotelType } from '@scandic-hotels/common/constants/hotelType' import { HotelType } from '@scandic-hotels/common/constants/hotelType'
import { TripAdvisorChip } from '../TripAdvisorChip' import { TripAdvisorChip } from '../TripAdvisorChip'
import { CurrencyEnum } from '@scandic-hotels/common/constants/currency'
type Price = { type Price = {
pricePerStay: number pricePerStay: number
@@ -57,47 +57,48 @@ export type HotelCardProps = {
tripAdvisor?: number tripAdvisor?: number
} }
} }
prices: { prices:
public?: { | {
rateType: RateTypeEnum public?: {
localPrice: Price rateType: RateTypeEnum
requestedPrice?: Price localPrice: Price
} requestedPrice?: Price
member?: { }
rateType: RateTypeEnum member?: {
localPrice: Price rateType: RateTypeEnum
requestedPrice?: Price localPrice: Price
} requestedPrice?: Price
voucher?: { }
numberOfVouchers: number voucher?: {
rateCode: string numberOfVouchers: number
rateType: RateTypeEnum rateCode: string
} rateType: RateTypeEnum
bonusCheque?: { }
rateCode: string bonusCheque?: {
rateType: RateTypeEnum rateCode: string
localPrice: { rateType: RateTypeEnum
additionalPricePerStay: number localPrice: {
currency: CurrencyEnum | null | undefined additionalPricePerStay: number
numberOfCheques: number currency: CurrencyEnum | null | undefined
numberOfCheques: number
}
requestedPrice?: {
additionalPricePerStay: number
currency: CurrencyEnum | null | undefined
numberOfCheques: number
}
}
redemptions?: {
rateCode: string
hasEnoughPoints: boolean
localPrice: {
additionalPricePerStay: number
pointsPerStay: number
currency: CurrencyEnum | null | undefined
}
}[]
} }
requestedPrice?: { | undefined
additionalPricePerStay: number
currency: CurrencyEnum | null | undefined
numberOfCheques: number
}
}
redemptions?: {
rateCode: string
hasEnoughPoints: boolean
localPrice: {
additionalPricePerStay: number
pointsPerStay: number
currency: string
}
}[]
}
images: GalleryImage[] images: GalleryImage[]
distanceToCityCenter: number distanceToCityCenter: number
isUserLoggedIn: boolean isUserLoggedIn: boolean
@@ -105,6 +106,7 @@ export type HotelCardProps = {
state?: 'default' | 'active' state?: 'default' | 'active'
bookingCode?: string | null bookingCode?: string | null
isAlternative?: boolean isAlternative?: boolean
fullPrice: boolean
lang: Lang lang: Lang
@@ -128,6 +130,7 @@ export const HotelCard = memo(
images, images,
lang, lang,
belowInfoSlot, belowInfoSlot,
fullPrice,
onAddressClick, onAddressClick,
onHover, onHover,
onHoverEnd, onHoverEnd,
@@ -151,16 +154,15 @@ export const HotelCard = memo(
} }
const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}` const addressStr = `${hotel.address.streetAddress}, ${hotel.address.city}`
const fullPrice = !bookingCode
const hasInsufficientPoints = !prices.redemptions?.some( const hasInsufficientPoints = !prices?.redemptions?.some(
(r) => r.hasEnoughPoints (r) => r.hasEnoughPoints
) )
const notEnoughPointsLabel = intl.formatMessage({ const notEnoughPointsLabel = intl.formatMessage({
defaultMessage: 'Not enough points', defaultMessage: 'Not enough points',
}) })
const isDisabled = prices.redemptions?.length && hasInsufficientPoints const isDisabled = prices?.redemptions?.length && hasInsufficientPoints
return ( return (
<article <article