Merged in feat/sw-2874-move-select-rate (pull request #2750)

Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-09-03 08:30:05 +00:00
parent 8c3f8c74db
commit f7ef58eafa
158 changed files with 708 additions and 735 deletions

View File

@@ -0,0 +1,346 @@
import { useIntl } from "react-intl"
import { RateEnum } from "@scandic-hotels/common/constants/rate"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import Body from "@scandic-hotels/design-system/Body"
import Caption from "@scandic-hotels/design-system/Caption"
import Footnote from "@scandic-hotels/design-system/Footnote"
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
import Subtitle from "@scandic-hotels/design-system/Subtitle"
import { useIsLoggedIn } from "../../../../hooks/useIsLoggedIn"
import SignupPromoDesktop from "../../../SignupPromo/Desktop"
import { isBookingCodeRate } from "./utils"
import styles from "./rateSummary.module.css"
import type { useSelectRateContext } from "../../../../contexts/SelectRate/SelectRateContext"
import type { SelectedRate } from "../../../../contexts/SelectRate/types"
export function DesktopSummary({
input,
selectedRates,
isSubmitting,
bookingCode,
}: {
selectedRates: ReturnType<typeof useSelectRateContext>["selectedRates"]
isSubmitting: boolean
input: ReturnType<typeof useSelectRateContext>["input"]
bookingCode: string
}) {
const intl = useIntl()
const isUserLoggedIn = useIsLoggedIn()
if (!selectedRates.totalPrice) {
return null
}
const hasMemberRates = selectedRates.rates.some(
(rate) => rate && "member" in rate && rate.member
)
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
const totalNights = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: input.nights }
)
const totalAdults = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{
totalAdults:
input.data?.booking.rooms.reduce((acc, room) => acc + room.adults, 0) ??
0,
}
)
const childrenInOneOrMoreRooms = input.data?.booking.rooms.some(
(room) => room.childrenInRoom?.length
)
const childrenInroom = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{
totalChildren: input.data?.booking.rooms.reduce(
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
0
),
}
)
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
const totalRooms = intl.formatMessage(
{
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
},
{ totalRooms: input.roomCount }
)
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
const isAllRoomsSelected = selectedRates.state === "ALL_SELECTED"
const showDiscounted =
isUserLoggedIn || selectedRates.rates.some(isBookingCodeRate)
const mainRoomRate = selectedRates.rates.at(0)
let mainRoomCurrency = getRoomCurrency(mainRoomRate)
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const isTotalRegularPriceGreaterThanPrice =
totalRegularPrice > selectedRates.totalPrice.local.price
const showStrikedThroughPrice =
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
return (
<>
<div className={styles.summaryText}>
{selectedRates.rates.map((room, index) => {
return (
<RateSummary
key={index}
room={room}
roomIndex={index}
isMultiRoom={selectedRates.rates.length > 1}
/>
)
})}
</div>
<div className={styles.summaryPriceContainer}>
{showMemberDiscountBanner && (
<div className={styles.promoContainer}>
<SignupPromoDesktop
memberPrice={{
amount: selectedRates.rates.reduce((total, rate) => {
if (!rate) {
return total
}
const memberExists = "member" in rate && rate.member
const publicExists = "public" in rate && rate.public
if (!memberExists && !publicExists) {
return total
}
const price =
rate.member?.localPrice.pricePerStay ||
rate.public?.localPrice.pricePerStay
if (!price) {
return total
}
const selectedPackagesPrice =
rate.roomInfo.selectedPackages.reduce(
(acc, pkg) => acc + pkg.localPrice.totalPrice,
0
)
return total + price + selectedPackagesPrice
}, 0),
currency: mainRoomCurrency ?? "",
}}
/>
</div>
)}
<div className={styles.summaryPriceTextDesktop}>
<Body>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{ b: (str) => <b>{str}</b> }
)}
</Body>
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
</div>
<div className={styles.summaryPrice}>
<div className={styles.summaryPriceTextDesktop}>
<Subtitle
color={showDiscounted ? "red" : "uiTextHighContrast"}
textAlign="right"
>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</Subtitle>
{showStrikedThroughPrice &&
selectedRates.totalPrice.local.regularPrice && (
<Caption
textAlign="right"
color="uiTextMediumContrast"
striked={true}
>
{formatPrice(
intl,
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</Caption>
)}
{selectedRates.totalPrice.requested ? (
<Body color="uiTextMediumContrast">
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
selectedRates.totalPrice.requested.price,
selectedRates.totalPrice.requested.currency,
selectedRates.totalPrice.requested.additionalPrice,
selectedRates.totalPrice.requested.additionalPriceCurrency
),
}
)}
</Body>
) : null}
</div>
<div className={styles.summaryPriceTextMobile}>
<Caption color="uiTextHighContrast">
{intl.formatMessage({
defaultMessage: "Total price",
})}
</Caption>
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</Subtitle>
<Footnote
color="uiTextMediumContrast"
className={styles.summaryPriceTextMobile}
>
{summaryPriceText}
</Footnote>
</div>
<Button
className={styles.continueButton}
disabled={!isAllRoomsSelected || isSubmitting}
theme="base"
type="submit"
>
{intl.formatMessage({
defaultMessage: "Continue",
})}
</Button>
</div>
</div>
</>
)
}
function RateSummary({
roomIndex,
room,
isMultiRoom,
}: {
room: SelectedRate | undefined
roomIndex: number
isMultiRoom: boolean
}) {
const intl = useIntl()
const getRateDetails = useRateDetails()
if (!room || !room.isSelected) {
return (
<div key={`unselected-${roomIndex}`}>
<Subtitle color="uiTextPlaceholder">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomIndex + 1 }
)}
</Subtitle>
<Body color="uiTextPlaceholder">
{intl.formatMessage({
defaultMessage: "Select room",
})}
</Body>
</div>
)
}
return (
<div key={roomIndex}>
{isMultiRoom ? (
<>
<Subtitle color="uiTextHighContrast">
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{ roomIndex: roomIndex + 1 }
)}
</Subtitle>
<Body color="uiTextMediumContrast">{room.roomInfo.roomType}</Body>
<Caption color="uiTextMediumContrast">
{getRateDetails(room.rate)}
</Caption>
</>
) : (
<>
<Subtitle color="uiTextHighContrast">
{room.roomInfo.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">{getRateDetails(room.rate)}</Body>
</>
)}
</div>
)
}
function useRateDetails() {
const intl = useIntl()
const freeCancelation = intl.formatMessage({
defaultMessage: "Free cancellation",
})
const nonRefundable = intl.formatMessage({
defaultMessage: "Non-refundable",
})
const freeBooking = intl.formatMessage({
defaultMessage: "Free rebooking",
})
const payLater = intl.formatMessage({
defaultMessage: "Pay later",
})
const payNow = intl.formatMessage({
defaultMessage: "Pay now",
})
return (rate: RateEnum) => {
switch (rate) {
case RateEnum.change:
return `${freeBooking}, ${payNow}`
case RateEnum.flex:
return `${freeCancelation}, ${payLater}`
case RateEnum.save:
default:
return `${nonRefundable}, ${payNow}`
}
}
}
function getRoomCurrency(rate: SelectedRate | undefined) {
if (!rate) {
return null
}
if ("member" in rate && rate.member?.localPrice) {
return rate.member.localPrice.currency
}
if ("public" in rate && rate.public?.localPrice) {
return rate.public.localPrice.currency
}
}

