feat(SW-1717): rewrite select-rate to show all variants of rate-cards
This commit is contained in:
committed by
Michael Zetterberg
parent
adde77eaa9
commit
ebaea78fb3
@@ -97,11 +97,19 @@ export default function PriceDetailsTable({
|
||||
return (
|
||||
<table className={styles.priceDetailsTable}>
|
||||
{rooms.map((room, idx) => {
|
||||
const getMemberRate = idx === 0 && isMember
|
||||
const price =
|
||||
getMemberRate && room.roomRate.memberRate
|
||||
? room.roomRate.memberRate
|
||||
: room.roomRate.publicRate
|
||||
const isMainRoom = idx === 0
|
||||
const getMemberRate = isMainRoom && isMember
|
||||
|
||||
let price
|
||||
if (
|
||||
getMemberRate &&
|
||||
"member" in room.roomRate &&
|
||||
room.roomRate.member
|
||||
) {
|
||||
price = room.roomRate.member
|
||||
} else if ("public" in room.roomRate && room.roomRate.public) {
|
||||
price = room.roomRate.public
|
||||
}
|
||||
if (!price) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
formatPriceWithAdditionalPrice,
|
||||
} from "@/utils/numberFormatting"
|
||||
|
||||
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||
import PriceDetailsTable from "./PriceDetailsTable"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
@@ -27,7 +28,6 @@ import styles from "./summary.module.css"
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function Summary({
|
||||
booking,
|
||||
@@ -48,19 +48,21 @@ export default function Summary({
|
||||
)
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
return roomRate?.memberRate
|
||||
? {
|
||||
currency: roomRate.memberRate.localPrice.currency ?? "",
|
||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight ?? 0,
|
||||
amount: roomRate.memberRate.localPrice.pricePerStay ?? 0,
|
||||
}
|
||||
: null
|
||||
if ("member" in roomRate && roomRate.member) {
|
||||
return {
|
||||
amount: roomRate.member.localPrice.pricePerStay,
|
||||
currency: roomRate.member.localPrice.currency,
|
||||
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(room) => room.roomRate.publicRate?.rateType !== RateTypeEnum.Regular
|
||||
const containsBookingCodeRate = rooms.find((r) =>
|
||||
isBookingCodeRate(r.roomRate)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
|
||||
@@ -119,9 +121,8 @@ export default function Summary({
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||
const isBookingCodeRate =
|
||||
room.roomRate.publicRate?.rateType !== RateTypeEnum.Regular
|
||||
const showDiscounted = isBookingCodeRate || showMemberPrice
|
||||
const showDiscounted =
|
||||
isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
||||
|
||||
@@ -9,12 +9,13 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||
import { mapRate } from "./mapRate"
|
||||
import Summary from "./Summary"
|
||||
|
||||
import styles from "./mobileSummary.module.css"
|
||||
|
||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function MobileSummary({
|
||||
@@ -69,54 +70,12 @@ export default function MobileSummary({
|
||||
return null
|
||||
}
|
||||
|
||||
const rateDefinitions = roomRateDefinitions.rateDefinitions
|
||||
const rooms = rateSummary.map((room, index) =>
|
||||
mapRate(room, index, bookingRooms)
|
||||
)
|
||||
|
||||
const rooms = rateSummary.map((room, index) => ({
|
||||
adults: bookingRooms[index].adults,
|
||||
childrenInRoom: bookingRooms[index].childrenInRoom ?? undefined,
|
||||
roomType: room.roomType,
|
||||
roomPrice: {
|
||||
perNight: {
|
||||
local: {
|
||||
price: (room.public?.localPrice.pricePerNight ||
|
||||
room.member?.localPrice.pricePerNight)!,
|
||||
currency: (room.public?.localPrice.currency ||
|
||||
room.member?.localPrice.currency)!,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
perStay: {
|
||||
local: {
|
||||
price: (room.public?.localPrice.pricePerStay ||
|
||||
room.member?.localPrice.pricePerStay)!,
|
||||
currency: (room.public?.localPrice.currency ||
|
||||
room.member?.localPrice.currency)!,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
currency: (room.public?.localPrice.currency ||
|
||||
room.member?.localPrice.currency)!,
|
||||
},
|
||||
roomRate: {
|
||||
...room.public,
|
||||
memberRate: room.member,
|
||||
publicRate: room.public,
|
||||
},
|
||||
rateDetails: rateDefinitions.find(
|
||||
(rate) =>
|
||||
rate.rateCode === room.public?.rateCode ||
|
||||
rate.rateCode === room.member?.rateCode
|
||||
)?.generalTerms,
|
||||
cancellationText:
|
||||
rateDefinitions.find(
|
||||
(rate) =>
|
||||
rate.rateCode === room.public?.rateCode ||
|
||||
rate.rateCode === room.member?.rateCode
|
||||
)?.cancellationText ?? "",
|
||||
}))
|
||||
|
||||
const containsBookingCodeRate = rateSummary.find(
|
||||
(rate) => rate.public?.rateType !== RateTypeEnum.Regular
|
||||
const containsBookingCodeRate = rateSummary.find((r) =>
|
||||
isBookingCodeRate(r.product)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export function isBookingCodeRate(product: Product) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
Rate,
|
||||
Room,
|
||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export function mapRate(room: Rate, index: number, bookingRooms: Room[]) {
|
||||
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: {
|
||||
local: {
|
||||
currency: CurrencyEnum.Unknown,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
perStay: {
|
||||
local: {
|
||||
currency: CurrencyEnum.Unknown,
|
||||
price: 0,
|
||||
},
|
||||
requested: undefined,
|
||||
},
|
||||
},
|
||||
roomRate: room.product,
|
||||
roomType: room.roomType,
|
||||
}
|
||||
|
||||
if ("corporateCheque" in room.product) {
|
||||
rate.roomPrice.currency = CurrencyEnum.CC
|
||||
rate.roomPrice.perNight.local = {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: room.product.corporateCheque.localPrice.additionalPricePerStay,
|
||||
}
|
||||
rate.roomPrice.perStay.local = {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: room.product.corporateCheque.localPrice.additionalPricePerStay,
|
||||
}
|
||||
} else if ("redemption" in room.product) {
|
||||
rate.roomPrice.currency = CurrencyEnum.POINTS
|
||||
rate.roomPrice.perNight.local = {
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: room.product.redemption.localPrice.pointsPerNight,
|
||||
}
|
||||
rate.roomPrice.perStay.local = {
|
||||
currency: CurrencyEnum.POINTS,
|
||||
price: room.product.redemption.localPrice.pointsPerStay,
|
||||
}
|
||||
} 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
|
||||
}
|
||||
@@ -21,17 +21,15 @@ import {
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import {
|
||||
calculateChequePrice,
|
||||
calculateCorporateChequePrice,
|
||||
calculateRedemptionTotalPrice,
|
||||
calculateTotalPrice,
|
||||
calculateVoucherPrice,
|
||||
} from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import {
|
||||
PointsPriceSchema,
|
||||
type Price,
|
||||
} from "@/types/components/hotelReservation/price"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
@@ -40,7 +38,6 @@ import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
const {
|
||||
bookingCode,
|
||||
isRedemption,
|
||||
bookingRooms,
|
||||
dates,
|
||||
petRoomPackage,
|
||||
@@ -105,7 +102,9 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
|
||||
const totalRoomsRequired = bookingRooms.length
|
||||
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
|
||||
const hasMemberRates = rateSummary.some((room) => room.member)
|
||||
const hasMemberRates = rateSummary.some(
|
||||
(room) => "member" in room.product && room.product.member
|
||||
)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
@@ -139,21 +138,31 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
}
|
||||
|
||||
const isBookingCodeRate = rateSummary.some(
|
||||
(rate) => rate.public?.rateType !== RateTypeEnum.Regular
|
||||
(rate) =>
|
||||
"public" in rate.product &&
|
||||
rate.product.public?.rateType !== RateTypeEnum.Regular
|
||||
)
|
||||
const isVoucherRate = rateSummary.some((rate) => "voucher" in rate.product)
|
||||
const isCorporateChequeRate = rateSummary.some(
|
||||
(rate) => "corporateCheque" in rate.product
|
||||
)
|
||||
const isVoucherRate = rateSummary.some((rate) => rate.voucher)
|
||||
const isChequeRate = rateSummary.some((rate) => rate.bonusCheque)
|
||||
const showDiscounted =
|
||||
isUserLoggedIn || isBookingCodeRate || isVoucherRate || isChequeRate
|
||||
isUserLoggedIn ||
|
||||
isBookingCodeRate ||
|
||||
isVoucherRate ||
|
||||
isCorporateChequeRate
|
||||
|
||||
const mainRoomProduct = rateSummary[0]
|
||||
let totalPriceToShow: Price
|
||||
if (isVoucherRate) {
|
||||
totalPriceToShow = calculateVoucherPrice(rateSummary)
|
||||
} else if (isChequeRate) {
|
||||
totalPriceToShow = calculateChequePrice(rateSummary)
|
||||
} else if (rateSummary[0].redemption) {
|
||||
if ("redemption" in mainRoomProduct.product) {
|
||||
// In case of reward night (redemption) only single room booking is supported by business rules
|
||||
totalPriceToShow = PointsPriceSchema.parse(rateSummary[0].redemption)
|
||||
totalPriceToShow = calculateRedemptionTotalPrice(
|
||||
mainRoomProduct.product.redemption
|
||||
)
|
||||
} else if ("voucher" in mainRoomProduct.product) {
|
||||
totalPriceToShow = calculateVoucherPrice(rateSummary)
|
||||
} else if ("corporateCheque" in mainRoomProduct.product) {
|
||||
totalPriceToShow = calculateCorporateChequePrice(rateSummary)
|
||||
} else {
|
||||
totalPriceToShow = calculateTotalPrice(
|
||||
rateSummary,
|
||||
@@ -162,40 +171,53 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
)
|
||||
}
|
||||
|
||||
let mainRoomCurrency = ""
|
||||
if (
|
||||
"member" in mainRoomProduct.product &&
|
||||
mainRoomProduct.product.member?.localPrice
|
||||
) {
|
||||
mainRoomCurrency = mainRoomProduct.product.member.localPrice.currency
|
||||
}
|
||||
if (
|
||||
!mainRoomCurrency &&
|
||||
"public" in mainRoomProduct.product &&
|
||||
mainRoomProduct.product.public?.localPrice
|
||||
) {
|
||||
mainRoomCurrency = mainRoomProduct.product.public.localPrice.currency
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
{rateSummary.map((room, index) => {
|
||||
return (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{rateSummary.map((room, index) => (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Render unselected rooms */}
|
||||
{Array.from({
|
||||
length: totalRoomsRequired - rateSummary.length,
|
||||
@@ -218,28 +240,34 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: rateSummary.reduce((total, room) => {
|
||||
const memberPrice = room.member?.localPrice.pricePerStay
|
||||
if (!memberPrice) {
|
||||
return total
|
||||
}
|
||||
const hasSelectedPetRoom =
|
||||
room.package === RoomPackageCodeEnum.PET_ROOM
|
||||
if (!hasSelectedPetRoom) {
|
||||
return total + memberPrice
|
||||
}
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice)
|
||||
: 0
|
||||
return total + memberPrice + petRoomPrice
|
||||
}, 0),
|
||||
currency: (rateSummary[0].member?.localPrice.currency ??
|
||||
rateSummary[0].public?.localPrice.currency)!,
|
||||
amount: rateSummary.reduce(
|
||||
(total, { features, package: roomPackage, product }) => {
|
||||
if (!("member" in product) || !product.member) {
|
||||
return total
|
||||
}
|
||||
const memberPrice =
|
||||
product.member.localPrice.pricePerStay
|
||||
if (!memberPrice) {
|
||||
return total
|
||||
}
|
||||
const hasSelectedPetRoom =
|
||||
roomPackage === RoomPackageCodeEnum.PET_ROOM
|
||||
if (!hasSelectedPetRoom) {
|
||||
return total + memberPrice
|
||||
}
|
||||
const isPetRoom = features.find(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice)
|
||||
: 0
|
||||
return total + memberPrice + petRoomPrice
|
||||
},
|
||||
0
|
||||
),
|
||||
currency: mainRoomCurrency,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,18 +5,27 @@ import {
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export const calculateTotalPrice = (
|
||||
export function calculateTotalPrice(
|
||||
selectedRateSummary: Rate[],
|
||||
isUserLoggedIn: boolean,
|
||||
petRoomPackage: RoomPackage | undefined
|
||||
) => {
|
||||
) {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room, idx) => {
|
||||
const rate =
|
||||
isUserLoggedIn && room.member && idx + 1 === 1
|
||||
? room.member
|
||||
: room.public
|
||||
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
|
||||
@@ -25,7 +34,6 @@ export const calculateTotalPrice = (
|
||||
const isPetRoom = room.features.find(
|
||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
|
||||
let petRoomPrice = 0
|
||||
if (
|
||||
petRoomPackage &&
|
||||
@@ -35,33 +43,47 @@ export const calculateTotalPrice = (
|
||||
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||
}
|
||||
|
||||
const regularPrice = rate.localPrice.regularPricePerStay
|
||||
? (total.local.regularPrice || 0) +
|
||||
(rate.localPrice.regularPricePerStay || 0)
|
||||
: undefined
|
||||
total.local.currency = rate.localPrice.currency
|
||||
total.local.price =
|
||||
total.local.price + rate.localPrice.pricePerStay + petRoomPrice
|
||||
|
||||
return {
|
||||
local: {
|
||||
currency: rate.localPrice.currency,
|
||||
price:
|
||||
total.local.price + rate.localPrice.pricePerStay + petRoomPrice,
|
||||
regularPrice,
|
||||
},
|
||||
requested: rate.requestedPrice
|
||||
? {
|
||||
currency: rate.requestedPrice.currency,
|
||||
price:
|
||||
(total.requested?.price ?? 0) +
|
||||
rate.requestedPrice.pricePerStay +
|
||||
petRoomPrice,
|
||||
}
|
||||
: undefined,
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
rate.localPrice.regularPricePerStay +
|
||||
petRoomPrice
|
||||
}
|
||||
|
||||
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 +
|
||||
petRoomPrice
|
||||
|
||||
if (rate.requestedPrice.regularPricePerStay) {
|
||||
total.requested.regularPrice =
|
||||
(total.requested.regularPrice || 0) +
|
||||
rate.requestedPrice.regularPricePerStay +
|
||||
petRoomPrice
|
||||
}
|
||||
}
|
||||
|
||||
return total
|
||||
},
|
||||
{
|
||||
local: {
|
||||
currency: (selectedRateSummary[0].public?.localPrice.currency ||
|
||||
selectedRateSummary[0].member?.localPrice.currency)!,
|
||||
currency: "",
|
||||
price: 0,
|
||||
regularPrice: undefined,
|
||||
},
|
||||
@@ -70,15 +92,32 @@ export const calculateTotalPrice = (
|
||||
)
|
||||
}
|
||||
|
||||
export const calculateVoucherPrice = (selectedRateSummary: Rate[]) => {
|
||||
export function calculateRedemptionTotalPrice(
|
||||
redemption: RedemptionProduct["redemption"]
|
||||
) {
|
||||
return {
|
||||
local: {
|
||||
additionalPrice: redemption.localPrice.additionalPricePerStay
|
||||
? redemption.localPrice.additionalPricePerStay
|
||||
: undefined,
|
||||
additionalPriceCurrency: redemption.localPrice.currency
|
||||
? redemption.localPrice.currency
|
||||
: undefined,
|
||||
currency: "PTS",
|
||||
price: redemption.localPrice.pointsPerStay,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateVoucherPrice(selectedRateSummary: Rate[]) {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room) => {
|
||||
const rate = room.voucher
|
||||
if (!rate) {
|
||||
if (!("voucher" in room.product)) {
|
||||
return total
|
||||
}
|
||||
const rate = room.product.voucher
|
||||
|
||||
return <Price>{
|
||||
return {
|
||||
local: {
|
||||
currency: total.local.currency,
|
||||
price: total.local.price + rate.numberOfVouchers,
|
||||
@@ -96,49 +135,47 @@ export const calculateVoucherPrice = (selectedRateSummary: Rate[]) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const calculateChequePrice = (selectedRateSummary: Rate[]) => {
|
||||
export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
|
||||
return selectedRateSummary.reduce<Price>(
|
||||
(total, room) => {
|
||||
const rate = room.bonusCheque
|
||||
if (!rate) {
|
||||
if (!("corporateCheque" in room.product)) {
|
||||
return total
|
||||
}
|
||||
const rate = room.product.corporateCheque
|
||||
|
||||
const price = total.local.price + rate.localPrice.numberOfBonusCheques
|
||||
|
||||
const additionalPrice =
|
||||
rate.localPrice.numberOfBonusCheques &&
|
||||
(total.local.additionalPrice ?? 0) +
|
||||
(rate.localPrice.additionalPricePerStay ?? 0)
|
||||
const additionalPriceCurrency = (rate.localPrice.numberOfBonusCheques &&
|
||||
rate.localPrice.currency)!
|
||||
|
||||
const requestedPrice = rate.requestedPrice?.numberOfBonusCheques
|
||||
? (total.requested?.price ?? 0) +
|
||||
rate.requestedPrice?.numberOfBonusCheques
|
||||
: total.requested?.price
|
||||
|
||||
const requestedAdditionalPrice =
|
||||
rate.requestedPrice?.additionalPricePerStay &&
|
||||
(total.requested?.additionalPrice ?? 0) +
|
||||
(rate.requestedPrice?.additionalPricePerStay ?? 0)
|
||||
|
||||
return <Price>{
|
||||
local: {
|
||||
currency: CurrencyEnum.CC,
|
||||
price,
|
||||
additionalPrice,
|
||||
additionalPriceCurrency,
|
||||
},
|
||||
requested: rate.requestedPrice
|
||||
? {
|
||||
currency: CurrencyEnum.CC,
|
||||
price: requestedPrice,
|
||||
additionalPrice: requestedAdditionalPrice,
|
||||
additionalPriceCurrency: rate.requestedPrice?.currency,
|
||||
}
|
||||
: undefined,
|
||||
total.local.price = total.local.price + rate.localPrice.numberOfCheques
|
||||
if (rate.localPrice.additionalPricePerStay) {
|
||||
total.local.additionalPrice =
|
||||
(total.local.additionalPrice || 0) +
|
||||
rate.localPrice.additionalPricePerStay
|
||||
}
|
||||
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: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.bookingCodeFilter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookingCodeFilterSelect {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.bookingCodeFilter {
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Select from "@/components/TempDesignSystem/Select"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import styles from "./bookingCodeFilter.module.css"
|
||||
|
||||
import type { Key } from "react"
|
||||
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function BookingCodeFilter() {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
actions: { selectFilter },
|
||||
selectedFilter,
|
||||
rooms,
|
||||
} = useRoomContext()
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
|
||||
const bookingCodeFilterItems = [
|
||||
{
|
||||
label: intl.formatMessage({ id: "Discounted rooms" }),
|
||||
value: BookingCodeFilterEnum.Discounted,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "Full price rooms" }),
|
||||
value: BookingCodeFilterEnum.Regular,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({ id: "See all" }),
|
||||
value: BookingCodeFilterEnum.All,
|
||||
},
|
||||
]
|
||||
|
||||
function handleChangeFilter(selectedFilter: Key) {
|
||||
selectFilter(selectedFilter as BookingCodeFilterEnum)
|
||||
}
|
||||
|
||||
const hideFilterDespiteBookingCode = rooms.every((room) =>
|
||||
room.products.every((product) => {
|
||||
const isRedemption = Array.isArray(product)
|
||||
if (isRedemption) {
|
||||
return true
|
||||
}
|
||||
const isCorporateCheque =
|
||||
product.rateDefinition?.rateType === RateTypeEnum.CorporateCheque
|
||||
const isVoucher =
|
||||
product.rateDefinition?.rateType === RateTypeEnum.Voucher
|
||||
return isCorporateCheque || isVoucher
|
||||
})
|
||||
)
|
||||
|
||||
if ((bookingCode && hideFilterDespiteBookingCode) || !bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<Select
|
||||
aria-label={intl.formatMessage({ id: "Booking Code filter" })}
|
||||
className={styles.bookingCodeFilterSelect}
|
||||
name="bookingCodeFilter"
|
||||
onSelect={handleChangeFilter}
|
||||
label=""
|
||||
items={bookingCodeFilterItems}
|
||||
defaultSelectedKey={selectedFilter}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import styles from "./selectedRoomPanel.module.css"
|
||||
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
export default function SelectedRoomPanel() {
|
||||
const intl = useIntl()
|
||||
@@ -58,10 +59,35 @@ export default function SelectedRoomPanel() {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedProduct =
|
||||
isUserLoggedIn && isMainRoom && selectedRate.product?.member
|
||||
? selectedRate.product?.member
|
||||
: selectedRate.product?.public
|
||||
let selectedProduct
|
||||
let isPerNight = true
|
||||
if (
|
||||
isUserLoggedIn &&
|
||||
isMainRoom &&
|
||||
"member" in selectedRate.product &&
|
||||
selectedRate.product.member
|
||||
) {
|
||||
const { localPrice } = selectedRate.product.member
|
||||
selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}`
|
||||
} else if ("public" in selectedRate.product && selectedRate.product.public) {
|
||||
const { localPrice } = selectedRate.product.public
|
||||
selectedProduct = `${localPrice.pricePerNight} ${localPrice.currency}`
|
||||
} else if ("corporateCheque" in selectedRate.product) {
|
||||
isPerNight = false
|
||||
const { localPrice } = selectedRate.product.corporateCheque
|
||||
selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
|
||||
if (localPrice.additionalPricePerStay && localPrice.currency) {
|
||||
selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay} ${localPrice.currency}`
|
||||
}
|
||||
} else if ("voucher" in selectedRate.product) {
|
||||
isPerNight = false
|
||||
selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
|
||||
}
|
||||
|
||||
if (!selectedProduct) {
|
||||
console.error("Selected product is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
@@ -79,9 +105,7 @@ export default function SelectedRoomPanel() {
|
||||
{getRateTitle(selectedRate.product.rate)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">
|
||||
{selectedProduct?.localPrice.pricePerNight}{" "}
|
||||
{selectedProduct?.localPrice.currency}/
|
||||
{intl.formatMessage({ id: "night" })}
|
||||
{`${selectedProduct}${isPerNight ? "/" + intl.formatMessage({ id: "night" }) : ""}`}
|
||||
</Body>
|
||||
</div>
|
||||
<div className={styles.imageContainer}>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.hotelAlert {
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./alert.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
|
||||
export default function NoAvailabilityAlert() {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
const { rooms } = useRoomContext()
|
||||
|
||||
const noAvailableRooms = rooms.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
|
||||
if (noAvailableRooms) {
|
||||
const text = intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={text}
|
||||
link={{
|
||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isPublicPromotionWithCode = rooms.some((room) => {
|
||||
const filteredCampaigns = room.campaign.filter(Boolean)
|
||||
return filteredCampaigns.length
|
||||
? filteredCampaigns.every(
|
||||
(product) => !!product.rateDefinition?.isCampaignRate
|
||||
)
|
||||
: false
|
||||
})
|
||||
|
||||
const noAvailableBookingCodeRooms =
|
||||
!isPublicPromotionWithCode &&
|
||||
rooms.every(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.NotAvailable || !room.code.length
|
||||
)
|
||||
|
||||
if (bookingCode && noAvailableBookingCodeRooms) {
|
||||
const bookingCodeText = intl.formatMessage(
|
||||
{
|
||||
id: "We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||
},
|
||||
{ bookingCode }
|
||||
)
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={bookingCodeText}
|
||||
link={{
|
||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import { calculatePricesPerNight } from "./utils"
|
||||
|
||||
import styles from "./priceList.module.css"
|
||||
|
||||
import type { PriceListProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function PriceList({
|
||||
publicPrice = {},
|
||||
memberPrice = {},
|
||||
petRoomPackage,
|
||||
rateName,
|
||||
}: PriceListProps) {
|
||||
const intl = useIntl()
|
||||
const { isMainRoom } = useRoomContext()
|
||||
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
|
||||
|
||||
const { localPrice: publicLocalPrice, requestedPrice: publicRequestedPrice } =
|
||||
publicPrice
|
||||
const { localPrice: memberLocalPrice, requestedPrice: memberRequestedPrice } =
|
||||
memberPrice
|
||||
|
||||
const petRoomLocalPrice = petRoomPackage?.localPrice
|
||||
const petRoomRequestedPrice = petRoomPackage?.requestedPrice
|
||||
|
||||
const showRequestedPrice =
|
||||
(publicRequestedPrice &&
|
||||
memberRequestedPrice &&
|
||||
publicRequestedPrice.currency !== publicLocalPrice.currency) ||
|
||||
(publicPrice.rateType !== RateTypeEnum.Regular && publicRequestedPrice)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const fromDate = searchParams.get("fromDate")
|
||||
const toDate = searchParams.get("toDate")
|
||||
|
||||
let nights = 1
|
||||
|
||||
if (fromDate && toDate) {
|
||||
nights = dt(toDate).diff(dt(fromDate), "days")
|
||||
}
|
||||
|
||||
const {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
} = calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
})
|
||||
|
||||
// When it is Promotion rate either booking code rate or public
|
||||
// Show striked Regular rate which is overtaken by the Promotional rate when member rate is not available
|
||||
const showOvertakingPrice = !!(
|
||||
!memberLocalPrice && publicLocalPrice.regularPricePerNight
|
||||
)
|
||||
|
||||
const priceLabelColor =
|
||||
rateName && !memberLocalPrice ? "red" : "uiTextHighContrast"
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
{isUserLoggedIn && isMainRoom && memberLocalPrice ? null : (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
{
|
||||
<Caption
|
||||
type="bold"
|
||||
color={
|
||||
totalPublicLocalPricePerNight ? priceLabelColor : "disabled"
|
||||
}
|
||||
>
|
||||
{rateName
|
||||
? rateName
|
||||
: intl.formatMessage({ id: "Standard price" })}
|
||||
</Caption>
|
||||
}
|
||||
</dt>
|
||||
<dd>
|
||||
{publicLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color={priceLabelColor}>
|
||||
{totalPublicLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color={priceLabelColor} textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Subtitle type="two" color="baseTextDisabled">
|
||||
{intl.formatMessage({ id: "N/A" })}
|
||||
</Subtitle>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{memberLocalPrice && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold" color={memberLocalPrice ? "red" : "disabled"}>
|
||||
{intl.formatMessage({ id: "Member price" })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
{memberLocalPrice ? (
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{totalMemberLocalPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="red" textTransform="bold">
|
||||
{memberLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
) : (
|
||||
<Body textTransform="bold" color="disabled">
|
||||
-
|
||||
</Body>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{showOvertakingPrice && (
|
||||
<div className={`${styles.priceRow} ${styles.alignEnd}`}>
|
||||
<dd>
|
||||
<div className={styles.priceStriked}>
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{publicLocalPrice.regularPricePerNight}
|
||||
</Subtitle>
|
||||
<Body color="uiTextHighContrast" textTransform="bold">
|
||||
{publicLocalPrice.currency}
|
||||
<span className={styles.perNight}>
|
||||
/{intl.formatMessage({ id: "night" })}
|
||||
</span>
|
||||
</Body>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{showRequestedPrice && (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{totalMemberRequestedPricePerNight
|
||||
? isUserLoggedIn
|
||||
? intl.formatMessage(
|
||||
{ id: "{memberPrice} {currency}" },
|
||||
{
|
||||
memberPrice: totalMemberRequestedPricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{publicPrice}/{memberPrice} {currency}" },
|
||||
{
|
||||
publicPrice: totalPublicRequestedPricePerNight,
|
||||
memberPrice: totalMemberRequestedPricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{price} {currency}" },
|
||||
{
|
||||
price: publicRequestedPrice.pricePerNight,
|
||||
currency: publicRequestedPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
.priceList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.pointsRow {
|
||||
justify-content: flex-start;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.priceTable {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.priceStriked {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.perNight {
|
||||
font-weight: 400;
|
||||
font-size: var(--typography-Caption-Regular-fontSize);
|
||||
}
|
||||
|
||||
.alignEnd {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { CalculatePricesPerNightProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
|
||||
export function calculatePricesPerNight({
|
||||
publicLocalPrice,
|
||||
memberLocalPrice,
|
||||
publicRequestedPrice,
|
||||
memberRequestedPrice,
|
||||
petRoomLocalPrice,
|
||||
petRoomRequestedPrice,
|
||||
nights,
|
||||
}: CalculatePricesPerNightProps) {
|
||||
const totalPublicLocalPricePerNight = publicLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(publicLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberLocalPricePerNight = memberLocalPrice
|
||||
? petRoomLocalPrice
|
||||
? Math.floor(
|
||||
Number(memberLocalPrice.pricePerNight) +
|
||||
Number(petRoomLocalPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberLocalPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalPublicRequestedPricePerNight = publicRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(publicRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(publicRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
const totalMemberRequestedPricePerNight = memberRequestedPrice
|
||||
? petRoomRequestedPrice
|
||||
? Math.floor(
|
||||
Number(memberRequestedPrice.pricePerNight) +
|
||||
Number(petRoomRequestedPrice.price) / nights
|
||||
)
|
||||
: Math.floor(Number(memberRequestedPrice.pricePerNight))
|
||||
: undefined
|
||||
|
||||
return {
|
||||
totalPublicLocalPricePerNight,
|
||||
totalMemberLocalPricePerNight,
|
||||
totalPublicRequestedPricePerNight,
|
||||
totalMemberRequestedPricePerNight,
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
.card,
|
||||
.noPricesCard {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noPricesCard {
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.noPricesCard:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"].radio {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card .checkIcon {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
height: 24px;
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--Spacing-x1);
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.priceType {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
grid-area: chevron;
|
||||
height: 100%;
|
||||
justify-self: flex-end;
|
||||
padding: 1px 0 0 0;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.noPricesLabel {
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
text-align: center;
|
||||
background-color: var(--Base-Surface-Subtle-Normal);
|
||||
border-radius: var(--Corner-radius-Rounded);
|
||||
margin: 0 auto var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.terms {
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.termsIcon {
|
||||
padding-right: var(--Spacing-x1);
|
||||
flex-shrink: 0;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import PriceTable from "./PriceList"
|
||||
|
||||
import styles from "./flexibilityOption.module.css"
|
||||
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function FlexibilityOption({
|
||||
features,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
petRoomPackage,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
rateName,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
|
||||
const {
|
||||
actions: { selectRate },
|
||||
isMainRoom,
|
||||
roomNr,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
|
||||
function handleSelect() {
|
||||
if (product) {
|
||||
selectRate({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isBookingCodeRate = product?.public?.rateType !== RateTypeEnum.Regular
|
||||
if (
|
||||
!product ||
|
||||
(isMainRoom && isUserLoggedIn && !product.member && !isBookingCodeRate)
|
||||
) {
|
||||
return (
|
||||
<div className={styles.noPricesCard}>
|
||||
<div className={styles.header}>
|
||||
<MaterialIcon
|
||||
icon="info"
|
||||
size={16}
|
||||
color="Icon/Interactive/Placeholder"
|
||||
/>
|
||||
<div className={styles.priceType}>
|
||||
<Caption>{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Label size="regular" className={styles.noPricesLabel}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "No prices available" })}
|
||||
</Caption>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const productMember = product.member
|
||||
const selectedRateMember = selectedRate?.product.member
|
||||
const bothMemberExist = productMember && selectedRateMember
|
||||
const selectedRateIsMember =
|
||||
bothMemberExist && productMember.rateCode === selectedRateMember.rateCode
|
||||
const productPublic = product.public
|
||||
const selectedRatePublic = selectedRate?.product.public
|
||||
const bothPublicExist = productPublic && selectedRatePublic
|
||||
const selectedRateIsPublic =
|
||||
bothPublicExist && productPublic.rateCode === selectedRatePublic.rateCode
|
||||
const isSelected = !!(
|
||||
(selectedRateIsMember || selectedRateIsPublic) &&
|
||||
selectedRate?.roomTypeCode === roomTypeCode
|
||||
)
|
||||
|
||||
const rate = (
|
||||
isUserLoggedIn && isMainRoom && product.member && !isBookingCodeRate
|
||||
? product.member
|
||||
: product.public
|
||||
)!
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
checked={isSelected}
|
||||
className={styles.radio}
|
||||
name={`rateCode-${roomNr}-${rate.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={rate.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon
|
||||
icon="info"
|
||||
size={16}
|
||||
color="Icon/Interactive/Placeholder"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={rateName ? rateName : title}
|
||||
subtitle={rateName ? `${title} (${paymentTerm})` : paymentTerm}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{priceInformation?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<PriceTable
|
||||
memberPrice={product.member}
|
||||
petRoomPackage={petRoomPackage}
|
||||
publicPrice={product.public}
|
||||
rateName={rateName}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.priceList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./chequePrice.module.css"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function ChequePrice({
|
||||
chequePrice,
|
||||
rateTitle,
|
||||
}: {
|
||||
chequePrice: NonNullable<Product["bonusCheque"]>
|
||||
rateTitle: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold" color="red">
|
||||
{rateTitle}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{chequePrice.localPrice.numberOfBonusCheques}
|
||||
</Subtitle>
|
||||
<Body color="red">{CurrencyEnum.CC}</Body>
|
||||
{chequePrice.localPrice.additionalPricePerStay ? (
|
||||
<>
|
||||
<Body color="red">{" + "}</Body>
|
||||
<Subtitle type="two" color="red">
|
||||
{chequePrice.localPrice.additionalPricePerStay}
|
||||
</Subtitle>
|
||||
<Body color="red">{chequePrice.localPrice.currency}</Body>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
{chequePrice.requestedPrice?.additionalPricePerStay ? (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold">
|
||||
{intl.formatMessage({ id: "Approx." })}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<div className={styles.price}>
|
||||
<Caption>
|
||||
{intl.formatMessage(
|
||||
{ id: "{price} {currency}" },
|
||||
{
|
||||
price: chequePrice.requestedPrice.numberOfBonusCheques,
|
||||
currency: CurrencyEnum.CC,
|
||||
}
|
||||
)}
|
||||
{" + "}
|
||||
{intl.formatMessage(
|
||||
{ id: "{price} {currency}" },
|
||||
{
|
||||
price: chequePrice.requestedPrice.additionalPricePerStay,
|
||||
currency: chequePrice.requestedPrice.currency,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
.card {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"].radio {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card .checkIcon {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.priceType {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terms {
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.termsIcon {
|
||||
padding-right: var(--Spacing-x1);
|
||||
flex-shrink: 0;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import ChequePrice from "./ChequePrice"
|
||||
|
||||
import styles from "./flexibilityOptionCheque.module.css"
|
||||
|
||||
import type { FlexibilityOptionChequeProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOptionCheque({
|
||||
features,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
rateName,
|
||||
}: FlexibilityOptionChequeProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
actions: { selectRateCheque },
|
||||
roomNr,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
|
||||
if (!product.bonusCheque) {
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
selectRateCheque({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
|
||||
const voucherRate = product.bonusCheque
|
||||
const isSelected = !!(
|
||||
selectedRate?.product.bonusCheque &&
|
||||
selectedRate?.product.bonusCheque.rateCode === voucherRate?.rateCode &&
|
||||
selectedRate?.roomTypeCode === roomTypeCode
|
||||
)
|
||||
|
||||
const rate = product.bonusCheque
|
||||
const chequeRateName =
|
||||
rateName ?? intl.formatMessage({ id: "Corporate Cheque" })
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
checked={isSelected}
|
||||
className={styles.radio}
|
||||
name={`rateCode-${roomNr}-${rate.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={rate.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon icon="info" size={16} color="Icon/Default" />
|
||||
</Button>
|
||||
}
|
||||
title={chequeRateName}
|
||||
subtitle={`${title} (${paymentTerm})`}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{priceInformation?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<ChequePrice
|
||||
chequePrice={product.bonusCheque}
|
||||
rateTitle={chequeRateName}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./pointsList.module.css"
|
||||
|
||||
import type { ProductTypePoints } from "@/types/trpc/routers/hotel/availability"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function PointsList({
|
||||
product,
|
||||
handleSelect,
|
||||
redemptions,
|
||||
}: {
|
||||
product: Product
|
||||
handleSelect: (product: Product, selectedRateCode: string) => void
|
||||
redemptions: ProductTypePoints[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
return (
|
||||
<div className={styles.pointsList}>
|
||||
{redemptions.map((redemption) => (
|
||||
<label key={redemption.rateCode} className={styles.pointsRow}>
|
||||
{/* ToDo Handle with appropriate Input or Radio component in UI implementation ticket */}
|
||||
<input
|
||||
name="redepmtionRate"
|
||||
onChange={() => {
|
||||
handleSelect(product, redemption.rateCode)
|
||||
}}
|
||||
type="radio"
|
||||
value={redemption.rateCode}
|
||||
/>
|
||||
<Subtitle>{redemption.localPrice.pointsPerStay}</Subtitle>
|
||||
<Caption>
|
||||
{" "}
|
||||
{intl.formatMessage({ id: "Points" })}
|
||||
{redemption.localPrice.additionalPricePerStay &&
|
||||
redemption.localPrice.additionalPriceCurrency
|
||||
? ` + ${formatPrice(
|
||||
intl,
|
||||
redemption.localPrice.additionalPricePerStay,
|
||||
redemption.localPrice.additionalPriceCurrency
|
||||
)}`
|
||||
: null}
|
||||
</Caption>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
.pointsList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pointsRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Label from "@/components/TempDesignSystem/Form/Label"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import PointsList from "./PointsList"
|
||||
|
||||
import styles from "../FlexibilityOption/flexibilityOption.module.css"
|
||||
|
||||
import type { FlexibilityOptionProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function FlexibilityOptionPoints({
|
||||
features,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
// Reward night rate tile obtianed from the ratedefinition
|
||||
rateName,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const rewardNightTitle =
|
||||
rateName ?? intl.formatMessage({ id: "Reward night" })
|
||||
const {
|
||||
actions: { selectRateRedemption },
|
||||
} = useRoomContext()
|
||||
|
||||
if (!product?.redemptions?.length) {
|
||||
return (
|
||||
<div className={styles.noPricesCard}>
|
||||
<div className={styles.header}>
|
||||
<MaterialIcon icon="info" size={16} />
|
||||
<div className={styles.priceType}>
|
||||
<Caption>{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<Label size="regular" className={styles.noPricesLabel}>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({ id: "No prices available" })}
|
||||
</Caption>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function handleSelect(product: Product, selectedRateCode?: string) {
|
||||
selectRateRedemption(
|
||||
{
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
},
|
||||
selectedRateCode
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
<Caption>
|
||||
{rewardNightTitle}
|
||||
{" ∙ "}
|
||||
{intl.formatMessage({ id: "Breakfast included" })}
|
||||
</Caption>
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon icon="info" size={16} />
|
||||
</Button>
|
||||
}
|
||||
title={rewardNightTitle}
|
||||
subtitle={`${title} ${paymentTerm}`}
|
||||
>
|
||||
{priceInformation?.length ? (
|
||||
<div className={styles.terms}>
|
||||
{priceInformation.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<PointsList
|
||||
product={product}
|
||||
handleSelect={handleSelect}
|
||||
redemptions={product.redemptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./voucherPrice.module.css"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function VoucherPrice({
|
||||
voucherPrice,
|
||||
rateTitle,
|
||||
}: {
|
||||
voucherPrice: NonNullable<Product["voucher"]>
|
||||
rateTitle: string
|
||||
}) {
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
<Caption type="bold" color="red">
|
||||
{rateTitle}
|
||||
</Caption>
|
||||
</dt>
|
||||
<dd>
|
||||
<div className={styles.price}>
|
||||
<Subtitle type="two" color="red">
|
||||
{voucherPrice.numberOfVouchers}
|
||||
</Subtitle>
|
||||
<Body color="red">{CurrencyEnum.Voucher}</Body>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.priceList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.priceRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.price {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: baseline;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
.card {
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
padding: var(--Spacing-x-one-and-half) var(--Spacing-x2);
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border: 1px solid var(--Base-Surface-Secondary-light-Normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x-half);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 100px;
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
border: 2px solid var(--Base-Border-Inverted);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="radio"].radio {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card {
|
||||
border: 1px solid var(--Primary-Dark-On-Surface-Divider);
|
||||
background-color: var(--Base-Surface-Primary-light-Hover-alt);
|
||||
}
|
||||
|
||||
input[type="radio"]:checked + .card .checkIcon {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.priceType {
|
||||
display: flex;
|
||||
gap: var(--Spacing-x-half);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terms {
|
||||
padding-top: var(--Spacing-x3);
|
||||
}
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.termsIcon {
|
||||
padding-right: var(--Spacing-x1);
|
||||
flex-shrink: 0;
|
||||
flex-basis: 32px;
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import VoucherPrice from "./VoucherPrice"
|
||||
|
||||
import styles from "./flexibilityOptionVoucher.module.css"
|
||||
|
||||
import type { FlexibilityOptionVoucherProps } from "@/types/components/hotelReservation/selectRate/flexibilityOption"
|
||||
|
||||
export default function FlexibilityOptionVoucher({
|
||||
features,
|
||||
paymentTerm,
|
||||
priceInformation,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
rateName,
|
||||
}: FlexibilityOptionVoucherProps) {
|
||||
const intl = useIntl()
|
||||
const {
|
||||
actions: { selectRateVoucher },
|
||||
roomNr,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
|
||||
if (!product.voucher) {
|
||||
return null
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
selectRateVoucher({
|
||||
features,
|
||||
product,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
})
|
||||
}
|
||||
|
||||
const voucherRate = product.voucher
|
||||
const isSelected = !!(
|
||||
selectedRate?.product.voucher &&
|
||||
selectedRate?.product.voucher.rateCode === voucherRate?.rateCode &&
|
||||
selectedRate?.roomTypeCode === roomTypeCode
|
||||
)
|
||||
|
||||
const rate = product.voucher
|
||||
const voucherRateName = rateName ?? intl.formatMessage({ id: "Voucher" })
|
||||
|
||||
return (
|
||||
<label>
|
||||
<input
|
||||
checked={isSelected}
|
||||
className={styles.radio}
|
||||
name={`rateCode-${roomNr}-${rate.rateCode}`}
|
||||
onChange={handleSelect}
|
||||
type="radio"
|
||||
value={rate.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button intent="text">
|
||||
<MaterialIcon icon="info" size={16} color="Icon/Default" />
|
||||
</Button>
|
||||
}
|
||||
title={voucherRateName}
|
||||
subtitle={`${title} (${paymentTerm})`}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{priceInformation?.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="check"
|
||||
color="Icon/Feedback/Success"
|
||||
size={20}
|
||||
className={styles.termsIcon}
|
||||
/>
|
||||
{info}
|
||||
</Body>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<div className={styles.priceType}>
|
||||
<Caption color="uiTextHighContrast">{title}</Caption>
|
||||
<Caption color="uiTextPlaceholder">({paymentTerm})</Caption>
|
||||
</div>
|
||||
</div>
|
||||
<VoucherPrice
|
||||
voucherPrice={product.voucher}
|
||||
rateTitle={voucherRateName}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
<MaterialIcon icon="check" color="Icon/Inverted" size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { createElement } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import { cardVariants } from "./cardVariants"
|
||||
import FlexibilityOption from "./FlexibilityOption"
|
||||
import FlexibilityOptionCheque from "./FlexibilityOptionCheque"
|
||||
import FlexibilityOptionPoints from "./FlexibilityOptionPoints"
|
||||
import FlexibilityOptionVoucher from "./FlexibilityOptionVoucher"
|
||||
import RoomSize from "./RoomSize"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RoomCardProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
import type {
|
||||
Product,
|
||||
RateDefinition,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
function getBreakfastMessage(
|
||||
publicBreakfastIncluded: boolean,
|
||||
memberBreakfastIncluded: boolean,
|
||||
hotelType: string | undefined,
|
||||
userIsLoggedIn: boolean,
|
||||
msgs: Record<
|
||||
"included" | "noSelection" | "scandicgo" | "notIncluded",
|
||||
string
|
||||
>,
|
||||
roomNr: number
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
/** selected and rate does not include breakfast */
|
||||
if (false) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
return msgs.noSelection
|
||||
}
|
||||
|
||||
export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
const intl = useIntl()
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const bookingCode = searchParams.get("bookingCode")
|
||||
const isRedemption = searchParams.get("searchtype") === REDEMPTION
|
||||
|
||||
const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } =
|
||||
useRatesStore((state) => ({
|
||||
hotelId: state.booking.hotelId,
|
||||
hotelType: state.hotelType,
|
||||
isUserLoggedIn: state.isUserLoggedIn,
|
||||
petRoomPackage: state.petRoomPackage,
|
||||
roomCategories: state.roomCategories,
|
||||
}))
|
||||
const { isMainRoom, roomAvailability, roomNr, selectedPackage } =
|
||||
useRoomContext()
|
||||
const showLowInventory =
|
||||
roomConfiguration.roomsLeft > 0 && roomConfiguration.roomsLeft < 5
|
||||
|
||||
if (!roomAvailability || !("rateDefinitions" in roomAvailability)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const classNames = cardVariants({
|
||||
availability:
|
||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
const breakfastMessages = {
|
||||
included: intl.formatMessage({ id: "Breakfast is included." }),
|
||||
notIncluded: intl.formatMessage({
|
||||
id: "Breakfast selection in next step.",
|
||||
}),
|
||||
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
||||
scandicgo: intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
}),
|
||||
}
|
||||
const breakfastMessage = getBreakfastMessage(
|
||||
roomConfiguration.breakfastIncludedInAllRatesPublic,
|
||||
roomConfiguration.breakfastIncludedInAllRatesMember,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
breakfastMessages,
|
||||
roomNr
|
||||
)
|
||||
|
||||
const petRoomPackageSelected =
|
||||
(selectedPackage === RoomPackageCodeEnum.PET_ROOM && petRoomPackage) ||
|
||||
undefined
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find(
|
||||
(roomType) => roomType.code === roomConfiguration.roomTypeCode
|
||||
)
|
||||
)
|
||||
|
||||
const { images, name, occupancy, roomSize } = selectedRoom || {}
|
||||
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||
|
||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||
const nonRefundable = intl.formatMessage({ id: "Non-refundable" })
|
||||
const freeBooking = intl.formatMessage({ id: "Free rebooking" })
|
||||
const payLater = intl.formatMessage({ id: "Pay later" })
|
||||
const payNow = intl.formatMessage({ id: "Pay now" })
|
||||
|
||||
function getRateTitle(rateCode: Product["rate"]) {
|
||||
switch (rateCode) {
|
||||
case "change":
|
||||
return freeBooking
|
||||
case "flex":
|
||||
return freeCancelation
|
||||
case "save":
|
||||
return nonRefundable
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get terms and rate title from the rate definitions when booking code rate
|
||||
* or public promotion is in play. Returns undefined when product is not available
|
||||
*
|
||||
* In case of redemption it will always return first redemption as terms
|
||||
* and title are same for all various redemption rates
|
||||
*
|
||||
* @param product - Either public or member product type
|
||||
* @param rateDefinitions - List of rate definitions
|
||||
* @returns RateDefinition | undefined
|
||||
*/
|
||||
function getRateDefinition(
|
||||
product: Product,
|
||||
rateDefinitions: RateDefinition[]
|
||||
) {
|
||||
let rateCode = ""
|
||||
if (isUserLoggedIn && product.member && isMainRoom) {
|
||||
rateCode = product.member.rateCode
|
||||
} else if (product.public?.rateCode) {
|
||||
rateCode = product.public.rateCode
|
||||
} else if (product.voucher) {
|
||||
rateCode = product.voucher.rateCode
|
||||
} else if (product.bonusCheque) {
|
||||
rateCode = product.bonusCheque.rateCode
|
||||
} else if (product.redemptions?.length) {
|
||||
// In case of redemption there will be same rate terms and title
|
||||
// irrespective of ratecodes
|
||||
return rateDefinitions[0]
|
||||
}
|
||||
return rateDefinitions.find(
|
||||
(rateDefinition) => rateDefinition.rateCode === rateCode
|
||||
)
|
||||
}
|
||||
|
||||
const isBookingCodeRate =
|
||||
bookingCode &&
|
||||
roomConfiguration.products.every((item) => {
|
||||
return item.public?.rateType !== RateTypeEnum.Regular
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{showLowInventory ? (
|
||||
<span className={styles.chip}>
|
||||
<Footnote color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount, number} left" },
|
||||
{ amount: roomConfiguration.roomsLeft }
|
||||
)}
|
||||
</Footnote>
|
||||
</span>
|
||||
) : null}
|
||||
{roomConfiguration.features
|
||||
.filter((feature) => selectedPackage === feature.code)
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{createElement(() => (
|
||||
<IconForFeatureCode
|
||||
featureCode={feature.code}
|
||||
color={"Icon/Interactive/Default"}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
title={roomConfiguration.roomType}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.specification}>
|
||||
{occupancy && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{occupancy.max === occupancy.min
|
||||
? intl.formatMessage(
|
||||
{ id: "{guests, plural, one {# guest} other {# guests}}" },
|
||||
{ guests: occupancy.max }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{min}-{max} guests" },
|
||||
{
|
||||
min: occupancy.min,
|
||||
max: occupancy.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
<RoomSize roomSize={roomSize} />
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomConfiguration.roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId.toString()}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
title={intl.formatMessage({ id: "Room details" })}
|
||||
intent="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
<div className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
<>
|
||||
{/** The empty div is used to allow for subgrid to align rows */}
|
||||
<div></div>
|
||||
<div className={styles.noRoomsContainer}>
|
||||
<div className={styles.noRooms}>
|
||||
<MaterialIcon
|
||||
icon="error"
|
||||
color="Icon/Interactive/Accent"
|
||||
size={16}
|
||||
/>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
id: "This room is not available",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
{isRedemption ? null : (
|
||||
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
|
||||
)}
|
||||
{bookingCode ? (
|
||||
<span className={!isBookingCodeRate ? styles.strikedText : ""}>
|
||||
<MaterialIcon icon="sell" />
|
||||
{bookingCode}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{roomConfiguration.products.map((product) => {
|
||||
const rateTitle = getRateTitle(product.rate)
|
||||
const isAvailable =
|
||||
product.public ||
|
||||
(product.member && isUserLoggedIn && isMainRoom) ||
|
||||
product.redemptions?.length ||
|
||||
product.bonusCheque ||
|
||||
product.voucher
|
||||
const rateDefinition = getRateDefinition(
|
||||
product,
|
||||
roomAvailability.rateDefinitions
|
||||
)
|
||||
const props = {
|
||||
features: roomConfiguration.features,
|
||||
paymentTerm: product.isFlex ? payLater : payNow,
|
||||
petRoomPackage: petRoomPackageSelected,
|
||||
priceInformation: rateDefinition?.generalTerms,
|
||||
product: isAvailable ? product : undefined,
|
||||
roomType: roomConfiguration.roomType,
|
||||
roomTypeCode: roomConfiguration.roomTypeCode,
|
||||
title: rateTitle,
|
||||
rateName:
|
||||
isBookingCodeRate ||
|
||||
isRedemption ||
|
||||
product.voucher ||
|
||||
product.bonusCheque
|
||||
? rateDefinition?.title
|
||||
: undefined,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{isRedemption && (
|
||||
<FlexibilityOptionPoints key={product.rate} {...props} />
|
||||
)}
|
||||
{product.voucher ? (
|
||||
<FlexibilityOptionVoucher
|
||||
key={product.rate}
|
||||
{...props}
|
||||
rateName={rateDefinition?.title}
|
||||
product={product}
|
||||
/>
|
||||
) : null}
|
||||
{product.bonusCheque ? (
|
||||
<FlexibilityOptionCheque
|
||||
key={product.rate}
|
||||
{...props}
|
||||
rateName={rateDefinition?.title}
|
||||
product={product}
|
||||
/>
|
||||
) : null}
|
||||
{product.public || product.member ? (
|
||||
<FlexibilityOption key={product.rate} {...props} />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
.card {
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
grid-row: span 7;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: subgrid;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .card {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card.noAvailability {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
margin: 0 calc(-1 * var(--Spacing-x2));
|
||||
min-height: 190px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .imageContainer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chipContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
left: 12px;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.card .imageContainer img {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.specification {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggleSidePeek {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.specification .toggleSidePeek button {
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-row: span 4;
|
||||
grid-template-rows: subgrid;
|
||||
}
|
||||
|
||||
.noRooms {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin: 0;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
.strikedText {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { alternativeHotels } from "@/constants/routes/hotelReservation"
|
||||
import { useBookingCodeFilterStore } from "@/stores/bookingCode-filter"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import BookingCodeFilter from "@/components/HotelReservation/SelectHotel/BookingCodeFilter"
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import RoomCard from "./RoomCard"
|
||||
import RoomTypeFilter from "./RoomTypeFilter"
|
||||
|
||||
import styles from "./roomSelectionPanel.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import { AlertTypeEnum } from "@/types/enums/alert"
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function RoomSelectionPanel() {
|
||||
const { rooms } = useRoomContext()
|
||||
const isSingleRoomAndHasSelection = useRatesStore(
|
||||
(state) => state.booking.rooms.length === 1 && !!state.rateSummary.length
|
||||
)
|
||||
const searchParams = useSearchParams()
|
||||
const bookingCode = searchParams.get("bookingCode")
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const noAvailableRooms = rooms.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
const activeCodeFilter = useBookingCodeFilterStore(
|
||||
(state) => state.activeCodeFilter
|
||||
)
|
||||
|
||||
const isVoucherOrCorpChequeRate = rooms.find((room) =>
|
||||
room.products.some((product) => product.voucher || product.bonusCheque)
|
||||
)
|
||||
|
||||
let isRegularRatesAvailableWithCode = false,
|
||||
isBookingCodeRatesAvailable = false
|
||||
let visibleRooms = rooms
|
||||
if (bookingCode && !isVoucherOrCorpChequeRate) {
|
||||
// Regular Rates (Save, Change and Flex) always should send both public and member rates
|
||||
// so we can check public rates for availability
|
||||
isRegularRatesAvailableWithCode = rooms.some(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.Available &&
|
||||
room.products.some(
|
||||
(product) =>
|
||||
product.public?.rateType === RateTypeEnum.Regular ||
|
||||
product.member?.rateType === RateTypeEnum.Regular
|
||||
)
|
||||
)
|
||||
|
||||
// Booking codes rate comes with various rate types but Regular is reserved
|
||||
// for non-booking code rates (Save, Change & Flex)
|
||||
// With Booking code rates we will always obtain public rate and maybe a member rate
|
||||
// so we check for public rate and ignore member rate
|
||||
isBookingCodeRatesAvailable = rooms.some(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.Available &&
|
||||
room.products.some(
|
||||
(product) =>
|
||||
product.public?.rateType !== RateTypeEnum.Regular ||
|
||||
product.member?.rateType !== RateTypeEnum.Regular
|
||||
)
|
||||
)
|
||||
|
||||
if (activeCodeFilter === BookingCodeFilterEnum.Discounted) {
|
||||
visibleRooms = rooms.filter(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.Available &&
|
||||
room.products.every(
|
||||
(product) => product.public?.rateType !== RateTypeEnum.Regular
|
||||
)
|
||||
)
|
||||
} else if (activeCodeFilter === BookingCodeFilterEnum.Regular) {
|
||||
visibleRooms = rooms.filter(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.Available &&
|
||||
room.products.every(
|
||||
(product) =>
|
||||
product.public?.rateType === RateTypeEnum.Regular ||
|
||||
product.member?.rateType === RateTypeEnum.Regular
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Show booking code filter when both of the booking code rates or regular rates are available
|
||||
const showBookingCodeFilter =
|
||||
isRegularRatesAvailableWithCode && isBookingCodeRatesAvailable
|
||||
|
||||
useEffect(() => {
|
||||
if (isSingleRoomAndHasSelection) {
|
||||
// Required to prevent the history.pushState on the first selection
|
||||
// to scroll user back to top
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 100
|
||||
const selectedInputRoomCard = document.querySelector(
|
||||
`.${styles.roomList} li:has(input[type=radio]:checked)`
|
||||
)
|
||||
if (selectedInputRoomCard) {
|
||||
const elementPosition =
|
||||
selectedInputRoomCard.getBoundingClientRect().top
|
||||
const offsetPosition =
|
||||
elementPosition + window.scrollY - SCROLL_OFFSET
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isSingleRoomAndHasSelection])
|
||||
|
||||
return (
|
||||
<>
|
||||
{noAvailableRooms ||
|
||||
(bookingCode &&
|
||||
!isBookingCodeRatesAvailable &&
|
||||
!isVoucherOrCorpChequeRate) ? (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
type={AlertTypeEnum.Info}
|
||||
heading={intl.formatMessage({ id: "No availability" })}
|
||||
text={
|
||||
bookingCode && !isBookingCodeRatesAvailable
|
||||
? intl.formatMessage(
|
||||
{
|
||||
id: "We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||
},
|
||||
{ bookingCode }
|
||||
)
|
||||
: intl.formatMessage({
|
||||
id: "There are no rooms available that match your request.",
|
||||
})
|
||||
}
|
||||
link={{
|
||||
title: intl.formatMessage({ id: "See alternative hotels" }),
|
||||
url: `${alternativeHotels(lang)}`,
|
||||
keepSearchParams: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<RoomTypeFilter />
|
||||
{showBookingCodeFilter ? <BookingCodeFilter /> : null}
|
||||
<ul className={styles.roomList}>
|
||||
{visibleRooms.map((roomConfiguration) => (
|
||||
<RoomCard
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type { RoomPackageCodeEnum } from "@/types/components/hotelReservation/se
|
||||
export default function RoomTypeFilter() {
|
||||
const filterOptions = useRatesStore((state) => state.filterOptions)
|
||||
const {
|
||||
actions: { selectFilter },
|
||||
actions: { selectPackage },
|
||||
rooms,
|
||||
selectedPackage,
|
||||
totalRooms,
|
||||
@@ -37,9 +37,9 @@ export default function RoomTypeFilter() {
|
||||
function handleChange(selectedFilter: Set<Key>) {
|
||||
if (selectedFilter.size) {
|
||||
const selected = selectedFilter.values().next()
|
||||
selectFilter(selected.value as RoomPackageCodeEnum)
|
||||
selectPackage(selected.value as RoomPackageCodeEnum)
|
||||
} else {
|
||||
selectFilter(undefined)
|
||||
selectPackage(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomCard"
|
||||
import type { RoomSizeProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||
|
||||
export default function RoomSize({ roomSize }: RoomSizeProps) {
|
||||
const intl = useIntl()
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import useSidePeekStore from "@/stores/sidepeek"
|
||||
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
|
||||
import { SidePeekEnum } from "@/types/components/hotelReservation/sidePeek"
|
||||
import type { ToggleSidePeekProps } from "@/types/components/hotelReservation/toggleSidePeekProps"
|
||||
|
||||
export default function ToggleSidePeek({
|
||||
hotelId,
|
||||
roomTypeCode,
|
||||
intent = "textInverted",
|
||||
title,
|
||||
}: ToggleSidePeekProps) {
|
||||
const intl = useIntl()
|
||||
const openSidePeek = useSidePeekStore((state) => state.openSidePeek)
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() =>
|
||||
openSidePeek({ key: SidePeekEnum.roomDetails, hotelId, roomTypeCode })
|
||||
}
|
||||
theme="base"
|
||||
size="small"
|
||||
variant="icon"
|
||||
intent={intent}
|
||||
wrapping
|
||||
>
|
||||
{title ? title : intl.formatMessage({ id: "See room details" })}
|
||||
<MaterialIcon icon="chevron_right" size={14} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.specification {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.toggleSidePeek {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.specification .toggleSidePeek button {
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.roomDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Spacing-x1);
|
||||
padding-bottom: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import RoomSize from "./RoomSize"
|
||||
import ToggleSidePeek from "./ToggleSidePeek"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
export default function Details({ roomTypeCode }: { roomTypeCode: string }) {
|
||||
const intl = useIntl()
|
||||
const { hotelId, roomCategories } = useRatesStore((state) => ({
|
||||
hotelId: state.booking.hotelId,
|
||||
roomCategories: state.roomCategories,
|
||||
}))
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
)
|
||||
|
||||
const { name, occupancy, roomSize } = selectedRoom || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.specification}>
|
||||
{occupancy && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{occupancy.max === occupancy.min
|
||||
? intl.formatMessage(
|
||||
{ id: "{guests, plural, one {# guest} other {# guests}}" },
|
||||
{ guests: occupancy.max }
|
||||
)
|
||||
: intl.formatMessage(
|
||||
{ id: "{min}-{max} guests" },
|
||||
{
|
||||
min: occupancy.min,
|
||||
max: occupancy.max,
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
<RoomSize roomSize={roomSize} />
|
||||
<div className={styles.toggleSidePeek}>
|
||||
{roomTypeCode && (
|
||||
<ToggleSidePeek
|
||||
hotelId={hotelId}
|
||||
roomTypeCode={roomTypeCode}
|
||||
title={intl.formatMessage({ id: "Room details" })}
|
||||
intent="text"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.roomDetails}>
|
||||
<Subtitle className={styles.name} type="two">
|
||||
{name}
|
||||
</Subtitle>
|
||||
{/* Out of scope for now
|
||||
<Body>{descriptions?.short}</Body>
|
||||
*/}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HotelTypeEnum } from "@/types/enums/hotelType"
|
||||
|
||||
export function getBreakfastMessage(
|
||||
publicBreakfastIncluded: boolean,
|
||||
memberBreakfastIncluded: boolean,
|
||||
hotelType: string | undefined,
|
||||
userIsLoggedIn: boolean,
|
||||
msgs: Record<
|
||||
"included" | "noSelection" | "scandicgo" | "notIncluded",
|
||||
string
|
||||
>,
|
||||
roomNr: number
|
||||
) {
|
||||
if (hotelType === HotelTypeEnum.ScandicGo) {
|
||||
return msgs.scandicgo
|
||||
}
|
||||
|
||||
if (userIsLoggedIn && memberBreakfastIncluded && roomNr === 1) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (publicBreakfastIncluded && memberBreakfastIncluded) {
|
||||
return msgs.included
|
||||
}
|
||||
|
||||
if (!publicBreakfastIncluded && !memberBreakfastIncluded) {
|
||||
return msgs.notIncluded
|
||||
}
|
||||
|
||||
return msgs.noSelection
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import { getBreakfastMessage } from "./getBreakfastMessage"
|
||||
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
|
||||
export default function BreakfastMessage({
|
||||
breakfastIncludedMember,
|
||||
breakfastIncludedStandard,
|
||||
hasRegularRates,
|
||||
}: {
|
||||
breakfastIncludedMember: boolean
|
||||
breakfastIncludedStandard: boolean
|
||||
hasRegularRates: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const { roomNr, selectedFilter } = useRoomContext()
|
||||
|
||||
const { hotelType, isUserLoggedIn } = useRatesStore((state) => ({
|
||||
hotelType: state.hotelType,
|
||||
isUserLoggedIn: state.isUserLoggedIn,
|
||||
}))
|
||||
|
||||
const breakfastMessages = {
|
||||
included: intl.formatMessage({ id: "Breakfast is included." }),
|
||||
notIncluded: intl.formatMessage({
|
||||
id: "Breakfast selection in next step.",
|
||||
}),
|
||||
noSelection: intl.formatMessage({ id: "Select a rate" }),
|
||||
scandicgo: intl.formatMessage({
|
||||
id: "Breakfast deal can be purchased at the hotel.",
|
||||
}),
|
||||
}
|
||||
|
||||
const breakfastMessage = getBreakfastMessage(
|
||||
breakfastIncludedStandard,
|
||||
breakfastIncludedMember,
|
||||
hotelType,
|
||||
isUserLoggedIn,
|
||||
breakfastMessages,
|
||||
roomNr
|
||||
)
|
||||
|
||||
const isDiscount = selectedFilter === BookingCodeFilterEnum.Discounted
|
||||
|
||||
if (isDiscount || !hasRegularRates) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import CampaignRateCard from "@scandic-hotels/design-system/CampaignRateCard"
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import { isSelectedPriceProduct } from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
interface CampaignProps extends SharedRateCardProps {
|
||||
campaign: PriceProduct[]
|
||||
}
|
||||
|
||||
export default function Campaign({
|
||||
campaign,
|
||||
handleSelectRate,
|
||||
nights,
|
||||
petRoomPackage,
|
||||
roomTypeCode,
|
||||
}: CampaignProps) {
|
||||
const intl = useIntl()
|
||||
const { roomAvailability, roomNr, selectedFilter, selectedRate } =
|
||||
useRoomContext()
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
const rateTitles = useRateTitles()
|
||||
|
||||
let isCampaignRate = false
|
||||
if (roomAvailability && "rateDefinitions" in roomAvailability) {
|
||||
if (roomAvailability.rateDefinitions.length === 1) {
|
||||
const rateDefinition = roomAvailability.rateDefinitions[0]
|
||||
isCampaignRate = rateDefinition.isCampaignRate
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted && !isCampaignRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const night = intl.formatMessage({ id: "night" }).toUpperCase()
|
||||
|
||||
return campaign.map((product) => {
|
||||
if (!product.public) {
|
||||
return (
|
||||
<NoRateAvailableCard
|
||||
key={product.rate}
|
||||
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
variant="Campaign"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const isSelected = isSelectedPriceProduct(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
let bannerText = intl.formatMessage({ id: "Campaign" })
|
||||
if (bookingCode) {
|
||||
bannerText = bookingCode
|
||||
}
|
||||
|
||||
if (product.rateDefinition?.breakfastIncluded) {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({ id: "Breakfast included" })}`
|
||||
} else {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({ id: "Breakfast excluded" })}`
|
||||
}
|
||||
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
product.public.localPrice.pricePerNight,
|
||||
product.public.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
|
||||
const pricePerNightMember = product.member
|
||||
? calculatePricePerNightPriceProduct(
|
||||
product.member.localPrice.pricePerNight,
|
||||
product.member.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
: undefined
|
||||
|
||||
let approximateRatePrice = undefined
|
||||
if (
|
||||
pricePerNight.totalRequestedPrice &&
|
||||
pricePerNightMember?.totalRequestedPrice
|
||||
) {
|
||||
approximateRatePrice = `${pricePerNight.totalRequestedPrice}/${pricePerNightMember.totalRequestedPrice}`
|
||||
} else if (pricePerNight.totalRequestedPrice) {
|
||||
approximateRatePrice = pricePerNight.totalRequestedPrice
|
||||
}
|
||||
|
||||
const approximateRate =
|
||||
approximateRatePrice && product.public.requestedPrice
|
||||
? {
|
||||
label: intl.formatMessage({ id: "Approx." }),
|
||||
price: approximateRatePrice,
|
||||
unit: product.public.requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<CampaignRateCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
isHighlightedRate={!!product.rateDefinition?.displayPriceRed}
|
||||
memberRate={
|
||||
pricePerNightMember
|
||||
? {
|
||||
label: intl.formatMessage({ id: "Member price" }),
|
||||
price: pricePerNightMember.totalPrice,
|
||||
unit: `${product.member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
name={`rateCode-${roomNr}-${product.public.rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: product.rateDefinition?.title,
|
||||
price: pricePerNight.totalPrice,
|
||||
unit: `${product.public.localPrice.currency}/${night}`,
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
omnibusRate={
|
||||
product.public.localPrice.omnibusPricePerNight
|
||||
? {
|
||||
label: intl
|
||||
.formatMessage({ id: "Lowest price (last 30 days)" })
|
||||
.toUpperCase(),
|
||||
price:
|
||||
product.public.localPrice.omnibusPricePerNight.toString(),
|
||||
unit: product.public.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
value={product.public.rateCode}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import {
|
||||
isSelectedCorporateCheque,
|
||||
isSelectedPriceProduct,
|
||||
isSelectedVoucher,
|
||||
} from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import type { CodeProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
interface CodeProps extends SharedRateCardProps {
|
||||
code: CodeProduct[]
|
||||
}
|
||||
|
||||
export default function Code({
|
||||
code,
|
||||
handleSelectRate,
|
||||
nights,
|
||||
petRoomPackage,
|
||||
roomTypeCode,
|
||||
}: CodeProps) {
|
||||
const intl = useIntl()
|
||||
const { roomNr, selectedRate } = useRoomContext()
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
const rateTitles = useRateTitles()
|
||||
const night = intl.formatMessage({ id: "night" }).toUpperCase()
|
||||
|
||||
return code.map((product) => {
|
||||
let bannerText = ""
|
||||
if (product.breakfastIncluded) {
|
||||
bannerText = `${bookingCode} ∙ ${intl.formatMessage({ id: "Breakfast included" })}`
|
||||
} else {
|
||||
bannerText = `${bookingCode} ∙ ${intl.formatMessage({ id: "Breakfast excluded" })}`
|
||||
}
|
||||
|
||||
if ("corporateCheque" in product) {
|
||||
const { localPrice, rateCode } = product.corporateCheque
|
||||
let price = `${localPrice.numberOfCheques} CC`
|
||||
if (localPrice.additionalPricePerStay) {
|
||||
price = `${price} + ${localPrice.additionalPricePerStay}`
|
||||
}
|
||||
|
||||
const isSelected = isSelectedCorporateCheque(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={product.rate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: product.rateDefinition?.title,
|
||||
price,
|
||||
unit: localPrice.currency ?? "",
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
const { numberOfVouchers, rateCode } = product.voucher
|
||||
const isSelected = isSelectedVoucher(product, selectedRate, roomTypeCode)
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={product.rate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: product.rateDefinition?.title,
|
||||
price: numberOfVouchers.toString(),
|
||||
unit: intl.formatMessage({ id: "Voucher" }).toUpperCase(),
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (product.public) {
|
||||
const { localPrice, rateCode, requestedPrice } = product.public
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.pricePerNight,
|
||||
requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
|
||||
const approximateRate = pricePerNight.totalRequestedPrice
|
||||
? {
|
||||
label: intl.formatMessage({ id: "Approx." }),
|
||||
price: pricePerNight.totalRequestedPrice,
|
||||
unit: localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const regularPricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.regularPricePerNight,
|
||||
requestedPrice?.regularPricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
|
||||
const comparisonRate = regularPricePerNight.totalPrice
|
||||
? {
|
||||
price: regularPricePerNight.totalPrice,
|
||||
unit: localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const isSelected = isSelectedPriceProduct(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
comparisonRate={comparisonRate}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: product.rateDefinition?.title,
|
||||
price: pricePerNight.totalPrice,
|
||||
unit: `${localPrice.currency}/${night}`,
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
interface RedemptionsProps extends SharedRateCardProps {
|
||||
redemptions: RedemptionProduct[]
|
||||
}
|
||||
|
||||
export default function Redemptions({
|
||||
handleSelectRate,
|
||||
redemptions,
|
||||
}: RedemptionsProps) {
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const { selectedFilter, selectedRate } = useRoomContext()
|
||||
|
||||
if (
|
||||
selectedFilter === BookingCodeFilterEnum.Discounted ||
|
||||
!redemptions.length
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rewardNight = intl.formatMessage({ id: "Reward night" })
|
||||
const breakfastIncluded = intl.formatMessage({
|
||||
id: "Breakfast included",
|
||||
})
|
||||
const breakfastExcluded = intl.formatMessage({
|
||||
id: "Breakfast excluded",
|
||||
})
|
||||
|
||||
let selectedRateCode = ""
|
||||
if (selectedRate?.product && "redemption" in selectedRate.product) {
|
||||
selectedRateCode = selectedRate.product.redemption.rateCode
|
||||
}
|
||||
|
||||
function handleSelect(rateCode: string) {
|
||||
const selectedRedemption = redemptions.find(
|
||||
(r) => r.redemption.rateCode === rateCode
|
||||
)
|
||||
if (selectedRedemption) {
|
||||
handleSelectRate(selectedRedemption)
|
||||
}
|
||||
}
|
||||
|
||||
const rates = redemptions.map((r) => ({
|
||||
additionalPrice:
|
||||
r.redemption.localPrice.additionalPricePerStay &&
|
||||
r.redemption.localPrice.currency
|
||||
? {
|
||||
currency: r.redemption.localPrice.currency,
|
||||
price: r.redemption.localPrice.additionalPricePerStay.toString(),
|
||||
}
|
||||
: undefined,
|
||||
currency: "PTS",
|
||||
isDisabled: !!r.redemption.localPrice.pointsPerNight, // TODO: FIX
|
||||
points: r.redemption.localPrice.pointsPerNight.toString(),
|
||||
}))
|
||||
|
||||
const firstRedemption = redemptions[0]
|
||||
const bannerText = firstRedemption.breakfastIncluded
|
||||
? `${rewardNight} ∙ ${breakfastIncluded}`
|
||||
: `${rewardNight} ∙ ${breakfastExcluded}`
|
||||
|
||||
return (
|
||||
<PointsRateCard
|
||||
key={firstRedemption.rate}
|
||||
bannerText={bannerText}
|
||||
onRateSelect={handleSelect}
|
||||
paymentTerm={rateTitles[firstRedemption.rate].paymentTerm}
|
||||
rates={rates}
|
||||
rateTitle={rateTitles[firstRedemption.rate].title}
|
||||
selectedRate={selectedRateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import NoRateAvailableCard from "@scandic-hotels/design-system/NoRateAvailableCard"
|
||||
import RegularRateCard from "@scandic-hotels/design-system/RegularRateCard"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import { isSelectedPriceProduct } from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
interface Rate {
|
||||
label: string
|
||||
price: string
|
||||
unit: string
|
||||
}
|
||||
|
||||
interface Rates {
|
||||
memberRate?: Rate
|
||||
rate?: Rate
|
||||
}
|
||||
|
||||
interface RegularProps extends SharedRateCardProps {
|
||||
regular: PriceProduct[]
|
||||
}
|
||||
|
||||
export default function Regular({
|
||||
handleSelectRate,
|
||||
nights,
|
||||
petRoomPackage,
|
||||
regular,
|
||||
roomTypeCode,
|
||||
}: RegularProps) {
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const { isMainRoom, roomNr, selectedFilter, selectedRate } = useRoomContext()
|
||||
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
const night = intl.formatMessage({ id: "night" }).toUpperCase()
|
||||
|
||||
return regular.map((product) => {
|
||||
const { member, public: standard } = product
|
||||
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
|
||||
const isMainRoomLoggedInWithoutMember =
|
||||
isMainRoomAndLoggedIn && !product.member
|
||||
const noRateAvailable = !product.member && !product.public
|
||||
const hideStandardPrice = isMainRoomAndLoggedIn && !!member
|
||||
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
|
||||
const rateCode = hideStandardPrice ? member.rateCode : standard?.rateCode
|
||||
if (
|
||||
noRateAvailable ||
|
||||
isMainRoomLoggedInWithoutMember ||
|
||||
!rateCode ||
|
||||
isNotLoggedInAndOnlyMemberRate
|
||||
) {
|
||||
return (
|
||||
<NoRateAvailableCard
|
||||
key={product.rate}
|
||||
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
variant="Regular"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const memberPricePerNight = member
|
||||
? calculatePricePerNightPriceProduct(
|
||||
member.localPrice.pricePerNight,
|
||||
member.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
: undefined
|
||||
const standardPricePerNight = standard
|
||||
? calculatePricePerNightPriceProduct(
|
||||
standard.localPrice.pricePerNight,
|
||||
standard.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
petRoomPackage
|
||||
)
|
||||
: undefined
|
||||
|
||||
let approximateMemberRatePrice = null
|
||||
const rates: Rates = {}
|
||||
if (memberPricePerNight) {
|
||||
rates.memberRate = {
|
||||
label: intl.formatMessage({ id: "Member price" }),
|
||||
price: memberPricePerNight.totalPrice,
|
||||
unit: `${member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
|
||||
if (memberPricePerNight.totalRequestedPrice) {
|
||||
approximateMemberRatePrice = memberPricePerNight.totalRequestedPrice
|
||||
}
|
||||
}
|
||||
|
||||
let approximateStandardRatePrice = null
|
||||
if (standardPricePerNight) {
|
||||
rates.rate = {
|
||||
label: intl.formatMessage({ id: "Standard price" }),
|
||||
price: standardPricePerNight.totalPrice,
|
||||
unit: `${standard!.localPrice.currency}/${night}`,
|
||||
}
|
||||
|
||||
if (standardPricePerNight.totalRequestedPrice) {
|
||||
approximateStandardRatePrice = standardPricePerNight.totalRequestedPrice
|
||||
}
|
||||
}
|
||||
|
||||
let approximatePrice = ""
|
||||
if (approximateStandardRatePrice && approximateMemberRatePrice) {
|
||||
approximatePrice = `${approximateStandardRatePrice}/${approximateMemberRatePrice}`
|
||||
} else if (approximateStandardRatePrice) {
|
||||
approximatePrice = approximateStandardRatePrice
|
||||
} else if (approximateMemberRatePrice) {
|
||||
approximatePrice = approximateMemberRatePrice
|
||||
}
|
||||
|
||||
const requestedCurrency =
|
||||
standard?.requestedPrice?.currency || member?.requestedPrice?.currency
|
||||
const approximateRate =
|
||||
approximatePrice && requestedCurrency
|
||||
? {
|
||||
label: intl.formatMessage({ id: "Approx." }),
|
||||
price: approximatePrice,
|
||||
unit: requestedCurrency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const isSelected = isSelectedPriceProduct(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
return (
|
||||
<RegularRateCard
|
||||
{...rates}
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
hidePublicRate={hideStandardPrice}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Product, RateDefinition } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
/**
|
||||
* Get terms and rate title from the rate definitions when booking code rate
|
||||
* or public promotion is in play. Returns undefined when product is not available
|
||||
*
|
||||
* @param product - Either public or member product type
|
||||
* @param rateDefinitions - List of rate definitions
|
||||
* @returns RateDefinition | undefined
|
||||
*/
|
||||
export function getRateDefinition(
|
||||
product: Product,
|
||||
rateDefinitions: RateDefinition[],
|
||||
isUserLoggedIn: boolean,
|
||||
isMainRoom: boolean,
|
||||
) {
|
||||
return rateDefinitions.find((rateDefinition) => {
|
||||
if ("member" in product && product.member && isUserLoggedIn && isMainRoom) {
|
||||
return rateDefinition.rateCode === product.member.rateCode
|
||||
}
|
||||
if ("corporateCheque" in product) {
|
||||
return rateDefinition.rateCode === product.corporateCheque.rateCode
|
||||
}
|
||||
if ("voucher" in product) {
|
||||
return rateDefinition.rateCode === product.voucher.rateCode
|
||||
}
|
||||
if ("public" in product && product.public) {
|
||||
return rateDefinition.rateCode === product.public.rateCode
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import SkeletonShimmer from "@/components/SkeletonShimmer"
|
||||
import Divider from "@/components/TempDesignSystem/Divider"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import BreakfastMessage from "./BreakfastMessage"
|
||||
import Campaign from "./Campaign"
|
||||
import Code from "./Code"
|
||||
import Redemptions from "./Redemptions"
|
||||
import Regular from "./Regular"
|
||||
|
||||
import type { RatesProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
|
||||
import type { Product } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function Rates({
|
||||
roomConfiguration: {
|
||||
breakfastIncludedInAllRates,
|
||||
breakfastIncludedInAllRatesMember,
|
||||
campaign,
|
||||
code,
|
||||
features,
|
||||
redemptions,
|
||||
regular,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
},
|
||||
}: RatesProps) {
|
||||
const {
|
||||
actions: { selectRate },
|
||||
isFetchingAdditionalRate,
|
||||
selectedFilter,
|
||||
selectedPackage,
|
||||
} = useRoomContext()
|
||||
const { nights, petRoomPackage } = useRatesStore((state) => ({
|
||||
nights: dt(state.booking.toDate).diff(state.booking.fromDate, "days"),
|
||||
petRoomPackage: state.petRoomPackage,
|
||||
}))
|
||||
|
||||
function handleSelectRate(product: Product) {
|
||||
selectRate({ features, product, roomType, roomTypeCode })
|
||||
}
|
||||
|
||||
const petRoomPackageSelected =
|
||||
selectedPackage === RoomPackageCodeEnum.PET_ROOM
|
||||
|
||||
const sharedProps = {
|
||||
handleSelectRate,
|
||||
nights,
|
||||
petRoomPackage:
|
||||
petRoomPackageSelected && petRoomPackage ? petRoomPackage : undefined,
|
||||
roomTypeCode,
|
||||
}
|
||||
|
||||
const showAllRates = selectedFilter === BookingCodeFilterEnum.All
|
||||
const hasBookingCodeRates = !!(campaign.length || code.length)
|
||||
const hasRegularRates = !!regular.length
|
||||
const showDivider =
|
||||
(showAllRates && hasBookingCodeRates && hasRegularRates) ||
|
||||
isFetchingAdditionalRate
|
||||
|
||||
return (
|
||||
<>
|
||||
<Campaign {...sharedProps} campaign={campaign} />
|
||||
<Code {...sharedProps} code={code} />
|
||||
<Redemptions {...sharedProps} redemptions={redemptions} />
|
||||
{showDivider ? <Divider color="borderDividerSubtle" /> : null}
|
||||
{isFetchingAdditionalRate ? (
|
||||
<>
|
||||
<SkeletonShimmer height="100px" />
|
||||
<SkeletonShimmer height="100px" />
|
||||
</>
|
||||
) : null}
|
||||
<BreakfastMessage
|
||||
breakfastIncludedMember={breakfastIncludedInAllRatesMember}
|
||||
breakfastIncludedStandard={breakfastIncludedInAllRates}
|
||||
hasRegularRates={!!regular.length}
|
||||
/>
|
||||
<Regular {...sharedProps} regular={regular} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SelectedRate } from "@/types/stores/rates"
|
||||
import type {
|
||||
CorporateChequeProduct,
|
||||
PriceProduct,
|
||||
VoucherProduct,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export function isSelectedPriceProduct(
|
||||
product: PriceProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string,
|
||||
) {
|
||||
if (!selectedRate) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { member, public: standard } = product
|
||||
let selectedRateMember: PriceProduct["member"] = null
|
||||
if ("member" in selectedRate.product) {
|
||||
selectedRateMember = selectedRate.product.member
|
||||
}
|
||||
|
||||
let selectedRatePublic: PriceProduct["public"] = null
|
||||
if ("public" in selectedRate.product) {
|
||||
selectedRatePublic = selectedRate.product.public
|
||||
}
|
||||
|
||||
const selectedRateIsMember = (
|
||||
member && selectedRateMember &&
|
||||
(member.rateCode === selectedRateMember.rateCode)
|
||||
)
|
||||
|
||||
const selectedRateIsPublic = (
|
||||
standard && selectedRatePublic &&
|
||||
(standard.rateCode === selectedRatePublic.rateCode)
|
||||
)
|
||||
return !!(
|
||||
(selectedRateIsMember || selectedRateIsPublic) &&
|
||||
selectedRate.roomTypeCode === roomTypeCode
|
||||
)
|
||||
}
|
||||
|
||||
export function isSelectedCorporateCheque(
|
||||
product: CorporateChequeProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string,
|
||||
) {
|
||||
if (!selectedRate || !("corporateCheque" in selectedRate.product)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSameRateCode = (
|
||||
product.corporateCheque.rateCode === selectedRate.product.corporateCheque.rateCode
|
||||
)
|
||||
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||
return isSameRateCode && isSameRoomTypeCode
|
||||
}
|
||||
|
||||
export function isSelectedVoucher(
|
||||
product: VoucherProduct,
|
||||
selectedRate: SelectedRate | null,
|
||||
roomTypeCode: string,
|
||||
) {
|
||||
if (!selectedRate || !("voucher" in selectedRate.product)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isSameRateCode = (
|
||||
product.voucher.rateCode === selectedRate.product.voucher.rateCode
|
||||
)
|
||||
const isSameRoomTypeCode = selectedRate.roomTypeCode === roomTypeCode
|
||||
return isSameRateCode && isSameRoomTypeCode
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type {
|
||||
RoomPackage,
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
export function calculatePricePerNightPriceProduct(
|
||||
pricePerNight: number,
|
||||
requestedPricePerNight: number | undefined,
|
||||
nights: number,
|
||||
petRoomPackage?: RoomPackage,
|
||||
) {
|
||||
const totalPrice = petRoomPackage?.localPrice
|
||||
? Math.floor(
|
||||
pricePerNight + (petRoomPackage.localPrice.price / nights)
|
||||
)
|
||||
: Math.floor(pricePerNight)
|
||||
|
||||
let totalRequestedPrice = undefined
|
||||
if (requestedPricePerNight) {
|
||||
if (petRoomPackage?.requestedPrice) {
|
||||
totalRequestedPrice = Math.floor(
|
||||
requestedPricePerNight +
|
||||
(petRoomPackage.requestedPrice.price / nights)
|
||||
)
|
||||
} else {
|
||||
totalRequestedPrice = Math.floor(requestedPricePerNight)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalPrice: totalPrice.toString(),
|
||||
totalRequestedPrice: totalRequestedPrice?.toString(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.imageContainer {
|
||||
margin: 0 calc(-1 * var(--Spacing-x2));
|
||||
min-height: 190px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .imageContainer {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chipContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--Spacing-x1);
|
||||
left: 12px;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background-color: var(--Main-Grey-White);
|
||||
border-radius: var(--Corner-radius-Small);
|
||||
padding: var(--Spacing-x-half) var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.imageContainer img {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: var(--Corner-radius-Medium) var(--Corner-radius-Medium) 0 0;
|
||||
max-width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import ImageGallery from "@/components/ImageGallery"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import styles from "./image.module.css"
|
||||
|
||||
import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||
|
||||
export default function RoomImage({
|
||||
features,
|
||||
roomsLeft,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
}: RoomListItemImageProps) {
|
||||
const intl = useIntl()
|
||||
const { selectedPackage } = useRoomContext()
|
||||
const roomCategories = useRatesStore((state) => state.roomCategories)
|
||||
|
||||
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
)
|
||||
|
||||
const galleryImages = mapApiImagesToGalleryImages(selectedRoom?.images || [])
|
||||
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{showLowInventory ? (
|
||||
<span className={styles.chip}>
|
||||
<Footnote color="burgundy" textTransform="uppercase">
|
||||
{intl.formatMessage(
|
||||
{ id: "{amount, number} left" },
|
||||
{ amount: roomsLeft }
|
||||
)}
|
||||
</Footnote>
|
||||
</span>
|
||||
) : null}
|
||||
{features
|
||||
.filter((feature) => selectedPackage === feature.code)
|
||||
.map((feature) => (
|
||||
<span className={styles.chip} key={feature.code}>
|
||||
{IconForFeatureCode({ featureCode: feature.code, size: 16 })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<ImageGallery images={galleryImages} title={roomType} fill />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
|
||||
import styles from "./notAvailable.module.css"
|
||||
|
||||
export default function RoomNotAvailable() {
|
||||
const intl = useIntl()
|
||||
return (
|
||||
<div className={styles.noRoomsContainer}>
|
||||
<div className={styles.noRooms}>
|
||||
<MaterialIcon
|
||||
icon="error_circle_rounded"
|
||||
color="Icon/Feedback/Error"
|
||||
size={16}
|
||||
/>
|
||||
<Caption color="uiTextHighContrast" type="bold">
|
||||
{intl.formatMessage({
|
||||
id: "This room is not available",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.noRooms {
|
||||
background-color: var(--Base-Surface-Secondary-light-Normal);
|
||||
border-radius: var(--Corner-radius-Medium);
|
||||
display: flex;
|
||||
gap: var(--Spacing-x1);
|
||||
margin: 0;
|
||||
padding: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import Details from "./Details"
|
||||
import { listItemVariants } from "./listItemVariants"
|
||||
import Rates from "./Rates"
|
||||
import RoomImage from "./RoomImage"
|
||||
import RoomNotAvailable from "./RoomNotAvailable"
|
||||
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||
import type { RoomListItemProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||
|
||||
export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
|
||||
const classNames = listItemVariants({
|
||||
availability:
|
||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<RoomImage
|
||||
features={roomConfiguration.features}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
roomsLeft={roomConfiguration.roomsLeft}
|
||||
/>
|
||||
<Details roomTypeCode={roomConfiguration.roomTypeCode} />
|
||||
|
||||
<div className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
<RoomNotAvailable />
|
||||
) : (
|
||||
<Rates roomConfiguration={roomConfiguration} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
export const cardVariants = cva(styles.card, {
|
||||
export const listItemVariants = cva(styles.listItem, {
|
||||
variants: {
|
||||
availability: {
|
||||
noAvailability: styles.noAvailability,
|
||||
@@ -0,0 +1,25 @@
|
||||
.listItem {
|
||||
align-content: flex-start;
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--Base-Border-Subtle);
|
||||
border-radius: var(--Corner-radius-Large);
|
||||
display: grid;
|
||||
font-size: 14px;
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
padding: 0 var(--Spacing-x2) var(--Spacing-x2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div[data-multiroom="true"] .listItem {
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem.noAvailability {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function ScrollToList() {
|
||||
const { isSingleRoomAndHasSelection } = useRatesStore(state => ({
|
||||
isSingleRoomAndHasSelection: state.booking.rooms.length === 1 && !!state.rateSummary.length,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSingleRoomAndHasSelection) {
|
||||
// Required to prevent the history.pushState on the first selection
|
||||
// to scroll user back to top
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 100
|
||||
const selectedInputRoomCard = document.querySelector(
|
||||
`.${styles.roomList} li:has(input[type=radio]:checked)`
|
||||
)
|
||||
if (selectedInputRoomCard) {
|
||||
const elementPosition =
|
||||
selectedInputRoomCard.getBoundingClientRect().top
|
||||
const offsetPosition =
|
||||
elementPosition + window.scrollY - SCROLL_OFFSET
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isSingleRoomAndHasSelection])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import RoomListItem from "./RoomListItem"
|
||||
import ScrollToList from "./ScrollToList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function RoomsList() {
|
||||
const { rooms } = useRoomContext()
|
||||
return (
|
||||
<>
|
||||
<ScrollToList />
|
||||
<ul className={styles.roomList}>
|
||||
{rooms.map((roomConfiguration) => (
|
||||
<RoomListItem
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,6 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.roomList > li {
|
||||
.roomList>li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hotelAlert {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: var(--Spacing-x-one-and-half);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,16 @@ import { useRatesStore } from "@/stores/select-rate"
|
||||
import RoomProvider from "@/providers/SelectRate/RoomProvider"
|
||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||
|
||||
import BookingCodeFilter from "./BookingCodeFilter"
|
||||
import MultiRoomWrapper from "./MultiRoomWrapper"
|
||||
import RoomSelectionPanel from "./RoomSelectionPanel"
|
||||
import NoAvailabilityAlert from "./NoAvailabilityAlert"
|
||||
import RoomsList from "./RoomsList"
|
||||
import RoomTypeFilter from "./RoomTypeFilter"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export default function Rooms() {
|
||||
const {
|
||||
arrivalDate,
|
||||
@@ -32,7 +37,13 @@ export default function Rooms() {
|
||||
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
|
||||
roomConfiguration.flatMap((room) =>
|
||||
room.products
|
||||
.filter((product) => product.member || product.public)
|
||||
.filter(
|
||||
(product): product is PriceProduct =>
|
||||
!!(
|
||||
("public" in product && product.public) ||
|
||||
("member" in product && product.member)
|
||||
)
|
||||
)
|
||||
.map((product) => ({
|
||||
currency: (product.public?.localPrice.currency ||
|
||||
product.member?.localPrice.currency)!,
|
||||
@@ -66,7 +77,10 @@ export default function Rooms() {
|
||||
room={rooms[idx]}
|
||||
>
|
||||
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
||||
<RoomSelectionPanel />
|
||||
<NoAvailabilityAlert />
|
||||
<RoomTypeFilter />
|
||||
<BookingCodeFilter />
|
||||
<RoomsList />
|
||||
</MultiRoomWrapper>
|
||||
</RoomProvider>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user