Merged in feat/sw-2874-move-select-rate (pull request #2750)
Approved-by: Joakim Jäderberg
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user