View File

@@ -0,0 +1,346 @@
"use client"
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
import { dt } from "@scandic-hotels/common/dt"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
import { Divider } from "@scandic-hotels/design-system/Divider"
import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useSelectRateContext } from "../../../../../../contexts/SelectRate/SelectRateContext"
import useLang from "../../../../../../hooks/useLang"
import PriceDetailsModal from "../../../../../PriceDetailsModal"
import SignupPromoDesktop from "../../../../../SignupPromo/Desktop"
import { useRateTitles } from "../../../Rooms/RoomsList/RoomListItem/Rates/useRateTitles"
import { isBookingCodeRate } from "../../utils"
import Room from "../Room"
import styles from "./summaryContent.module.css"
import type { Price } from "../../../../../../contexts/SelectRate/getTotalPrice"
export type SelectRateSummaryProps = {
isMember: boolean
bookingCode?: string
toggleSummaryOpen: () => void
}
export default function SummaryContent({
isMember,
toggleSummaryOpen,
}: SelectRateSummaryProps) {
const { selectedRates, input } = useSelectRateContext()
const intl = useIntl()
const lang = useLang()
const rateTitles = useRateTitles()
const nightsLabel = intl.formatMessage(
{
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
},
{ totalNights: input.nights }
)
const memberPrice =
selectedRates.rates.length === 1 &&
selectedRates.rates[0] &&
"member" in selectedRates.rates[0]
? selectedRates.rates[0].member
: null
const containsBookingCodeRate = selectedRates.rates.find(
(r) => r && isBookingCodeRate(r)
)
if (!selectedRates?.totalPrice) {
return null
}
const showDiscounted = containsBookingCodeRate || isMember
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice =
totalRegularPrice > selectedRates?.totalPrice?.local?.price
return (
<section className={styles.summary}>
<header>
<div className={styles.headingWrapper}>
<Typography variant="Title/Subtitle/md">
<h3 className={styles.heading}>
{intl.formatMessage({
defaultMessage: "Booking summary",
})}
</h3>
</Typography>
<IconButton
className={styles.closeButton}
onPress={toggleSummaryOpen}
theme="Black"
style="Muted"
>
<MaterialIcon
icon="keyboard_arrow_down"
size={20}
color="CurrentColor"
/>
</IconButton>
</div>
<Typography variant="Body/Paragraph/mdBold">
<p className={styles.dates}>
{dt(input.data?.booking.fromDate)
.locale(lang)
.format(longDateFormat[lang])}
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
{dt(input.data?.booking.toDate)
.locale(lang)
.format(longDateFormat[lang])}{" "}
({nightsLabel})
</p>
</Typography>
</header>
<Divider color="Border/Divider/Subtle" />
{selectedRates.rates.map((room, idx) => {
if (!room) {
return null
}
return (
<Room
key={idx}
room={mapToRoom({
isMember,
rate: room,
input,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
})}
roomNumber={idx + 1}
roomCount={selectedRates.rates.length}
isMember={isMember}
/>
)
})}
<div>
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdRegular">
<p>
{intl.formatMessage(
{
defaultMessage: "<b>Total price</b> (incl VAT)",
},
{
b: (str) => (
<Typography variant="Body/Paragraph/mdBold">
<span>{str}</span>
</Typography>
),
}
)}
</p>
</Typography>
{selectedRates.totalPrice.requested ? (
<Typography variant="Body/Supporting text (caption)/smRegular">
<p className={styles.approxPrice}>
{intl.formatMessage(
{
defaultMessage: "Approx. {value}",
},
{
value: formatPrice(
intl,
selectedRates.totalPrice.requested.price,
selectedRates.totalPrice.requested.currency,
selectedRates.totalPrice.requested.additionalPrice,
selectedRates.totalPrice.requested
.additionalPriceCurrency
),
}
)}
</p>
</Typography>
) : null}
</div>
<div className={styles.prices}>
<Typography variant="Body/Paragraph/mdBold">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
data-testid="total-price"
>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
) : null}
</div>
</div>
<PriceDetailsModal
bookingCode={input.bookingCode}
defaultCurrency={
selectedRates.totalPrice.requested?.currency ??
selectedRates.totalPrice.local.currency
}
rooms={selectedRates.rates
.map((room, idx) => {
if (!room) {
return null
}
const mapped = mapToRoom({
isMember,
rate: room,
input,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
})
function getPrice(
room: NonNullable<(typeof selectedRates.rates)[number]>,
isMember: boolean
) {
switch (room.type) {
case "regular":
return {
regular: isMember
? (room.member?.localPrice ?? room.public?.localPrice)
: room.public?.localPrice,
}
case "campaign":
return {
campaign: isMember
? (room.member ?? room.public)
: room.public,
}
case "redemption":
return {
redemption: room.redemption,
}
case "code": {
if ("corporateCheque" in room) {
return {
corporateCheque: room.corporateCheque,
}
}
if ("voucher" in room) {
return {
voucher: room.voucher,
}
}
if ("public" in room) {
return {
regular: isMember
? (room.member?.localPrice ?? room.public?.localPrice)
: room.public?.localPrice,
}
}
}
default:
throw new Error("Unknown price type")
}
}
const p = getPrice(room!, isMember)
return {
...mapped,
idx,
getPriceForRoom: selectedRates.getPriceForRoom,
rateTitles,
price: p,
bedType: undefined,
breakfast: undefined,
breakfastIncluded:
room?.rateDefinition.breakfastIncluded ?? false,
rateDefinition: room.rateDefinition,
}
})
.filter((x) => !!x)}
fromDate={input.data?.booking.fromDate ?? ""}
toDate={input.data?.booking.toDate ?? ""}
totalPrice={selectedRates.totalPrice}
vat={selectedRates.vat}
/>
</div>
{!isMember && memberPrice ? (
<SignupPromoDesktop
memberPrice={{
amount: memberPrice.localPrice.pricePerStay,
currency: memberPrice.localPrice.currency,
}}
badgeContent={"✌️"}
/>
) : null}
</section>
)
}
function mapToRoom({
isMember,
rate,
input,
idx,
getPriceForRoom,
rateTitles,
}: {
isMember: boolean
rate: NonNullable<
ReturnType<typeof useSelectRateContext>["selectedRates"]["rates"][number]
>
input: ReturnType<typeof useSelectRateContext>["input"]
idx: number
getPriceForRoom: (roomIndex: number) => Price | null
rateTitles: ReturnType<typeof useRateTitles>
}) {
return {
adults: input.data?.booking.rooms[idx].adults || 0,
childrenInRoom: input.data?.booking.rooms[idx].childrenInRoom,
roomType: rate.roomInfo.roomType,
roomRate: rate,
cancellationText: rateTitles[rate.rate].title,
roomPrice: {
perNight: { local: { price: -1, currency: CurrencyEnum.SEK } },
perStay: getPriceForRoom(idx) ?? {
local: { price: -1, currency: CurrencyEnum.Unknown },
},
},
rateDetails: isMember
? (rate.rateDefinitionMember?.generalTerms ??
rate.rateDefinition.generalTerms)
: rate.rateDefinition.generalTerms,
packages: rate.roomInfo.selectedPackages,
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,279 @@
import { cx } from "class-variance-authority"
import { useIntl } from "react-intl"
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
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 Modal from "@scandic-hotels/design-system/Modal"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
import { isBookingCodeRate } from "../../utils"
import { getMemberPrice } from "../utils"
import styles from "./room.module.css"
import type { Child } from "@scandic-hotels/trpc/types/child"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
import type { Price } from "../../../../../../types/price"
interface RoomProps {
room: {
adults: number
childrenInRoom: Child[] | undefined
roomType: string
roomPrice: {
perNight: Price
perStay: Price
}
roomRate: Product
rateDetails: string[] | undefined
cancellationText: string
packages?: Packages
}
roomNumber: number
roomCount: number
isMember: boolean
}
export default function Room({
room,
roomNumber,
roomCount,
isMember,
}: RoomProps) {
const intl = useIntl()
const adults = room.adults
const childrenInRoom = room.childrenInRoom
const childrenBeds = childrenInRoom?.reduce(
(acc, value) => {
const bedType = Number(value.bed)
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
return acc
}
const count = acc.get(bedType) ?? 0
acc.set(bedType, count + 1)
return acc
},
new Map<ChildBedMapEnum, number>([
[ChildBedMapEnum.IN_CRIB, 0],
[ChildBedMapEnum.IN_EXTRA_BED, 0],
])
)
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
const memberPrice = getMemberPrice(room.roomRate)
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
const adultsMsg = intl.formatMessage(
{
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
},
{ totalAdults: adults }
)
const guestsParts = [adultsMsg]
if (childrenInRoom?.length) {
const childrenMsg = intl.formatMessage(
{
defaultMessage:
"{totalChildren, plural, one {# child} other {# children}}",
},
{ totalChildren: childrenInRoom.length }
)
guestsParts.push(childrenMsg)
}
const roomPackages = room.packages
return (
<>
<div className={styles.room} data-testid={`summary-room-${roomNumber}`}>
<div>
{roomCount > 1 ? (
<Typography variant="Body/Supporting text (caption)/smBold">
<p className={styles.roomTitle}>
{intl.formatMessage(
{
defaultMessage: "Room {roomIndex}",
},
{
roomIndex: roomNumber,
}
)}
</p>
</Typography>
) : null}
<div className={styles.entry}>
<div>
<Typography variant="Body/Paragraph/mdBold">
<p>{room.roomType}</p>
</Typography>
<Typography variant="Body/Supporting text (caption)/smRegular">
<div className={styles.additionalInformation}>
<p>{guestsParts.join(", ")}</p>
<p>{room.cancellationText}</p>
</div>
</Typography>
</div>
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.prices}>
<p
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{showMemberPrice
? formatPrice(
intl,
memberPrice.amount,
memberPrice.currency
)
: formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency,
room.roomPrice.perStay.local.additionalPrice,
room.roomPrice.perStay.local.additionalPriceCurrency
)}
</p>
{showDiscounted && room.roomPrice.perStay.local.price ? (
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
room.roomPrice.perStay.local.price,
room.roomPrice.perStay.local.currency
)}
</s>
) : null}
</div>
</Typography>
</div>
{room.rateDetails?.length ? (
<div className={styles.ctaWrapper}>
<Modal
trigger={
<Button
className={styles.termsButton}
variant="Text"
typography="Body/Supporting text (caption)/smBold"
wrapping={false}
>
{intl.formatMessage({
defaultMessage: "Rate details",
})}
<MaterialIcon
icon="chevron_right"
size={20}
color="CurrentColor"
/>
</Button>
}
title={room.cancellationText}
>
<div className={styles.terms}>
{room.rateDetails.map((info) => (
<Typography key={info} variant="Body/Paragraph/mdRegular">
<p className={styles.termsText}>
<MaterialIcon
icon="check"
color="Icon/Feedback/Success"
size={20}
className={styles.termsIcon}
/>
{info}
</p>
</Typography>
))}
</div>
</Modal>
</div>
) : null}
</div>
{childBedCrib ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Crib (child) × {count}",
},
{ count: childBedCrib }
)}
</p>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
</span>
</div>
</div>
</Typography>
) : null}
{childBedExtraBed ? (
<Typography variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<div>
<p>
{intl.formatMessage(
{
defaultMessage: "Extra bed (child) × {count}",
},
{
count: childBedExtraBed,
}
)}
</p>
<Typography variant="Body/Supporting text (caption)/smRegular">
<p>
{intl.formatMessage({
defaultMessage: "Subject to availability",
})}
</p>
</Typography>
</div>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(intl, 0, room.roomPrice.perStay.local.currency)}
</span>
</div>
</div>
</Typography>
) : null}
{roomPackages?.map((pkg) => (
<Typography key={pkg.code} variant="Body/Paragraph/mdRegular">
<div className={styles.entry}>
<p>{pkg.description}</p>
<div className={styles.prices}>
<span className={styles.price}>
{formatPrice(
intl,
pkg.localPrice.price,
pkg.localPrice.currency
)}
</span>
</div>
</div>
</Typography>
))}
</div>
<Divider color="Border/Divider/Subtle" />
</>
)
}

View File

@@ -0,0 +1,56 @@
.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;
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);
}

View File

@@ -0,0 +1,153 @@
"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 { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
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 { useSelectRateContext } from "../../../../../contexts/SelectRate/SelectRateContext"
import { useIsLoggedIn } from "../../../../../hooks/useIsLoggedIn"
import { isBookingCodeRate } from "../utils"
import SummaryContent from "./Content"
import styles from "./mobileSummary.module.css"
export function MobileSummary() {
const intl = useIntl()
const scrollY = useRef(0)
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const isUserLoggedIn = useIsLoggedIn()
const { selectedRates } = useSelectRateContext()
function toggleSummaryOpen() {
setIsSummaryOpen(!isSummaryOpen)
}
useEffect(() => {
if (isSummaryOpen) {
scrollY.current = window.scrollY
document.body.style.position = "fixed"
document.body.style.top = `-${scrollY.current}px`
document.body.style.width = "100%"
} else {
document.body.style.position = ""
document.body.style.top = ""
document.body.style.width = ""
window.scrollTo({
top: scrollY.current,
left: 0,
behavior: "instant",
})
}
return () => {
document.body.style.position = ""
document.body.style.top = ""
document.body.style.width = ""
}
}, [isSummaryOpen])
const containsBookingCodeRate = selectedRates.rates.find(
(r) => r && isBookingCodeRate(r)
)
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
if (!selectedRates.totalPrice) {
return null
}
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
? selectedRates.totalPrice.local.regularPrice
: 0
const showStrikeThroughPrice =
totalRegularPrice > selectedRates.totalPrice.local?.price
return (
<div className={styles.wrapper} data-open={isSummaryOpen}>
<div className={styles.content}>
<div className={styles.summaryAccordion}>
<SummaryContent
isMember={isUserLoggedIn}
toggleSummaryOpen={toggleSummaryOpen}
/>
</div>
</div>
<div className={styles.bottomSheet}>
<ButtonRAC
data-open={isSummaryOpen}
onPress={toggleSummaryOpen}
className={styles.priceDetailsButton}
>
<Typography variant="Body/Supporting text (caption)/smRegular">
<span className={styles.priceLabel}>
{intl.formatMessage({
defaultMessage: "Total price",
})}
</span>
</Typography>
<Typography variant="Title/Subtitle/lg">
<span
className={cx(styles.price, {
[styles.discounted]: showDiscounted,
})}
>
{formatPrice(
intl,
selectedRates.totalPrice.local.price,
selectedRates.totalPrice.local.currency,
selectedRates.totalPrice.local.additionalPrice,
selectedRates.totalPrice.local.additionalPriceCurrency
)}
</span>
</Typography>
{showDiscounted &&
showStrikeThroughPrice &&
selectedRates.totalPrice.local.regularPrice ? (
<Typography variant="Body/Paragraph/mdRegular">
<s className={styles.strikeThroughRate}>
{formatPrice(
intl,
selectedRates.totalPrice.local.regularPrice,
selectedRates.totalPrice.local.currency
)}
</s>
</Typography>
) : null}
<Typography variant="Body/Supporting text (caption)/smBold">
<span className={styles.seeDetails}>
<span>
{intl.formatMessage({
defaultMessage: "See details",
})}
</span>
<MaterialIcon
icon="chevron_right"
color="CurrentColor"
size={20}
/>
</span>
</Typography>
</ButtonRAC>
<Button
variant="Primary"
color="Primary"
size="Large"
type="submit"
typography="Body/Paragraph/mdBold"
isDisabled={selectedRates.state !== "ALL_SELECTED"}
>
{intl.formatMessage({
defaultMessage: "Continue",
})}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type {
Rate,
Room,
} from "../../../../../types/components/selectRate/selectRate"
import type { Price } from "../../../../../types/price"
export function mapRate(
room: Rate,
index: number,
bookingRooms: Room[],
packages: NonNullable<Packages>
) {
const rate = {
adults: bookingRooms[index].adults,
cancellationText: room.product.rateDefinition?.cancellationText ?? "",
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
rateDetails: room.product.rateDefinition?.generalTerms,
roomPrice: {
currency: CurrencyEnum.Unknown,
perNight: <Price>{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
},
requested: undefined,
},
perStay: <Price>{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
},
requested: undefined,
},
},
roomRate: room.product,
roomType: room.roomType,
packages,
}
if ("corporateCheque" in room.product) {
rate.roomPrice.currency = CurrencyEnum.CC
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.CC,
price: room.product.corporateCheque.localPrice.numberOfCheques,
additionalPrice:
room.product.corporateCheque.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.corporateCheque.localPrice.currency ??
CurrencyEnum.Unknown,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.CC,
price: room.product.corporateCheque.localPrice.numberOfCheques,
additionalPrice:
room.product.corporateCheque.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.corporateCheque.localPrice.currency ??
CurrencyEnum.Unknown,
}
} else if ("redemption" in room.product) {
rate.roomPrice.currency = CurrencyEnum.POINTS
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.POINTS,
price: room.product.redemption.localPrice.pointsPerNight,
additionalPrice:
room.product.redemption.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.POINTS,
price: room.product.redemption.localPrice.pointsPerStay,
additionalPrice:
room.product.redemption.localPrice.additionalPricePerStay,
additionalPriceCurrency:
room.product.redemption.localPrice.currency ?? CurrencyEnum.Unknown,
}
} else if ("voucher" in room.product) {
rate.roomPrice.currency = CurrencyEnum.Voucher
rate.roomPrice.perNight.local = {
currency: CurrencyEnum.Voucher,
price: room.product.voucher.numberOfVouchers,
}
rate.roomPrice.perStay.local = {
currency: CurrencyEnum.Voucher,
price: room.product.voucher.numberOfVouchers,
}
} else {
const currency =
room.product.public?.localPrice.currency ||
room.product.member?.localPrice.currency ||
CurrencyEnum.Unknown
rate.roomPrice.currency = currency
rate.roomPrice.perNight.local = {
currency,
price:
room.product.public?.localPrice.pricePerNight ||
room.product.member?.localPrice.pricePerNight ||
0,
}
rate.roomPrice.perStay.local = {
currency,
price:
room.product.public?.localPrice.pricePerStay ||
room.product.member?.localPrice.pricePerStay ||
0,
}
}
return rate
}

View File

@@ -0,0 +1,108 @@
.wrapper {
position: relative;
display: grid;
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 {
position: relative;
z-index: var(--default-modal-z-index);
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--Overlay-40);
z-index: var(--default-modal-overlay-z-index);
}
.bottomSheet {
display: grid;
grid-template-columns: 1fr 1fr;
padding: var(--Space-x2) var(--Space-x3) var(--Space-x5);
align-items: flex-start;
transition: all 0.5s ease-in-out;
width: 100vw;
}
.priceDetailsButton {
border-width: 0;
background-color: transparent;
text-align: start;
cursor: pointer;
padding: 0;
display: grid;
overflow: hidden;
transition: all 0.3s ease-in-out;
}
.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(--Space-x2) 0 var(--Space-x7);
}
}

View File

@@ -0,0 +1,105 @@
.summary {
border-radius: var(--Corner-radius-lg);
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
padding: var(--Spacing-x3);
height: 100%;
}
.header button {
display: grid;
grid-template-areas: "title button" "date date";
grid-template-columns: 1fr auto;
align-items: center;
width: 100%;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
}
.title {
grid-area: title;
}
.chevronIcon {
grid-area: button;
}
.date {
align-items: center;
display: flex;
gap: var(--Spacing-x1);
justify-content: flex-start;
grid-area: date;
}
.link {
margin-top: var(--Spacing-x1);
}
.addOns {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-one-and-half);
overflow-y: auto;
}
.rateDetailsPopover {
display: flex;
flex-direction: column;
gap: var(--Spacing-x-half);
max-width: 360px;
}
.entry {
display: flex;
gap: var(--Spacing-x-half);
justify-content: space-between;
}
.entry > :last-child {
justify-items: flex-end;
}
.total {
display: flex;
flex-direction: column;
gap: var(--Spacing-x2);
}
.bottomDivider {
display: none;
}
.modalContent {
width: 560px;
}
.terms {
margin-top: var(--Spacing-x3);
margin-bottom: var(--Spacing-x3);
}
.termsText:nth-child(n) {
display: flex;
margin-bottom: var(--Spacing-x1);
}
.terms .termsIcon {
margin-right: var(--Spacing-x1);
}
@media screen and (min-width: 1367px) {
.bottomDivider {
display: block;
}
.header {
display: block;
}
.summary .header .chevronButton {
display: none;
}
}

View File

@@ -0,0 +1,13 @@
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
export function getMemberPrice(roomRate: Product) {
if ("member" in roomRate && roomRate.member) {
return {
amount: roomRate.member.localPrice.pricePerStay,
currency: roomRate.member.localPrice.currency,
pricePerNight: roomRate.member.localPrice.pricePerNight,
}
}
return null
}

View File

@@ -0,0 +1,79 @@
"use client"
import { useRouter, useSearchParams } from "next/navigation"
import { useState, useTransition } from "react"
import { useSelectRateContext } from "../../../../contexts/SelectRate/SelectRateContext"
import { ErrorBoundary } from "../../../ErrorBoundary/ErrorBoundary"
import { DesktopSummary } from "./DesktopSummary"
import { MobileSummary } from "./MobileSummary"
import styles from "./rateSummary.module.css"
export function RateSummary() {
return (
<ErrorBoundary fallback={<div>Unable to render summary</div>}>
<InnerRateSummary />
</ErrorBoundary>
)
}
function InnerRateSummary() {
const { selectedRates, input } = useSelectRateContext()
const [isSubmitting, setIsSubmitting] = useState(false)
const router = useRouter()
const params = useSearchParams()
const [_, startTransition] = useTransition()
if (selectedRates.state === "NONE_SELECTED") {
return null
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setIsSubmitting(true)
startTransition(() => {
router.push(`details?${params}`)
})
}
const totalPriceToShow = selectedRates.totalPrice
if (
!totalPriceToShow ||
!selectedRates.rates.some((room) => room?.isSelected ?? false)
) {
return null
}
// 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
return (
<form
data-footer-spacing
action={`details?${params}`}
method="GET"
onSubmit={handleSubmit}
>
<div className={styles.summary}>
<div className={styles.content}>
<ErrorBoundary fallback={<div>Unable to render desktop summary</div>}>
<DesktopSummary
isSubmitting={isSubmitting}
input={input}
selectedRates={selectedRates}
bookingCode={input.data?.booking.bookingCode || ""}
/>
</ErrorBoundary>
</div>
<div className={styles.mobileSummary}>
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
<MobileSummary />
</ErrorBoundary>
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,122 @@
@keyframes slideUp {
0% {
bottom: -100%;
}
100% {
bottom: 0%;
}
}
.summary {
align-items: center;
animation: slideUp 300ms ease forwards;
background-color: var(--Base-Surface-Primary-light-Normal);
border-top: 1px solid var(--Base-Border-Subtle);
bottom: -100%;
left: 0;
position: fixed;
right: 0;
z-index: 99;
}
.content {
display: none;
}
.summaryPriceContainer {
display: flex;
flex-direction: row;
gap: var(--Spacing-x4);
padding-top: var(--Spacing-x2);
width: 100%;
}
.promoContainer {
display: none;
max-width: 264px;
}
.summaryPrice {
align-self: center;
display: flex;
width: 100%;
gap: var(--Spacing-x4);
}
.petInfo {
border-left: 1px solid var(--Primary-Light-On-Surface-Divider-subtle);
padding-left: var(--Spacing-x2);
display: none;
}
.summaryText {
display: none;
}
.summaryPriceTextDesktop {
align-self: center;
display: none;
}
.continueButton {
margin-left: auto;
height: fit-content;
width: 100%;
min-width: 140px;
}
.summaryPriceTextMobile {
white-space: nowrap;
}
.mobileSummary {
display: block;
}
@media (min-width: 1367px) {
.summary {
border-top: 1px solid var(--Base-Border-Subtle);
padding: var(--Spacing-x3) 0 var(--Spacing-x5);
}
.content {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
margin: 0 auto;
max-width: var(--max-width-page);
width: 100%;
}
.petInfo,
.promoContainer,
.summaryPriceTextDesktop {
display: block;
}
.summaryText {
display: flex;
gap: var(--Spacing-x2);
}
.summaryPriceTextMobile {
display: none;
}
.summaryPrice,
.continueButton {
width: auto;
}
.summaryPriceContainer {
width: auto;
padding: 0;
align-items: center;
}
.mobileSummary {
display: none;
}
}

View File

@@ -0,0 +1,260 @@
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
import { RateTypeEnum } from "@scandic-hotels/common/constants/rateType"
import { sumPackages } from "../../../../utils/SelectRate"
import type { Packages } from "@scandic-hotels/trpc/types/packages"
import type {
Product,
RedemptionProduct,
} from "@scandic-hotels/trpc/types/roomAvailability"
import type { Rate } from "../../../../types/components/selectRate/selectRate"
import type { Price } from "../../../../types/price"
export function calculateTotalPrice(
selectedRateSummary: Rate[],
isUserLoggedIn: boolean
) {
return selectedRateSummary.reduce<Price>(
(total, room, idx) => {
if (!("member" in room.product) || !("public" in room.product)) {
return total
}
const roomNr = idx + 1
const isMainRoom = roomNr === 1
let rate
if (isUserLoggedIn && isMainRoom && room.product.member) {
rate = room.product.member
} else if (room.product.public) {
rate = room.product.public
}
if (!rate) {
return total
}
const packagesPrice = room.packages.reduce(
(total, pkg) => {
total.local = total.local + pkg.localPrice.totalPrice
if (pkg.requestedPrice.totalPrice) {
total.requested = total.requested + pkg.requestedPrice.totalPrice
}
return total
},
{ local: 0, requested: 0 }
)
total.local.currency = rate.localPrice.currency
total.local.price =
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
if (rate.localPrice.regularPricePerStay) {
total.local.regularPrice =
(total.local.regularPrice || 0) +
rate.localPrice.regularPricePerStay +
packagesPrice.local
}
if (rate.requestedPrice) {
if (!total.requested) {
total.requested = {
currency: rate.requestedPrice.currency,
price: 0,
}
}
if (!total.requested.currency) {
total.requested.currency = rate.requestedPrice.currency
}
total.requested.price =
total.requested.price +
rate.requestedPrice.pricePerStay +
packagesPrice.requested
if (rate.requestedPrice.regularPricePerStay) {
total.requested.regularPrice =
(total.requested.regularPrice || 0) +
rate.requestedPrice.regularPricePerStay +
packagesPrice.requested
}
}
return total
},
{
local: {
currency: CurrencyEnum.Unknown,
price: 0,
regularPrice: undefined,
},
requested: undefined,
}
)
}
export function calculateRedemptionTotalPrice(
redemption: RedemptionProduct["redemption"],
packages: Packages | null
) {
const pkgsSum = sumPackages(packages)
let additionalPrice
if (redemption.localPrice.additionalPricePerStay) {
additionalPrice =
redemption.localPrice.additionalPricePerStay + pkgsSum.price
} else if (pkgsSum.price) {
additionalPrice = pkgsSum.price
}
let additionalPriceCurrency
if (redemption.localPrice.currency) {
additionalPriceCurrency = redemption.localPrice.currency
} else if (pkgsSum.currency) {
additionalPriceCurrency = pkgsSum.currency
}
return {
local: {
additionalPrice,
additionalPriceCurrency,
currency: CurrencyEnum.POINTS,
price: redemption.localPrice.pointsPerStay,
},
}
}
export function calculateVoucherPrice(selectedRateSummary: Rate[]) {
return selectedRateSummary.reduce<Price>(
(total, room) => {
if (!("voucher" in room.product)) {
return total
}
const rate = room.product.voucher
total.local.price = total.local.price + rate.numberOfVouchers
const pkgsSum = sumPackages(room.packages)
if (pkgsSum.price && pkgsSum.currency) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) + pkgsSum.price
total.local.additionalPriceCurrency = pkgsSum.currency
}
return total
},
{
local: {
currency: CurrencyEnum.Voucher,
price: 0,
},
requested: undefined,
}
)
}
export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
return selectedRateSummary.reduce<Price>(
(total, room) => {
if (!("corporateCheque" in room.product)) {
return total
}
const rate = room.product.corporateCheque
const pkgsSum = sumPackages(room.packages)
total.local.price = total.local.price + rate.localPrice.numberOfCheques
if (rate.localPrice.additionalPricePerStay) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) +
rate.localPrice.additionalPricePerStay +
pkgsSum.price
} else if (pkgsSum.price) {
total.local.additionalPrice =
(total.local.additionalPrice || 0) + pkgsSum.price
}
if (rate.localPrice.currency) {
total.local.additionalPriceCurrency = rate.localPrice.currency
}
if (rate.requestedPrice) {
if (!total.requested) {
total.requested = {
currency: CurrencyEnum.CC,
price: 0,
}
}
total.requested.price =
total.requested.price + rate.requestedPrice.numberOfCheques
if (rate.requestedPrice.additionalPricePerStay) {
total.requested.additionalPrice =
(total.requested.additionalPrice || 0) +
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 getTotalPrice(
mainRoomProduct: Rate | null,
rateSummary: Array<Rate | null>,
isUserLoggedIn: boolean
): Price | null {
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
if (summaryArray.some((rate) => "corporateCheque" in rate.product)) {
return calculateCorporateChequePrice(summaryArray)
}
if (!mainRoomProduct) {
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}
const { packages, product } = mainRoomProduct
// In case of reward night (redemption) or voucher only single room booking is supported by business rules
if ("redemption" in product) {
return calculateRedemptionTotalPrice(product.redemption, packages)
}
if ("voucher" in product) {
const voucherPrice = calculateVoucherPrice(summaryArray)
return voucherPrice
}
return calculateTotalPrice(summaryArray, isUserLoggedIn)
}
export function isBookingCodeRate(product: Product | undefined | null) {
if (!product) return false
if (
"corporateCheque" in product ||
"redemption" in product ||
"voucher" in product
) {
return true
} else {
if (product.public) {
return product.public.rateType !== RateTypeEnum.Regular
}
if (product.member) {
return product.member.rateType !== RateTypeEnum.Regular
}
return false
}
}