Merged in fix/remove-old-select-rate (pull request #2647)
Fix/remove old select rate * remove old select-rate * Fix imports * renamed SelectRate2 -> SelectRate
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import type { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import type { SelectedRate } from "@/contexts/SelectRate/types"
|
||||
|
||||
export function DesktopSummary({
|
||||
input,
|
||||
selectedRates,
|
||||
isSubmitting,
|
||||
bookingCode,
|
||||
}: {
|
||||
selectedRates: ReturnType<typeof useSelectRateContext>["selectedRates"]
|
||||
isSubmitting: boolean
|
||||
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||
bookingCode: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useIsUserLoggedIn()
|
||||
|
||||
if (!selectedRates.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasMemberRates = selectedRates.rates.some(
|
||||
(rate) => rate && "member" in rate && rate.member
|
||||
)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const totalNights = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: input.nights }
|
||||
)
|
||||
const totalAdults = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{
|
||||
totalAdults:
|
||||
input.data?.booking.rooms.reduce((acc, room) => acc + room.adults, 0) ??
|
||||
0,
|
||||
}
|
||||
)
|
||||
const childrenInOneOrMoreRooms = input.data?.booking.rooms.some(
|
||||
(room) => room.childrenInRoom?.length
|
||||
)
|
||||
const childrenInroom = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
totalChildren: input.data?.booking.rooms.reduce(
|
||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||
0
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||
const totalRooms = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||
},
|
||||
{ totalRooms: input.roomCount }
|
||||
)
|
||||
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||
const isAllRoomsSelected = selectedRates.state === "ALL_SELECTED"
|
||||
|
||||
const showDiscounted =
|
||||
isUserLoggedIn || selectedRates.rates.some(isBookingCodeRate)
|
||||
|
||||
const mainRoomRate = selectedRates.rates.at(0)
|
||||
let mainRoomCurrency = getRoomCurrency(mainRoomRate)
|
||||
|
||||
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
const isTotalRegularPriceGreaterThanPrice =
|
||||
totalRegularPrice > selectedRates.totalPrice.local.price
|
||||
const showStrikedThroughPrice =
|
||||
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.summaryText}>
|
||||
{selectedRates.rates.map((room, index) => {
|
||||
return (
|
||||
<RateSummary
|
||||
key={index}
|
||||
room={room}
|
||||
roomIndex={index}
|
||||
isMultiRoom={selectedRates.rates.length > 1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: selectedRates.rates.reduce((total, rate) => {
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const memberExists = "member" in rate && rate.member
|
||||
const publicExists = "public" in rate && rate.public
|
||||
if (!memberExists && !publicExists) {
|
||||
return total
|
||||
}
|
||||
const price =
|
||||
rate.member?.localPrice.pricePerStay ||
|
||||
rate.public?.localPrice.pricePerStay
|
||||
if (!price) {
|
||||
return total
|
||||
}
|
||||
const selectedPackagesPrice =
|
||||
rate.roomInfo.selectedPackages.reduce(
|
||||
(acc, pkg) => acc + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
return total + price + selectedPackagesPrice
|
||||
}, 0),
|
||||
currency: mainRoomCurrency ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
{showStrikedThroughPrice &&
|
||||
selectedRates.totalPrice.local.regularPrice && (
|
||||
<Caption
|
||||
textAlign="right"
|
||||
color="uiTextMediumContrast"
|
||||
striked={true}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{selectedRates.totalPrice.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.requested.price,
|
||||
selectedRates.totalPrice.requested.currency,
|
||||
selectedRates.totalPrice.requested.additionalPrice,
|
||||
selectedRates.totalPrice.requested.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.summaryPriceTextMobile}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</Caption>
|
||||
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
color="uiTextMediumContrast"
|
||||
className={styles.summaryPriceTextMobile}
|
||||
>
|
||||
{summaryPriceText}
|
||||
</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.continueButton}
|
||||
disabled={!isAllRoomsSelected || isSubmitting}
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function RateSummary({
|
||||
roomIndex,
|
||||
room,
|
||||
isMultiRoom,
|
||||
}: {
|
||||
room: SelectedRate | undefined
|
||||
roomIndex: number
|
||||
isMultiRoom: boolean
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const getRateDetails = useRateDetails()
|
||||
|
||||
if (!room || !room.isSelected) {
|
||||
return (
|
||||
<div key={`unselected-${roomIndex}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomIndex + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Select room",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={roomIndex}>
|
||||
{isMultiRoom ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: roomIndex + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomInfo.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomInfo.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{getRateDetails(room.rate)}</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useRateDetails() {
|
||||
const intl = useIntl()
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
return (rate: RateEnum) => {
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getRoomCurrency(rate: SelectedRate | undefined) {
|
||||
if (!rate) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ("member" in rate && rate.member?.localPrice) {
|
||||
return rate.member.localPrice.currency
|
||||
}
|
||||
|
||||
if ("public" in rate && rate.public?.localPrice) {
|
||||
return rate.public.localPrice.currency
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { cx } from "class-variance-authority"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
@@ -10,59 +11,64 @@ import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { mapToPrice } from "../mapToPrice"
|
||||
import { isBookingCodeRate } from "../../utils"
|
||||
import Room from "../Room"
|
||||
import { getMemberPrice, isBookingCodeRate } from "../utils"
|
||||
|
||||
import styles from "./summaryContent.module.css"
|
||||
|
||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
import type { Price } from "@/contexts/SelectRate/getTotalPrice"
|
||||
|
||||
export type SelectRateSummaryProps = {
|
||||
isMember: boolean
|
||||
bookingCode?: string
|
||||
toggleSummaryOpen: () => void
|
||||
}
|
||||
|
||||
export default function SummaryContent({
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const { rateSummary, defaultCurrency } = useRatesStore((state) => ({
|
||||
rateSummary: state.rateSummary,
|
||||
defaultCurrency: state.defaultCurrency,
|
||||
}))
|
||||
const { selectedRates, input } = useSelectRateContext()
|
||||
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const rateTitles = useRateTitles()
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
const nightsLabel = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: diff }
|
||||
{ totalNights: input.nights }
|
||||
)
|
||||
|
||||
const filteredRooms = rooms.filter(
|
||||
(room): room is NonNullable<typeof room> => !!room
|
||||
)
|
||||
const memberPrice =
|
||||
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(r) => r && isBookingCodeRate(r.roomRate)
|
||||
selectedRates.rates.length === 1 &&
|
||||
selectedRates.rates[0] &&
|
||||
"member" in selectedRates.rates[0]
|
||||
? selectedRates.rates[0].member
|
||||
: null
|
||||
|
||||
const containsBookingCodeRate = selectedRates.rates.find(
|
||||
(r) => r && isBookingCodeRate(r)
|
||||
)
|
||||
|
||||
if (!selectedRates?.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
const totalRegularPrice = totalPrice.local?.regularPrice
|
||||
? totalPrice.local.regularPrice
|
||||
const totalRegularPrice = selectedRates?.totalPrice?.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
|
||||
const showStrikeThroughPrice = totalRegularPrice > totalPrice.local.price
|
||||
const priceDetailsRooms = mapToPrice(rateSummary, booking.rooms, isMember)
|
||||
const showStrikeThroughPrice =
|
||||
totalRegularPrice > selectedRates?.totalPrice?.local?.price
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
@@ -90,26 +96,44 @@ export default function SummaryContent({
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdBold">
|
||||
<p className={styles.dates}>
|
||||
{dt(booking.fromDate).locale(lang).format(longDateFormat[lang])}
|
||||
{dt(input.data?.booking.fromDate)
|
||||
.locale(lang)
|
||||
.format(longDateFormat[lang])}
|
||||
<MaterialIcon icon="arrow_forward" size={15} color="CurrentColor" />
|
||||
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||
{dt(booking.toDate).locale(lang).format(longDateFormat[lang])} (
|
||||
{nights}){/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||
{dt(input.data?.booking.toDate)
|
||||
.locale(lang)
|
||||
.format(longDateFormat[lang])}{" "}
|
||||
({nightsLabel})
|
||||
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||
</p>
|
||||
</Typography>
|
||||
</header>
|
||||
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
|
||||
{filteredRooms.map((room, idx) => (
|
||||
<Room
|
||||
key={idx}
|
||||
room={room}
|
||||
roomNumber={idx + 1}
|
||||
roomCount={rooms.length}
|
||||
isMember={isMember}
|
||||
/>
|
||||
))}
|
||||
{selectedRates.rates.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Room
|
||||
key={idx}
|
||||
room={mapToRoom({
|
||||
isMember,
|
||||
rate: room,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
})}
|
||||
roomNumber={idx + 1}
|
||||
roomCount={selectedRates.rates.length}
|
||||
isMember={isMember}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
<div>
|
||||
<div className={styles.entry}>
|
||||
@@ -130,7 +154,7 @@ export default function SummaryContent({
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
{totalPrice.requested ? (
|
||||
{selectedRates.totalPrice.requested ? (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.approxPrice}>
|
||||
{intl.formatMessage(
|
||||
@@ -140,10 +164,11 @@ export default function SummaryContent({
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency,
|
||||
totalPrice.requested.additionalPrice,
|
||||
totalPrice.requested.additionalPriceCurrency
|
||||
selectedRates.totalPrice.requested.price,
|
||||
selectedRates.totalPrice.requested.currency,
|
||||
selectedRates.totalPrice.requested.additionalPrice,
|
||||
selectedRates.totalPrice.requested
|
||||
.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
@@ -161,22 +186,22 @@ export default function SummaryContent({
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted &&
|
||||
showStrikeThroughPrice &&
|
||||
totalPrice.local.regularPrice ? (
|
||||
selectedRates.totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.regularPrice,
|
||||
totalPrice.local.currency
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
@@ -185,18 +210,140 @@ export default function SummaryContent({
|
||||
</div>
|
||||
|
||||
<PriceDetailsModal
|
||||
bookingCode={booking.bookingCode}
|
||||
defaultCurrency={defaultCurrency}
|
||||
fromDate={booking.fromDate}
|
||||
rooms={priceDetailsRooms}
|
||||
toDate={booking.toDate}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
bookingCode={input.bookingCode}
|
||||
defaultCurrency={
|
||||
selectedRates.totalPrice.requested?.currency ??
|
||||
selectedRates.totalPrice.local.currency
|
||||
}
|
||||
rooms={selectedRates.rates
|
||||
.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mapped = mapToRoom({
|
||||
isMember,
|
||||
rate: room,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
})
|
||||
|
||||
function getPrice(
|
||||
room: NonNullable<(typeof selectedRates.rates)[number]>,
|
||||
isMember: boolean
|
||||
) {
|
||||
switch (room.type) {
|
||||
case "regular":
|
||||
return {
|
||||
regular: isMember
|
||||
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||
: room.public?.localPrice,
|
||||
}
|
||||
case "campaign":
|
||||
return {
|
||||
campaign: isMember
|
||||
? (room.member ?? room.public)
|
||||
: room.public,
|
||||
}
|
||||
case "redemption":
|
||||
return {
|
||||
redemption: room.redemption,
|
||||
}
|
||||
case "code": {
|
||||
if ("corporateCheque" in room) {
|
||||
return {
|
||||
corporateCheque: room.corporateCheque,
|
||||
}
|
||||
}
|
||||
|
||||
if ("voucher" in room) {
|
||||
return {
|
||||
voucher: room.voucher,
|
||||
}
|
||||
}
|
||||
if ("public" in room) {
|
||||
return {
|
||||
regular: isMember
|
||||
? (room.member?.localPrice ?? room.public?.localPrice)
|
||||
: room.public?.localPrice,
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown price type")
|
||||
}
|
||||
}
|
||||
|
||||
const p = getPrice(room!, isMember)
|
||||
|
||||
return {
|
||||
...mapped,
|
||||
idx,
|
||||
getPriceForRoom: selectedRates.getPriceForRoom,
|
||||
rateTitles,
|
||||
price: p,
|
||||
bedType: undefined,
|
||||
breakfast: undefined,
|
||||
breakfastIncluded:
|
||||
room?.rateDefinition.breakfastIncluded ?? false,
|
||||
rateDefinition: room.rateDefinition,
|
||||
}
|
||||
})
|
||||
.filter((x) => !!x)}
|
||||
fromDate={input.data?.booking.fromDate ?? ""}
|
||||
toDate={input.data?.booking.toDate ?? ""}
|
||||
totalPrice={selectedRates.totalPrice}
|
||||
vat={selectedRates.vat}
|
||||
/>
|
||||
</div>
|
||||
{!isMember && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: memberPrice.localPrice.pricePerStay,
|
||||
currency: memberPrice.localPrice.currency,
|
||||
}}
|
||||
badgeContent={"✌️"}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function mapToRoom({
|
||||
isMember,
|
||||
rate,
|
||||
input,
|
||||
idx,
|
||||
getPriceForRoom,
|
||||
rateTitles,
|
||||
}: {
|
||||
isMember: boolean
|
||||
rate: NonNullable<
|
||||
ReturnType<typeof useSelectRateContext>["selectedRates"]["rates"][number]
|
||||
>
|
||||
input: ReturnType<typeof useSelectRateContext>["input"]
|
||||
idx: number
|
||||
getPriceForRoom: (roomIndex: number) => Price | null
|
||||
rateTitles: ReturnType<typeof useRateTitles>
|
||||
}) {
|
||||
return {
|
||||
adults: input.data?.booking.rooms[idx].adults || 0,
|
||||
childrenInRoom: input.data?.booking.rooms[idx].childrenInRoom,
|
||||
roomType: rate.roomInfo.roomType,
|
||||
roomRate: rate,
|
||||
cancellationText: rateTitles[rate.rate].title,
|
||||
roomPrice: {
|
||||
perNight: { local: { price: -1, currency: CurrencyEnum.SEK } },
|
||||
perStay: getPriceForRoom(idx) ?? {
|
||||
local: { price: -1, currency: CurrencyEnum.Unknown },
|
||||
},
|
||||
},
|
||||
rateDetails: isMember
|
||||
? (rate.rateDefinitionMember?.generalTerms ??
|
||||
rate.rateDefinition.generalTerms)
|
||||
: rate.rateDefinition.generalTerms,
|
||||
packages: rate.roomInfo.selectedPackages,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prices .strikeThroughRate {
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import { getRoomPrice } from "@/stores/enter-details/helpers"
|
||||
|
||||
import Modal from "@/components/Modal"
|
||||
|
||||
import { getMemberPrice, isBookingCodeRate } from "../utils"
|
||||
import { isBookingCodeRate } from "../../utils"
|
||||
import { getMemberPrice } from "../utils"
|
||||
|
||||
import styles from "./room.module.css"
|
||||
|
||||
@@ -72,7 +71,6 @@ export default function Room({
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||
const showDiscounted = isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||
const regularRate = getRoomPrice(room.roomRate, showMemberPrice)
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
@@ -146,13 +144,12 @@ export default function Room({
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</p>
|
||||
{/* Show the price on which discount applies as Striked when discounted price is available */}
|
||||
{showDiscounted && regularRate.perStay.local.regularPrice ? (
|
||||
{showDiscounted && room.roomPrice.perStay.local.price ? (
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
regularRate.perStay.local.regularPrice,
|
||||
regularRate.perStay.local.currency
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
)}
|
||||
</s>
|
||||
) : null}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: var(--Space-x1);
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.prices .strikeThroughRate {
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
"use client"
|
||||
import { Fragment } from "react"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { longDateFormat } from "@scandic-hotels/common/constants/dateFormats"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { ChildBedMapEnum } from "@scandic-hotels/trpc/enums/childBedMapEnum"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import PriceDetailsModal from "@/components/HotelReservation/PriceDetailsModal"
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import Modal from "@/components/Modal"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import { mapToPrice } from "./mapToPrice"
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./summary.module.css"
|
||||
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||
|
||||
export default function Summary({
|
||||
booking,
|
||||
rooms,
|
||||
totalPrice,
|
||||
isMember,
|
||||
vat,
|
||||
toggleSummaryOpen,
|
||||
}: SelectRateSummaryProps) {
|
||||
const { rateSummary, defaultCurrency } = useRatesStore((state) => ({
|
||||
rateSummary: state.rateSummary,
|
||||
defaultCurrency: state.defaultCurrency,
|
||||
}))
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
|
||||
const diff = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||
|
||||
const nights = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: diff }
|
||||
)
|
||||
|
||||
function getMemberPrice(roomRate: RoomRate) {
|
||||
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 =
|
||||
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
|
||||
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(r) => r && isBookingCodeRate(r.roomRate)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
|
||||
const priceDetailsRooms = mapToPrice(rateSummary, booking.rooms, isMember)
|
||||
|
||||
return (
|
||||
<section className={styles.summary}>
|
||||
<header className={styles.header}>
|
||||
<ButtonRAC onPress={toggleSummaryOpen}>
|
||||
<Subtitle className={styles.title} type="two">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Booking summary",
|
||||
})}
|
||||
</Subtitle>
|
||||
<Body className={styles.date} color="baseTextMediumContrast">
|
||||
{dt(booking.fromDate).locale(lang).format(longDateFormat[lang])}
|
||||
<MaterialIcon
|
||||
icon="arrow_forward"
|
||||
size={15}
|
||||
color="Icon/Interactive/Secondary"
|
||||
/>
|
||||
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
|
||||
{dt(booking.toDate).locale(lang).format(longDateFormat[lang])} (
|
||||
{nights}){/* eslint-enable formatjs/no-literal-string-in-jsx */}
|
||||
</Body>
|
||||
<MaterialIcon
|
||||
className={styles.chevronIcon}
|
||||
icon="keyboard_arrow_down"
|
||||
size={30}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ButtonRAC>
|
||||
</header>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
{rooms.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
|
||||
const childrenBeds = childrenInRoom?.reduce(
|
||||
(acc, value) => {
|
||||
const bedType = Number(value.bed)
|
||||
if (bedType === ChildBedMapEnum.IN_ADULTS_BED) {
|
||||
return acc
|
||||
}
|
||||
const count = acc.get(bedType) ?? 0
|
||||
acc.set(bedType, count + 1)
|
||||
return acc
|
||||
},
|
||||
new Map<ChildBedMapEnum, number>([
|
||||
[ChildBedMapEnum.IN_CRIB, 0],
|
||||
[ChildBedMapEnum.IN_EXTRA_BED, 0],
|
||||
])
|
||||
)
|
||||
|
||||
const childBedCrib = childrenBeds?.get(ChildBedMapEnum.IN_CRIB)
|
||||
const childBedExtraBed = childrenBeds?.get(ChildBedMapEnum.IN_EXTRA_BED)
|
||||
|
||||
const memberPrice = getMemberPrice(room.roomRate)
|
||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||
const showDiscounted =
|
||||
isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||
|
||||
const adultsMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: adults }
|
||||
)
|
||||
|
||||
const guestsParts = [adultsMsg]
|
||||
if (childrenInRoom?.length) {
|
||||
const childrenMsg = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{ totalChildren: childrenInRoom.length }
|
||||
)
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
const roomPackages = room.packages
|
||||
const zeroPrice = formatPrice(intl, 0, defaultCurrency)
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
className={styles.addOns}
|
||||
data-testid={`summary-room-${roomNumber}`}
|
||||
>
|
||||
<div>
|
||||
{rooms.length > 1 ? (
|
||||
<Body textTransform="bold">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{
|
||||
roomIndex: roomNumber,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{guestsParts.join(", ")}
|
||||
</Caption>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{room.cancellationText}
|
||||
</Caption>
|
||||
<Modal
|
||||
trigger={
|
||||
<Button
|
||||
variant="Text"
|
||||
typography="Body/Supporting text (caption)/smBold"
|
||||
wrapping={false}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Rate details",
|
||||
})}
|
||||
<MaterialIcon
|
||||
icon="chevron_right"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={room.cancellationText}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{room.rateDetails?.map((info) => (
|
||||
<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>
|
||||
|
||||
{childBedCrib ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Crib (child) × {count}",
|
||||
},
|
||||
{ count: childBedCrib }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Subject to availability",
|
||||
})}
|
||||
</Caption>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">{zeroPrice}</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{childBedExtraBed ? (
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Extra bed (child) × {count}",
|
||||
},
|
||||
{
|
||||
count: childBedExtraBed,
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
<Body color="uiTextHighContrast">{zeroPrice}</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{roomPackages?.map((pkg) => (
|
||||
<div className={styles.entry} key={pkg.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{pkg.description}</Body>
|
||||
</div>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
pkg.localPrice.price,
|
||||
pkg.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider color="Border/Divider/Subtle" />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
<div className={styles.total}>
|
||||
<div className={styles.entry}>
|
||||
<div>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<PriceDetailsModal
|
||||
bookingCode={booking.bookingCode}
|
||||
fromDate={booking.fromDate}
|
||||
rooms={priceDetailsRooms}
|
||||
toDate={booking.toDate}
|
||||
totalPrice={totalPrice}
|
||||
vat={vat}
|
||||
defaultCurrency={defaultCurrency}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Body
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
textTransform="bold"
|
||||
data-testid="total-price"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
{booking.bookingCode && totalPrice.local.regularPrice && (
|
||||
<Caption color="uiTextMediumContrast" striked={true}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPrice.local.regularPrice,
|
||||
totalPrice.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
{totalPrice.requested && (
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPrice.requested.price,
|
||||
totalPrice.requested.currency,
|
||||
totalPrice.requested.additionalPrice,
|
||||
totalPrice.requested.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Caption>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider
|
||||
className={styles.bottomDivider}
|
||||
color="Border/Divider/Subtle"
|
||||
/>
|
||||
</div>
|
||||
{!isMember && memberPrice ? (
|
||||
<SignupPromoDesktop memberPrice={memberPrice} badgeContent={"✌️"} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -9,35 +9,21 @@ import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
import { isBookingCodeRate } from "../utils"
|
||||
import SummaryContent from "./Content"
|
||||
import { mapRate } from "./mapRate"
|
||||
import { isBookingCodeRate } from "./utils"
|
||||
|
||||
import styles from "./mobileSummary.module.css"
|
||||
|
||||
import type { RoomsAvailability } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
|
||||
export default function MobileSummary({
|
||||
isAllRoomsSelected,
|
||||
isUserLoggedIn,
|
||||
totalPriceToShow,
|
||||
}: MobileSummaryProps) {
|
||||
export function MobileSummary() {
|
||||
const intl = useIntl()
|
||||
const scrollY = useRef(0)
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
const isUserLoggedIn = useIsUserLoggedIn()
|
||||
|
||||
const { booking, bookingRooms, roomsAvailability, rateSummary, vat } =
|
||||
useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
bookingRooms: state.booking.rooms,
|
||||
roomsAvailability: state.roomsAvailability,
|
||||
rateSummary: state.rateSummary,
|
||||
vat: state.vat,
|
||||
}))
|
||||
const { selectedRates } = useSelectRateContext()
|
||||
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
@@ -67,38 +53,28 @@ export default function MobileSummary({
|
||||
}
|
||||
}, [isSummaryOpen])
|
||||
|
||||
const roomRateDefinitions = roomsAvailability?.find(
|
||||
(ra): ra is RoomsAvailability => "rateDefinitions" in ra
|
||||
const containsBookingCodeRate = selectedRates.rates.find(
|
||||
(r) => r && isBookingCodeRate(r)
|
||||
)
|
||||
if (!roomRateDefinitions) {
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
|
||||
if (!selectedRates.totalPrice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rooms = rateSummary.map((room, index) =>
|
||||
room ? mapRate(room, index, bookingRooms, room.packages) : null
|
||||
)
|
||||
|
||||
const containsBookingCodeRate = rateSummary.find(
|
||||
(r) => r && isBookingCodeRate(r.product)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
const totalRegularPrice = totalPriceToShow.local?.regularPrice
|
||||
? totalPriceToShow.local.regularPrice
|
||||
const totalRegularPrice = selectedRates.totalPrice.local?.regularPrice
|
||||
? selectedRates.totalPrice.local.regularPrice
|
||||
: 0
|
||||
|
||||
const showStrikeThroughPrice =
|
||||
totalRegularPrice > totalPriceToShow.local.price
|
||||
totalRegularPrice > selectedRates.totalPrice.local?.price
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-open={isSummaryOpen}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryAccordion}>
|
||||
<SummaryContent
|
||||
booking={booking}
|
||||
rooms={rooms}
|
||||
isMember={isUserLoggedIn}
|
||||
totalPrice={totalPriceToShow}
|
||||
vat={vat}
|
||||
toggleSummaryOpen={toggleSummaryOpen}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,22 +100,22 @@ export default function MobileSummary({
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
selectedRates.totalPrice.local.price,
|
||||
selectedRates.totalPrice.local.currency,
|
||||
selectedRates.totalPrice.local.additionalPrice,
|
||||
selectedRates.totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</span>
|
||||
</Typography>
|
||||
{showDiscounted &&
|
||||
showStrikeThroughPrice &&
|
||||
totalPriceToShow.local.regularPrice ? (
|
||||
selectedRates.totalPrice.local.regularPrice ? (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<s className={styles.strikeThroughRate}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.regularPrice,
|
||||
totalPriceToShow.local.currency
|
||||
selectedRates.totalPrice.local.regularPrice,
|
||||
selectedRates.totalPrice.local.currency
|
||||
)}
|
||||
</s>
|
||||
</Typography>
|
||||
@@ -166,7 +142,7 @@ export default function MobileSummary({
|
||||
size="Large"
|
||||
type="submit"
|
||||
typography="Body/Paragraph/mdBold"
|
||||
isDisabled={!isAllRoomsSelected}
|
||||
isDisabled={selectedRates.state !== "ALL_SELECTED"}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.priceDetailsButton .strikeThroughRate {
|
||||
.strikeThroughRate {
|
||||
text-decoration: line-through;
|
||||
color: var(--Text-Secondary);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
border-radius: var(--Corner-radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
padding: var(--Space-x3);
|
||||
gap: var(--Spacing-x2);
|
||||
padding: var(--Spacing-x3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -31,32 +31,32 @@
|
||||
.date {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--Space-x1);
|
||||
gap: var(--Spacing-x1);
|
||||
justify-content: flex-start;
|
||||
grid-area: date;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-top: var(--Space-x1);
|
||||
margin-top: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
.addOns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x15);
|
||||
gap: var(--Spacing-x-one-and-half);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rateDetailsPopover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x05);
|
||||
gap: var(--Spacing-x-half);
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--Space-x05);
|
||||
gap: var(--Spacing-x-half);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
.total {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--Space-x2);
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
|
||||
.bottomDivider {
|
||||
@@ -79,15 +79,16 @@
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-top: var(--Space-x3);
|
||||
margin-bottom: var(--Space-x3);
|
||||
margin-top: var(--Spacing-x3);
|
||||
margin-bottom: var(--Spacing-x3);
|
||||
}
|
||||
.termsText:nth-child(n) {
|
||||
display: flex;
|
||||
margin-bottom: var(--Space-x1);
|
||||
align-items: center;
|
||||
margin-bottom: var(--Spacing-x1);
|
||||
}
|
||||
.terms .termsIcon {
|
||||
margin-right: var(--Space-x1);
|
||||
margin-right: var(--Spacing-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1367px) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||
|
||||
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
|
||||
export function getMemberPrice(roomRate: RoomRate) {
|
||||
@@ -15,21 +11,3 @@ export function getMemberPrice(roomRate: RoomRate) {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,138 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useState, useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
import Footnote from "@scandic-hotels/design-system/Footnote"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import SignupPromoDesktop from "@/components/HotelReservation/SignupPromo/Desktop"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import { getTotalPrice } from "./utils"
|
||||
import { DesktopSummary } from "./DesktopSummary"
|
||||
import { MobileSummary } from "./MobileSummary"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
export default function RateSummary() {
|
||||
const {
|
||||
bookingCode,
|
||||
bookingRooms,
|
||||
dates,
|
||||
isFetchingPackages,
|
||||
rateSummary,
|
||||
roomsAvailability,
|
||||
} = useRatesStore((state) => ({
|
||||
bookingCode: state.booking.bookingCode,
|
||||
bookingRooms: state.booking.rooms,
|
||||
dates: {
|
||||
checkInDate: state.booking.fromDate,
|
||||
checkOutDate: state.booking.toDate,
|
||||
},
|
||||
isFetchingPackages: state.rooms.some((room) => room.isFetchingPackages),
|
||||
rateSummary: state.rateSummary,
|
||||
roomsAvailability: state.roomsAvailability,
|
||||
}))
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
export function RateSummary() {
|
||||
return (
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
<ErrorBoundary fallback={<div>Unable to render summary</div>}>
|
||||
<InnerRateSummary />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerRateSummary() {
|
||||
const { selectedRates, input } = useSelectRateContext()
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const intl = useIntl()
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const [_, startTransition] = useTransition()
|
||||
|
||||
if (!roomsAvailability) {
|
||||
if (selectedRates.state === "NONE_SELECTED") {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkInDate = new Date(dates.checkInDate)
|
||||
const checkOutDate = new Date(dates.checkOutDate)
|
||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||
|
||||
const totalNights = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalNights, plural, one {# night} other {# nights}}",
|
||||
},
|
||||
{ totalNights: nights }
|
||||
)
|
||||
const totalAdults = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalAdults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ totalAdults: bookingRooms.reduce((acc, room) => acc + room.adults, 0) }
|
||||
)
|
||||
const childrenInOneOrMoreRooms = bookingRooms.some(
|
||||
(room) => room.childrenInRoom?.length
|
||||
)
|
||||
const childrenInroom = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"{totalChildren, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
totalChildren: bookingRooms.reduce(
|
||||
(acc, room) => acc + (room.childrenInRoom?.length ?? 0),
|
||||
0
|
||||
),
|
||||
}
|
||||
)
|
||||
const totalChildren = childrenInOneOrMoreRooms ? `, ${childrenInroom}` : ""
|
||||
const totalRooms = intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{totalRooms, plural, one {# room} other {# rooms}}",
|
||||
},
|
||||
{ totalRooms: bookingRooms.length }
|
||||
)
|
||||
|
||||
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||
|
||||
const totalRoomsRequired = bookingRooms.length
|
||||
const isAllRoomsSelected =
|
||||
rateSummary.filter((rate) => rate !== null).length === totalRoomsRequired
|
||||
const hasMemberRates = rateSummary.some(
|
||||
(room) => room && "member" in room.product && room.product.member
|
||||
)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
function getRateDetails(rate: RateEnum) {
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
@@ -141,62 +40,15 @@ export default function RateSummary() {
|
||||
})
|
||||
}
|
||||
|
||||
if (!rateSummary.length || isFetchingPackages) {
|
||||
return null
|
||||
}
|
||||
const totalPriceToShow = selectedRates.totalPrice
|
||||
|
||||
const isBookingCodeRate = rateSummary.some(
|
||||
(rate) =>
|
||||
rate &&
|
||||
"public" in rate.product &&
|
||||
rate.product.public?.rateType !== RateTypeEnum.Regular
|
||||
)
|
||||
const isVoucherRate = rateSummary.some(
|
||||
(rate) => rate && "voucher" in rate.product
|
||||
)
|
||||
const isCorporateChequeRate = rateSummary.some(
|
||||
(rate) => rate && "corporateCheque" in rate.product
|
||||
)
|
||||
const showDiscounted =
|
||||
isUserLoggedIn ||
|
||||
isBookingCodeRate ||
|
||||
isVoucherRate ||
|
||||
isCorporateChequeRate
|
||||
|
||||
const mainRoomProduct = rateSummary[0]
|
||||
const totalPriceToShow = getTotalPrice(
|
||||
mainRoomProduct,
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
intl
|
||||
)
|
||||
|
||||
const rateProduct = rateSummary.find((rate) => rate?.product)?.product
|
||||
|
||||
if (!totalPriceToShow || !rateProduct) {
|
||||
return null
|
||||
}
|
||||
|
||||
let mainRoomCurrency = ""
|
||||
if ("member" in rateProduct && rateProduct.member?.localPrice) {
|
||||
mainRoomCurrency = rateProduct.member.localPrice.currency
|
||||
}
|
||||
if (
|
||||
!mainRoomCurrency &&
|
||||
"public" in rateProduct &&
|
||||
rateProduct.public?.localPrice
|
||||
!totalPriceToShow ||
|
||||
!selectedRates.rates.some((room) => room?.isSelected ?? false)
|
||||
) {
|
||||
mainRoomCurrency = rateProduct.public.localPrice.currency
|
||||
return null
|
||||
}
|
||||
|
||||
const totalRegularPrice = totalPriceToShow.local?.regularPrice
|
||||
? totalPriceToShow.local.regularPrice
|
||||
: 0
|
||||
const isTotalRegularPriceGreaterThanPrice =
|
||||
totalRegularPrice > totalPriceToShow.local.price
|
||||
const showStrikedThroughPrice =
|
||||
(!!bookingCode || isUserLoggedIn) && isTotalRegularPriceGreaterThanPrice
|
||||
|
||||
// attribute data-footer-spacing used to add spacing
|
||||
// beneath footer to be able to show entire footer upon
|
||||
// scrolling down to the bottom of the page
|
||||
@@ -209,218 +61,21 @@ export default function RateSummary() {
|
||||
>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
{rateSummary.map((room, index) => {
|
||||
if (!room) {
|
||||
return (
|
||||
<div key={`unselected-${index}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Select room",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "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,
|
||||
}).map((_, index) => (
|
||||
<div key={`unselected-${index}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Room {roomIndex}",
|
||||
},
|
||||
{ roomIndex: rateSummary.length + index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Select room",
|
||||
})}
|
||||
</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.summaryPriceContainer}>
|
||||
{showMemberDiscountBanner && (
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: rateSummary.reduce((total, rate) => {
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const { packages: roomPackages, product } = rate
|
||||
|
||||
const memberExists = "member" in product && product.member
|
||||
const publicExists = "public" in product && product.public
|
||||
if (!memberExists) {
|
||||
if (!publicExists) {
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
const price =
|
||||
product.member?.localPrice.pricePerStay ||
|
||||
product.public?.localPrice.pricePerStay
|
||||
|
||||
if (!price) {
|
||||
return total
|
||||
}
|
||||
|
||||
const selectedPackagesPrice = roomPackages.reduce(
|
||||
(acc, pkg) => acc + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
|
||||
return total + price + selectedPackagesPrice
|
||||
}, 0),
|
||||
currency: mainRoomCurrency,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Body>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "<b>Total price</b> (incl VAT)",
|
||||
},
|
||||
{ b: (str) => <b>{str}</b> }
|
||||
)}
|
||||
</Body>
|
||||
<Caption color="uiTextMediumContrast">{summaryPriceText}</Caption>
|
||||
</div>
|
||||
<div className={styles.summaryPrice}>
|
||||
<div className={styles.summaryPriceTextDesktop}>
|
||||
<Subtitle
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
{showStrikedThroughPrice &&
|
||||
totalPriceToShow.local.regularPrice ? (
|
||||
<Caption
|
||||
textAlign="right"
|
||||
color="uiTextMediumContrast"
|
||||
striked={true}
|
||||
>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.regularPrice,
|
||||
totalPriceToShow.local.currency
|
||||
)}
|
||||
</Caption>
|
||||
) : null}
|
||||
{totalPriceToShow.requested ? (
|
||||
<Body color="uiTextMediumContrast">
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "Approx. {value}",
|
||||
},
|
||||
{
|
||||
value: formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.requested.price,
|
||||
totalPriceToShow.requested.currency,
|
||||
totalPriceToShow.requested.additionalPrice,
|
||||
totalPriceToShow.requested.additionalPriceCurrency
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Body>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.summaryPriceTextMobile}>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Total price",
|
||||
})}
|
||||
</Caption>
|
||||
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
color="uiTextMediumContrast"
|
||||
className={styles.summaryPriceTextMobile}
|
||||
>
|
||||
{summaryPriceText}
|
||||
</Footnote>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.continueButton}
|
||||
disabled={!isAllRoomsSelected || isSubmitting}
|
||||
theme="base"
|
||||
type="submit"
|
||||
>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Continue",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<ErrorBoundary fallback={<div>Unable to render desktop summary</div>}>
|
||||
<DesktopSummary
|
||||
isSubmitting={isSubmitting}
|
||||
input={input}
|
||||
selectedRates={selectedRates}
|
||||
bookingCode={input.data?.booking.bookingCode || ""}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<div className={styles.mobileSummary}>
|
||||
<MobileSummary
|
||||
isAllRoomsSelected={isAllRoomsSelected}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
totalPriceToShow={totalPriceToShow}
|
||||
/>
|
||||
{/* eslint-disable-next-line formatjs/no-literal-string-in-jsx */}
|
||||
<ErrorBoundary fallback={<div>Unable to render mobile summary</div>}>
|
||||
<MobileSummary />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,7 +4,10 @@ import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||
import { sumPackages } from "@/components/HotelReservation/utils"
|
||||
|
||||
import type { Packages } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type {
|
||||
Product,
|
||||
RedemptionProduct,
|
||||
} from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type { IntlShape } from "react-intl"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
@@ -23,10 +26,8 @@ export function calculateTotalPrice(
|
||||
const roomNr = idx + 1
|
||||
const isMainRoom = roomNr === 1
|
||||
let rate
|
||||
let publicRate
|
||||
if (isUserLoggedIn && isMainRoom && room.product.member) {
|
||||
rate = room.product.member
|
||||
publicRate = room.product.public
|
||||
} else if (room.product.public) {
|
||||
rate = room.product.public
|
||||
}
|
||||
@@ -50,16 +51,10 @@ export function calculateTotalPrice(
|
||||
total.local.price =
|
||||
total.local.price + rate.localPrice.pricePerStay + packagesPrice.local
|
||||
|
||||
if (rate.rateType === RateTypeEnum.Regular && publicRate) {
|
||||
if (rate.localPrice.regularPricePerStay) {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
publicRate.localPrice.pricePerStay +
|
||||
packagesPrice.local
|
||||
} else {
|
||||
total.local.regularPrice =
|
||||
(total.local.regularPrice || 0) +
|
||||
(rate.localPrice.regularPricePerStay ||
|
||||
rate.localPrice.pricePerStay) +
|
||||
rate.localPrice.regularPricePerStay +
|
||||
packagesPrice.local
|
||||
}
|
||||
|
||||
@@ -248,3 +243,23 @@ export function getTotalPrice(
|
||||
|
||||
return calculateTotalPrice(summaryArray, isUserLoggedIn)
|
||||
}
|
||||
|
||||
export function isBookingCodeRate(product: Product | undefined | null) {
|
||||
if (!product) return false
|
||||
|
||||
if (
|
||||
"corporateCheque" in product ||
|
||||
"redemption" in product ||
|
||||
"voucher" in product
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
if (product.public) {
|
||||
return product.public.rateType !== RateTypeEnum.Regular
|
||||
}
|
||||
if (product.member) {
|
||||
return product.member.rateType !== RateTypeEnum.Regular
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CurrencyEnum } from "@scandic-hotels/common/constants/currency"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
import Body from "@scandic-hotels/design-system/Body"
|
||||
import Caption from "@scandic-hotels/design-system/Caption"
|
||||
@@ -13,123 +11,40 @@ import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
import { RateEnum } from "@scandic-hotels/trpc/enums/rate"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Chip from "@/components/TempDesignSystem/Chip"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
import styles from "./selectedRoomPanel.module.css"
|
||||
|
||||
export default function SelectedRoomPanel() {
|
||||
export function SelectedRoomPanel({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const { dates, roomCategories, rooms } = useRatesStore((state) => ({
|
||||
dates: {
|
||||
from: state.booking.fromDate,
|
||||
to: state.booking.toDate,
|
||||
},
|
||||
roomCategories: state.roomCategories,
|
||||
rooms: state.rooms,
|
||||
}))
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
|
||||
const isMainRoom = roomIndex === 0
|
||||
const roomNr = roomIndex + 1
|
||||
const {
|
||||
actions: { modifyRate },
|
||||
isMainRoom,
|
||||
roomNr,
|
||||
selectedPackages,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
const nights = dt(dates.to).diff(dt(dates.from), "days")
|
||||
selectedRates,
|
||||
actions: { setActiveRoom },
|
||||
} = useSelectRateContext()
|
||||
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||
const images = selectedRate?.roomInfo?.roomInfo?.images
|
||||
|
||||
const images = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.some(
|
||||
(roomType) => roomType.code === selectedRate?.roomTypeCode
|
||||
)
|
||||
)?.images
|
||||
const rateTitle = useRateTitle(selectedRate?.rate)
|
||||
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
function getRateTitle(rate: RateEnum) {
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
const selectedProductTitle = useSelectedProductTitle({ roomIndex })
|
||||
|
||||
if (!selectedRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedPackagesCurrency = selectedPackages.find(
|
||||
(pkg) => pkg.localPrice.currency
|
||||
)
|
||||
const selectedPackagesPrice = selectedPackages.reduce(
|
||||
(total, pkg) => total + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
const selectedPackagesPricePerNight = Math.ceil(
|
||||
selectedPackagesPrice / nights
|
||||
)
|
||||
|
||||
const night = intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})
|
||||
let selectedProduct
|
||||
if (
|
||||
isUserLoggedIn &&
|
||||
isMainRoom &&
|
||||
"member" in selectedRate.product &&
|
||||
selectedRate.product.member
|
||||
) {
|
||||
const { localPrice } = selectedRate.product.member
|
||||
selectedProduct = `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
} else if ("public" in selectedRate.product && selectedRate.product.public) {
|
||||
const { localPrice } = selectedRate.product.public
|
||||
selectedProduct = `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
} else if ("corporateCheque" in selectedRate.product) {
|
||||
const { localPrice } = selectedRate.product.corporateCheque
|
||||
selectedProduct = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
|
||||
if (
|
||||
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
|
||||
localPrice.currency
|
||||
) {
|
||||
selectedProduct = `${selectedProduct} + ${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
|
||||
}
|
||||
} else if ("voucher" in selectedRate.product) {
|
||||
selectedProduct = `${selectedRate.product.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
|
||||
if (selectedPackagesPrice && selectedPackagesCurrency) {
|
||||
selectedProduct = `${selectedProduct} + ${selectedPackagesPrice} ${selectedPackagesCurrency}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedProduct) {
|
||||
if (!selectedProductTitle) {
|
||||
logger.error("Selected product is unknown")
|
||||
return null
|
||||
}
|
||||
|
||||
const showModifyButton =
|
||||
isMainRoom ||
|
||||
(!isMainRoom && rooms.slice(0, roomNr).every((room) => room.selectedRate))
|
||||
(!isMainRoom && selectedRates.rates.slice(0, roomNr).every((room) => room))
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
@@ -143,17 +58,19 @@ export default function SelectedRoomPanel() {
|
||||
)}
|
||||
</Caption>
|
||||
<Subtitle className={styles.subtitle} color="uiTextHighContrast">
|
||||
{selectedRate.roomType}
|
||||
{selectedRate.roomInfo.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateTitle(selectedRate.product.rate)}
|
||||
</Body>
|
||||
<Body color="uiTextHighContrast">{selectedProduct}</Body>
|
||||
<Body color="uiTextMediumContrast">{rateTitle}</Body>
|
||||
<Body color="uiTextHighContrast">{selectedProductTitle}</Body>
|
||||
</div>
|
||||
<div className={styles.imageContainer}>
|
||||
{images?.[0]?.imageSizes?.tiny ? (
|
||||
<Image
|
||||
alt={selectedRate.roomType ?? images[0].metaData?.altText ?? ""}
|
||||
alt={
|
||||
selectedRate.roomInfo.roomType ??
|
||||
images[0].metaData?.altText ??
|
||||
""
|
||||
}
|
||||
className={styles.img}
|
||||
height={300}
|
||||
src={images[0].imageSizes.tiny}
|
||||
@@ -162,7 +79,7 @@ export default function SelectedRoomPanel() {
|
||||
) : null}
|
||||
{showModifyButton && (
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button clean onClick={modifyRate}>
|
||||
<Button clean onClick={() => setActiveRoom(roomIndex)}>
|
||||
<Chip size="small" variant="uiTextHighContrast">
|
||||
<MaterialIcon
|
||||
size={16}
|
||||
@@ -180,3 +97,99 @@ export default function SelectedRoomPanel() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useSelectedProductTitle({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useIsUserLoggedIn()
|
||||
const {
|
||||
selectedRates,
|
||||
input: { nights },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||
|
||||
const night = intl.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})
|
||||
|
||||
const isMainRoom = roomIndex === 0
|
||||
|
||||
if (!selectedRate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedPackagesCurrency = selectedRate.roomInfo.selectedPackages.find(
|
||||
(pkg) => pkg.localPrice.currency
|
||||
)
|
||||
const selectedPackagesPrice = selectedRate.roomInfo.selectedPackages.reduce(
|
||||
(total, pkg) => total + pkg.localPrice.totalPrice,
|
||||
0
|
||||
)
|
||||
const selectedPackagesPricePerNight = Math.ceil(
|
||||
selectedPackagesPrice / nights
|
||||
)
|
||||
|
||||
if (
|
||||
isUserLoggedIn &&
|
||||
isMainRoom &&
|
||||
"member" in selectedRate &&
|
||||
selectedRate.member
|
||||
) {
|
||||
const { localPrice } = selectedRate.member
|
||||
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
}
|
||||
|
||||
if ("public" in selectedRate && selectedRate.public) {
|
||||
const { localPrice } = selectedRate.public
|
||||
return `${localPrice.pricePerNight + selectedPackagesPricePerNight} ${localPrice.currency} / ${night}`
|
||||
}
|
||||
|
||||
if ("corporateCheque" in selectedRate) {
|
||||
const { localPrice } = selectedRate.corporateCheque
|
||||
const mainProductTitle = `${localPrice.numberOfCheques} ${CurrencyEnum.CC}`
|
||||
if (
|
||||
(localPrice.additionalPricePerStay || selectedPackagesPrice) &&
|
||||
localPrice.currency
|
||||
) {
|
||||
const packagesText = `${localPrice.additionalPricePerStay + selectedPackagesPrice} ${localPrice.currency}`
|
||||
return `${mainProductTitle} + ${packagesText}`
|
||||
}
|
||||
}
|
||||
|
||||
if ("voucher" in selectedRate) {
|
||||
const mainProductText = `${selectedRate.voucher.numberOfVouchers} ${CurrencyEnum.Voucher}`
|
||||
if (selectedPackagesPrice && selectedPackagesCurrency) {
|
||||
const packagesText = `${selectedPackagesPrice} ${selectedPackagesCurrency}`
|
||||
return `${mainProductText} + ${packagesText}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useRateTitle(rate: RateEnum | undefined) {
|
||||
const intl = useIntl()
|
||||
const freeCancelation = intl.formatMessage({
|
||||
defaultMessage: "Free cancellation",
|
||||
})
|
||||
const nonRefundable = intl.formatMessage({
|
||||
defaultMessage: "Non-refundable",
|
||||
})
|
||||
const freeBooking = intl.formatMessage({
|
||||
defaultMessage: "Free rebooking",
|
||||
})
|
||||
const payLater = intl.formatMessage({
|
||||
defaultMessage: "Pay later",
|
||||
})
|
||||
const payNow = intl.formatMessage({
|
||||
defaultMessage: "Pay now",
|
||||
})
|
||||
|
||||
switch (rate) {
|
||||
case RateEnum.change:
|
||||
return `${freeBooking}, ${payNow}`
|
||||
case RateEnum.flex:
|
||||
return `${freeCancelation}, ${payLater}`
|
||||
case RateEnum.save:
|
||||
default:
|
||||
return `${nonRefundable}, ${payNow}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,29 +6,32 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { OldDSButton as Button } from "@scandic-hotels/design-system/OldDSButton"
|
||||
import Subtitle from "@scandic-hotels/design-system/Subtitle"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import SelectedRoomPanel from "./SelectedRoomPanel"
|
||||
import { SelectedRoomPanel } from "./SelectedRoomPanel"
|
||||
import { roomSelectionPanelVariants } from "./variants"
|
||||
|
||||
import styles from "./multiRoomWrapper.module.css"
|
||||
|
||||
export default function MultiRoomWrapper({
|
||||
children,
|
||||
isMultiRoom,
|
||||
}: React.PropsWithChildren<{ isMultiRoom: boolean }>) {
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
isMultiRoom: boolean
|
||||
roomIndex: number
|
||||
}
|
||||
export function MultiRoomWrapper({ children, isMultiRoom, roomIndex }: Props) {
|
||||
const intl = useIntl()
|
||||
const activeRoom = useRatesStore((state) => state.activeRoom)
|
||||
const {
|
||||
actions: { closeSection },
|
||||
bookingRoom,
|
||||
isActiveRoom,
|
||||
roomNr,
|
||||
selectedRate,
|
||||
} = useRoomContext()
|
||||
|
||||
const { getTopOffset } = useStickyPosition()
|
||||
const {
|
||||
activeRoomIndex,
|
||||
selectedRates,
|
||||
actions: { setActiveRoom },
|
||||
input: { data },
|
||||
} = useSelectRateContext()
|
||||
const roomNr = roomIndex + 1
|
||||
const adultCount = data?.booking.rooms[roomIndex]?.adults || 0
|
||||
const childCount = data?.booking.rooms[roomIndex]?.childrenInRoom?.length || 0
|
||||
const isActiveRoom = activeRoomIndex === roomIndex
|
||||
|
||||
const roomMsg = intl.formatMessage(
|
||||
{
|
||||
@@ -41,7 +44,7 @@ export default function MultiRoomWrapper({
|
||||
{
|
||||
defaultMessage: "{adults, plural, one {# adult} other {# adults}}",
|
||||
},
|
||||
{ adults: bookingRoom.adults }
|
||||
{ adults: adultCount }
|
||||
)
|
||||
|
||||
const childrenMsg = intl.formatMessage(
|
||||
@@ -49,15 +52,13 @@ export default function MultiRoomWrapper({
|
||||
defaultMessage: "{children, plural, one {# child} other {# children}}",
|
||||
},
|
||||
{
|
||||
children: bookingRoom.childrenInRoom?.length,
|
||||
children: childCount,
|
||||
}
|
||||
)
|
||||
|
||||
const onlyAdultsMsg = adultsMsg
|
||||
const adultsAndChildrenMsg = [adultsMsg, childrenMsg].join(", ")
|
||||
const guestsMsg = bookingRoom.childrenInRoom?.length
|
||||
? adultsAndChildrenMsg
|
||||
: onlyAdultsMsg
|
||||
const guestsMsg = childCount ? adultsAndChildrenMsg : onlyAdultsMsg
|
||||
|
||||
const title = [roomMsg, guestsMsg].join(", ")
|
||||
|
||||
@@ -69,7 +70,7 @@ export default function MultiRoomWrapper({
|
||||
// If no room is active we will show all rooms collapsed, hence we want
|
||||
// to scroll to the first room.
|
||||
const selectedRoom =
|
||||
activeRoom === -1 ? roomElements[0] : roomElements[activeRoom]
|
||||
activeRoomIndex === -1 ? roomElements[0] : roomElements[activeRoomIndex]
|
||||
|
||||
if (selectedRoom) {
|
||||
const elementPosition = selectedRoom.getBoundingClientRect().top
|
||||
@@ -86,7 +87,9 @@ export default function MultiRoomWrapper({
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeRoom])
|
||||
}, [activeRoomIndex])
|
||||
|
||||
const selectedRate = selectedRates.rateSelectedForRoom(roomIndex)
|
||||
|
||||
if (isMultiRoom) {
|
||||
const classNames = roomSelectionPanelVariants({
|
||||
@@ -102,7 +105,9 @@ export default function MultiRoomWrapper({
|
||||
{selectedRate && isActiveRoom ? (
|
||||
<Button
|
||||
intent="text"
|
||||
onClick={closeSection}
|
||||
onClick={() => {
|
||||
setActiveRoom("deselect")
|
||||
}}
|
||||
size="medium"
|
||||
theme="base"
|
||||
variant="icon"
|
||||
@@ -120,7 +125,7 @@ export default function MultiRoomWrapper({
|
||||
</div>
|
||||
<div className={classNames}>
|
||||
<div className={styles.roomPanel}>
|
||||
<SelectedRoomPanel />
|
||||
<SelectedRoomPanel roomIndex={roomIndex} />
|
||||
</div>
|
||||
<div className={styles.roomSelectionPanel}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -5,31 +5,36 @@ import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotel
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import styles from "./alert.module.css"
|
||||
|
||||
export default function NoAvailabilityAlert() {
|
||||
export default function NoAvailabilityAlert({
|
||||
roomIndex,
|
||||
}: {
|
||||
roomIndex: number
|
||||
}) {
|
||||
const lang = useLang()
|
||||
const intl = useIntl()
|
||||
const [bookingCode, selectedRooms, activeRoom] = useRatesStore((state) => [
|
||||
state.booking.bookingCode,
|
||||
state.rooms,
|
||||
state.activeRoom,
|
||||
])
|
||||
|
||||
const { isFetchingPackages, rooms } = useRoomContext()
|
||||
const { availability, input } = useSelectRateContext()
|
||||
if (availability.isFetching || !availability.data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const noAvailableRooms = rooms.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
const indexed = availability.data[roomIndex]
|
||||
const hasAvailabilityError = "error" in indexed
|
||||
if (hasAvailabilityError) {
|
||||
return null
|
||||
}
|
||||
|
||||
const noAvailableRooms = hasAvailableRoomsForRoom(indexed.roomConfigurations)
|
||||
|
||||
const alertLink =
|
||||
activeRoom !== -1 && selectedRooms[activeRoom].selectedPackages.length === 0
|
||||
roomIndex !== -1 &&
|
||||
(input.data?.booking.rooms.at(roomIndex)?.packages ?? []).length === 0
|
||||
? {
|
||||
title: intl.formatMessage({
|
||||
defaultMessage: "See alternative hotels",
|
||||
@@ -39,10 +44,6 @@ export default function NoAvailabilityAlert() {
|
||||
}
|
||||
: null
|
||||
|
||||
if (isFetchingPackages) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (noAvailableRooms) {
|
||||
const text = intl.formatMessage({
|
||||
defaultMessage: "There are no rooms available that match your request.",
|
||||
@@ -61,7 +62,7 @@ export default function NoAvailabilityAlert() {
|
||||
)
|
||||
}
|
||||
|
||||
const isPublicPromotionWithCode = rooms.some((room) => {
|
||||
const isPublicPromotionWithCode = indexed.roomConfigurations.some((room) => {
|
||||
const filteredCampaigns = room.campaign.filter(Boolean)
|
||||
return filteredCampaigns.length
|
||||
? filteredCampaigns.every(
|
||||
@@ -72,19 +73,20 @@ export default function NoAvailabilityAlert() {
|
||||
|
||||
const noAvailableBookingCodeRooms =
|
||||
!isPublicPromotionWithCode &&
|
||||
rooms.every(
|
||||
indexed.roomConfigurations.every(
|
||||
(room) =>
|
||||
room.status === AvailabilityEnum.NotAvailable || !room.code.length
|
||||
)
|
||||
|
||||
if (bookingCode && noAvailableBookingCodeRooms) {
|
||||
if (input.bookingCode && noAvailableBookingCodeRooms) {
|
||||
const bookingCodeText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
"We found no available rooms using this booking code ({bookingCode}). See available rates below.",
|
||||
},
|
||||
{ bookingCode }
|
||||
{ bookingCode: input.bookingCode }
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={styles.hotelAlert}>
|
||||
<Alert
|
||||
@@ -101,3 +103,16 @@ export default function NoAvailabilityAlert() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function hasAvailableRoomsForRoom(
|
||||
roomConfigurations: Extract<
|
||||
NonNullable<
|
||||
ReturnType<typeof useSelectRateContext>["availability"]["data"]
|
||||
>[number],
|
||||
{ roomConfigurations: unknown }
|
||||
>["roomConfigurations"]
|
||||
) {
|
||||
return roomConfigurations.every(
|
||||
(roomConfig) => roomConfig.status === AvailabilityEnum.NotAvailable
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
.bookingCodeFilter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
border-radius: var(--Corner-radius-md);
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: var(--popup-box-shadow);
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
display: grid;
|
||||
gap: var(--Space-x1);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.radio {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.radio[data-hovered] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.radio[data-focus-visible]::before {
|
||||
outline: 1px auto var(--Border-Interactive-Focus);
|
||||
}
|
||||
|
||||
.radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio::before {
|
||||
flex-shrink: 0;
|
||||
content: "";
|
||||
margin-right: var(--Space-x15);
|
||||
background-color: var(--Surface-UI-Fill-Default);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px var(--Base-Border-Normal);
|
||||
}
|
||||
|
||||
.radio[data-selected]::before {
|
||||
box-shadow: inset 0 0 0 8px var(--Surface-UI-Fill-Active);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: var(--Overlay-40);
|
||||
|
||||
&[data-entering] {
|
||||
animation: overlay-fade 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: overlay-fade 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--Space-x2) var(--Space-x05);
|
||||
border-radius: var(--Corner-radius-md) var(--Corner-radius-md) 0 0;
|
||||
background-color: var(--Surface-Primary-Default);
|
||||
box-shadow: 0px 0px 14px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&[data-entering] {
|
||||
animation: modal-anim 200ms;
|
||||
}
|
||||
|
||||
&[data-exiting] {
|
||||
animation: modal-anim 150ms reverse ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
display: grid;
|
||||
gap: var(--Space-x2);
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 var(--Space-x1);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.radioGroup {
|
||||
padding: var(--Space-x1);
|
||||
}
|
||||
|
||||
.modalOverlay {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlay-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-anim {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RateTypeEnum } from "@scandic-hotels/trpc/enums/rateType"
|
||||
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint"
|
||||
|
||||
import styles from "./bookingCodeFilter.module.css"
|
||||
|
||||
export function BookingCodeFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
input,
|
||||
getAvailabilityForRoom,
|
||||
bookingCodeFilter,
|
||||
actions: { selectBookingCodeFilter },
|
||||
} = useSelectRateContext()
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex)
|
||||
|
||||
const bookingCodeFilterItems = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Booking code rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.Discounted,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "All rates",
|
||||
}),
|
||||
value: BookingCodeFilterEnum.All,
|
||||
},
|
||||
]
|
||||
|
||||
async function updateFilterValue(selectedFilter: string) {
|
||||
selectBookingCodeFilter(selectedFilter as BookingCodeFilterEnum)
|
||||
}
|
||||
|
||||
const hideFilter = (roomAvailability ?? []).some((room) => {
|
||||
room.products.some((product) => {
|
||||
const isRedemption = Array.isArray(product)
|
||||
if (isRedemption) {
|
||||
return true
|
||||
}
|
||||
|
||||
switch (product.rateDefinition.rateType) {
|
||||
case RateTypeEnum.Arb:
|
||||
case RateTypeEnum.CorporateCheque:
|
||||
case RateTypeEnum.Voucher:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (hideFilter || !input?.bookingCode) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.bookingCodeFilter}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{
|
||||
bookingCodeFilterItems.find(
|
||||
(item) => item.value === bookingCodeFilter
|
||||
)?.label
|
||||
}
|
||||
<MaterialIcon
|
||||
icon="keyboard_arrow_down"
|
||||
size={20}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</ChipButton>
|
||||
{!displayAsModal ? (
|
||||
<Popover placement="bottom end" isNonModal>
|
||||
<Dialog className={styles.dialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
autoFocus={bookingCodeFilter === item.value}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Popover>
|
||||
) : (
|
||||
<ModalOverlay className={styles.modalOverlay} isDismissable>
|
||||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.modalDialog}>
|
||||
{({ close }) => {
|
||||
function handleChangeFilterValue(value: string) {
|
||||
updateFilterValue(value)
|
||||
close()
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<Typography variant="Title/Subtitle/md">
|
||||
<h3>
|
||||
{intl.formatMessage({
|
||||
defaultMessage: "Room rates",
|
||||
})}
|
||||
</h3>
|
||||
</Typography>
|
||||
<IconButton
|
||||
theme="Black"
|
||||
style="Muted"
|
||||
onPress={() => {
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
icon="close"
|
||||
size={24}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<RadioGroup
|
||||
aria-label={intl.formatMessage({
|
||||
defaultMessage: "Booking Code Filter",
|
||||
})}
|
||||
onChange={handleChangeFilterValue}
|
||||
name="bookingCodeFilter"
|
||||
value={bookingCodeFilter}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
{bookingCodeFilterItems.map((item) => (
|
||||
<Radio
|
||||
aria-label={item.label}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
className={styles.radio}
|
||||
>
|
||||
{item.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import BookingCodeChip from "@/components/BookingCodeChip"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
export function RemoveBookingCodeButton() {
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
const roomNr = useRatesStore((state) =>
|
||||
state.activeRoom !== -1 ? state.activeRoom : 0
|
||||
)
|
||||
const {
|
||||
input: { bookingCode },
|
||||
} = useSelectRateContext()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
@@ -26,9 +24,6 @@ export function RemoveBookingCodeButton() {
|
||||
onClose={() => {
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.delete("bookingCode")
|
||||
newSearchParams.delete(`room[${roomNr}].bookingCode`)
|
||||
newSearchParams.delete(`room[${roomNr}].ratecode`)
|
||||
newSearchParams.delete(`room[${roomNr}].roomtype`)
|
||||
|
||||
const url = `${pathname}?${newSearchParams.toString()}`
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { formatPrice } from "@scandic-hotels/common/utils/numberFormatting"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import styles from "./petRoom.module.css"
|
||||
|
||||
export default function PetRoomMessage() {
|
||||
export default function PetRoomMessage({
|
||||
priceData,
|
||||
}: {
|
||||
priceData?: { price: number; currency: string }
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const { petRoomPackage } = useRoomContext()
|
||||
if (!petRoomPackage) {
|
||||
|
||||
if (!priceData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.additionalInformation}>
|
||||
@@ -28,11 +32,7 @@ export default function PetRoomMessage() {
|
||||
<span className={styles.additionalInformationPrice}>{str}</span>
|
||||
</Typography>
|
||||
),
|
||||
price: formatPrice(
|
||||
intl,
|
||||
petRoomPackage.localPrice.price,
|
||||
petRoomPackage.localPrice.currency
|
||||
),
|
||||
price: formatPrice(intl, priceData.price, priceData.currency),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -4,25 +4,29 @@ import { Controller, useFormContext } from "react-hook-form"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { usePackageLabels } from "../../usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "../../utils"
|
||||
import PetRoomMessage from "./PetRoomMessage"
|
||||
import {
|
||||
checkIsAllergyRoom,
|
||||
checkIsPetRoom,
|
||||
includesAllergyRoom,
|
||||
includesPetRoom,
|
||||
} from "./utils"
|
||||
|
||||
import styles from "./checkbox.module.css"
|
||||
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "../formValues"
|
||||
|
||||
export default function Checkboxes() {
|
||||
const packageOptions = useRatesStore((state) => state.packageOptions)
|
||||
export function PackageCheckboxes({
|
||||
availablePackages,
|
||||
}: {
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message?: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const { control } = useFormContext<FormValues>()
|
||||
const packageLabels = usePackageLabels()
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -32,7 +36,7 @@ export default function Checkboxes() {
|
||||
const petRoomSelected = includesPetRoom(field.value)
|
||||
return (
|
||||
<CheckboxGroup {...field} className={styles.checkboxGroup}>
|
||||
{packageOptions.map((option) => {
|
||||
{availablePackages?.map((option) => {
|
||||
const isAllergyRoom = checkIsAllergyRoom(option.code)
|
||||
const isPetRoom = checkIsPetRoom(option.code)
|
||||
const isDisabled =
|
||||
@@ -59,13 +63,13 @@ export default function Checkboxes() {
|
||||
className={styles.text}
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
>
|
||||
<span>{option.description}</span>
|
||||
<span>{packageLabels[option.code]}</span>
|
||||
</Typography>
|
||||
{iconName ? (
|
||||
<MaterialIcon icon={iconName} color="Icon/Default" />
|
||||
) : null}
|
||||
</Checkbox>
|
||||
{isPetRoom ? <PetRoomMessage /> : null}
|
||||
{option.message}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -75,3 +79,23 @@ export default function Checkboxes() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function includesAllergyRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||
}
|
||||
|
||||
export function includesPetRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
|
||||
}
|
||||
|
||||
export function checkIsAllergyRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.ALLERGY_ROOM {
|
||||
return code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
}
|
||||
|
||||
export function checkIsPetRoom(
|
||||
code: PackageEnum
|
||||
): code is RoomPackageCodeEnum.PET_ROOM {
|
||||
return code === RoomPackageCodeEnum.PET_ROOM
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function includesAllergyRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.ALLERGY_ROOM)
|
||||
}
|
||||
|
||||
export function includesPetRoom(codes: PackageEnum[]) {
|
||||
return codes.includes(RoomPackageCodeEnum.PET_ROOM)
|
||||
}
|
||||
|
||||
export function checkIsAllergyRoom(code: PackageEnum) {
|
||||
return code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
}
|
||||
|
||||
export function checkIsPetRoom(code: PackageEnum) {
|
||||
return code === RoomPackageCodeEnum.PET_ROOM
|
||||
}
|
||||
@@ -5,76 +5,53 @@ import { useIntl } from "react-intl"
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import Checkboxes from "./Checkboxes"
|
||||
import { PackageCheckboxes } from "./Checkboxes"
|
||||
|
||||
import styles from "./form.module.css"
|
||||
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
import type { FormValues } from "./formValues"
|
||||
|
||||
export default function Form({ close }: { close: () => void }) {
|
||||
export function RoomPackagesForm({
|
||||
close,
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
close: () => void
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
selectedPackages: PackageEnum[]
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const lang = useLang()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const {
|
||||
actions: { removeSelectedPackages, selectPackages, updateRooms },
|
||||
bookingRoom,
|
||||
selectedPackages,
|
||||
} = useRoomContext()
|
||||
const booking = useRatesStore((state) => state.booking)
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
values: {
|
||||
selectedPackages: selectedPackages.map((pkg) => pkg.code),
|
||||
selectedPackages: selectedPackages,
|
||||
},
|
||||
})
|
||||
|
||||
async function getFilteredRates(packages: PackageEnum[]) {
|
||||
const bookingCode = bookingRoom.rateCode
|
||||
? bookingRoom.bookingCode
|
||||
: booking.bookingCode
|
||||
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
|
||||
booking: {
|
||||
fromDate: booking.fromDate,
|
||||
hotelId: booking.hotelId,
|
||||
searchType: booking.searchType,
|
||||
toDate: booking.toDate,
|
||||
room: {
|
||||
...bookingRoom,
|
||||
bookingCode: bookingCode ?? undefined,
|
||||
packages,
|
||||
},
|
||||
},
|
||||
lang,
|
||||
})
|
||||
updateRooms(filterRates?.roomConfigurations)
|
||||
}
|
||||
|
||||
function clearSelectedPackages() {
|
||||
removeSelectedPackages()
|
||||
onSelectPackages([])
|
||||
close()
|
||||
getFilteredRates([])
|
||||
}
|
||||
|
||||
function onSubmit(data: FormValues) {
|
||||
selectPackages(data.selectedPackages)
|
||||
onSelectPackages(data.selectedPackages)
|
||||
close()
|
||||
getFilteredRates(data.selectedPackages)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<Checkboxes />
|
||||
<PackageCheckboxes availablePackages={availablePackages} />
|
||||
<div className={styles.footer}>
|
||||
<Divider color="Border/Divider/Subtle" className={styles.divider} />
|
||||
<div className={styles.buttonContainer}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -12,11 +12,25 @@ import { IconButton } from "@scandic-hotels/design-system/IconButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import Form from "./Form"
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
export default function RoomPackageFilterModal() {
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterModal({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
@@ -48,7 +62,12 @@ export default function RoomPackageFilterModal() {
|
||||
<MaterialIcon icon="close" size={24} color="CurrentColor" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Form close={() => setIsOpen(false)} />
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
|
||||
import Form from "./Form"
|
||||
import { RoomPackagesForm } from "./Form"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
export default function RoomPackageFilterPopover() {
|
||||
import type { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
export function RoomPackageFilterPopover({
|
||||
selectedPackages,
|
||||
onSelectPackages,
|
||||
availablePackages,
|
||||
}: {
|
||||
onSelectPackages: (packages: PackageEnum[]) => void
|
||||
selectedPackages: PackageEnum[]
|
||||
availablePackages: {
|
||||
code: RoomPackageCodeEnum
|
||||
message: ReactNode
|
||||
}[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
@@ -25,8 +39,13 @@ export default function RoomPackageFilterPopover() {
|
||||
</ChipButton>
|
||||
|
||||
<Popover placement="bottom end" className={styles.popover}>
|
||||
<Dialog className={styles.popoverDialog}>
|
||||
<Form close={() => setIsOpen(false)} />
|
||||
<Dialog>
|
||||
<RoomPackagesForm
|
||||
close={() => setIsOpen(false)}
|
||||
availablePackages={availablePackages}
|
||||
selectedPackages={selectedPackages}
|
||||
onSelectPackages={onSelectPackages}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -1,64 +1,70 @@
|
||||
"use client"
|
||||
import { Button as ButtonRAC } from "react-aria-components"
|
||||
import { useMediaQuery } from "usehooks-ts"
|
||||
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import useLang from "@/hooks/useLang"
|
||||
|
||||
import RoomPackageFilterModal from "./Modal"
|
||||
import RoomPackageFilterPopover from "./Popover"
|
||||
import PetRoomMessage from "./Form/Checkboxes/PetRoomMessage"
|
||||
import { RoomPackageFilterModal } from "./Modal"
|
||||
import { RoomPackageFilterPopover } from "./Popover"
|
||||
import { usePackageLabels } from "./usePackageLabels"
|
||||
import { getIconNameByPackageCode } from "./utils"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import type { BreakfastPackageEnum } from "@scandic-hotels/trpc/enums/breakfast"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export default function RoomPackageFilter() {
|
||||
const lang = useLang()
|
||||
const utils = trpc.useUtils()
|
||||
const displayAsPopover = useMediaQuery("(min-width: 768px)")
|
||||
export function RoomPackageFilter({ roomIndex }: { roomIndex: number }) {
|
||||
const displayAsModal = useBreakpoint("mobile")
|
||||
|
||||
const {
|
||||
actions: { removeSelectedPackage, updateRooms },
|
||||
bookingRoom,
|
||||
selectedPackages,
|
||||
} = useRoomContext()
|
||||
const { booking, packageOptions } = useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
packageOptions: state.packageOptions,
|
||||
}))
|
||||
getPackagesForRoom,
|
||||
actions: { selectPackages },
|
||||
} = useSelectRateContext()
|
||||
|
||||
async function deleteSelectedPackage(code: PackageEnum) {
|
||||
removeSelectedPackage(code)
|
||||
const bookingCode = bookingRoom.rateCode
|
||||
? bookingRoom.bookingCode
|
||||
: booking.bookingCode
|
||||
const { selectedPackages, availablePackages } = getPackagesForRoom(roomIndex)
|
||||
|
||||
const filterRates = await utils.hotel.availability.selectRate.room.fetch({
|
||||
booking: {
|
||||
fromDate: booking.fromDate,
|
||||
hotelId: booking.hotelId,
|
||||
searchType: booking.searchType,
|
||||
toDate: booking.toDate,
|
||||
room: {
|
||||
...bookingRoom,
|
||||
bookingCode: bookingCode ?? undefined,
|
||||
packages: selectedPackages
|
||||
.filter((pkg) => pkg.code !== code)
|
||||
.map((pkg) => pkg.code),
|
||||
},
|
||||
},
|
||||
lang,
|
||||
function deletePackage(code: PackageEnum) {
|
||||
selectPackages({
|
||||
roomIndex,
|
||||
packages: selectedPackages
|
||||
.filter((pkg) => pkg.code !== code)
|
||||
.map((pkg) => pkg.code),
|
||||
})
|
||||
updateRooms(filterRates?.roomConfigurations)
|
||||
}
|
||||
|
||||
const petRoomPackage = availablePackages.find(
|
||||
(x) => x.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const packageLabels = usePackageLabels()
|
||||
const packageMessages = packageMessageMap({
|
||||
petRoomPrice:
|
||||
petRoomPackage && !("type" in petRoomPackage)
|
||||
? petRoomPackage.localPrice
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const packages = availablePackages
|
||||
.map((x) => {
|
||||
if (!isRoomPackage(x)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
code: x.code,
|
||||
message: packageMessages[x.code],
|
||||
}
|
||||
})
|
||||
.filter((x) => {
|
||||
return !!x
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={styles.roomPackageFilter}>
|
||||
<div className={styles.selectedPackages}>
|
||||
@@ -73,12 +79,9 @@ export default function RoomPackageFilter() {
|
||||
size={16}
|
||||
color="CurrentColor"
|
||||
/>
|
||||
{
|
||||
packageOptions.find((pkgOption) => pkg.code === pkgOption.code)
|
||||
?.description
|
||||
}
|
||||
{packageLabels[pkg.code] ?? pkg.description}
|
||||
<ButtonRAC
|
||||
onPress={() => deleteSelectedPackage(pkg.code)}
|
||||
onPress={() => deletePackage(pkg.code)}
|
||||
className={styles.removeButton}
|
||||
>
|
||||
<MaterialIcon icon="close" size={16} color="CurrentColor" />
|
||||
@@ -87,12 +90,45 @@ export default function RoomPackageFilter() {
|
||||
</Typography>
|
||||
))}
|
||||
</div>
|
||||
<div hidden={displayAsPopover}>
|
||||
<RoomPackageFilterModal />
|
||||
</div>
|
||||
<div hidden={!displayAsPopover}>
|
||||
<RoomPackageFilterPopover />
|
||||
</div>
|
||||
{displayAsModal ? (
|
||||
<div>
|
||||
<RoomPackageFilterModal
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<RoomPackageFilterPopover
|
||||
availablePackages={packages}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
onSelectPackages={(packages) => {
|
||||
selectPackages({ roomIndex, packages })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isRoomPackage(x: {
|
||||
code: BreakfastPackageEnum | RoomPackageCodeEnum
|
||||
}): x is { code: RoomPackageCodeEnum } {
|
||||
return Object.values(RoomPackageCodeEnum).includes(
|
||||
x.code as RoomPackageCodeEnum
|
||||
)
|
||||
}
|
||||
|
||||
const packageMessageMap = ({
|
||||
petRoomPrice,
|
||||
}: {
|
||||
petRoomPrice?: { price: number; currency: string }
|
||||
}): Record<RoomPackageCodeEnum, ReactNode | undefined> => ({
|
||||
[RoomPackageCodeEnum.PET_ROOM]: <PetRoomMessage priceData={petRoomPrice} />,
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: undefined,
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: undefined,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@scandic-hotels/trpc/enums/roomFilter"
|
||||
|
||||
export const usePackageLabels = () => {
|
||||
const intl = useIntl()
|
||||
|
||||
const labels: Record<RoomPackageCodeEnum, string> = {
|
||||
[RoomPackageCodeEnum.ALLERGY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Allergy-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.PET_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Pet-friendly room",
|
||||
}),
|
||||
[RoomPackageCodeEnum.ACCESSIBILITY_ROOM]: intl.formatMessage({
|
||||
defaultMessage: "Accessible room",
|
||||
}),
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
@@ -1,24 +1,52 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary/ErrorBoundary"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import { RemoveBookingCodeButton } from "./RemoveBookingCodeButton/RemoveBookingCodeButton"
|
||||
import RoomPackageFilter from "./RoomPackageFilter"
|
||||
import { RoomPackageFilter } from "./RoomPackageFilter"
|
||||
|
||||
import styles from "./roomsHeader.module.css"
|
||||
|
||||
export default function RoomsHeader() {
|
||||
const { isFetchingPackages, rooms, totalRooms } = useRoomContext()
|
||||
const intl = useIntl()
|
||||
export function RoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
|
||||
<ErrorBoundary fallback={<div>Unable to render rooms header</div>}>
|
||||
<InnerRoomsHeader roomIndex={roomIndex} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
const availableRooms = rooms.filter(
|
||||
(room) => room.status === AvailabilityEnum.Available
|
||||
function InnerRoomsHeader({ roomIndex }: { roomIndex: number }) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<AvailableRoomCount roomIndex={roomIndex} />
|
||||
<div className={styles.filters}>
|
||||
<RemoveBookingCodeButton />
|
||||
<RoomPackageFilter roomIndex={roomIndex} />
|
||||
{/* <BookingCodeFilter roomIndex={roomIndex} /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AvailableRoomCount({ roomIndex }: { roomIndex: number }) {
|
||||
const intl = useIntl()
|
||||
const { isFetching, getAvailabilityForRoom } = useSelectRateContext()
|
||||
|
||||
const roomAvailability = getAvailabilityForRoom(roomIndex) ?? []
|
||||
|
||||
const availableRooms = roomAvailability.filter(
|
||||
(x) => x.status === AvailabilityEnum.Available
|
||||
).length
|
||||
|
||||
const totalRooms = roomAvailability.length
|
||||
|
||||
const notAllRoomsAvailableText = intl.formatMessage(
|
||||
{
|
||||
defaultMessage:
|
||||
@@ -40,23 +68,17 @@ export default function RoomsHeader() {
|
||||
}
|
||||
)
|
||||
|
||||
if (isFetching) {
|
||||
return <SkeletonShimmer height="30px" width="25ch" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
|
||||
{isFetchingPackages ? (
|
||||
<p></p>
|
||||
) : (
|
||||
<p>
|
||||
{availableRooms !== totalRooms
|
||||
? notAllRoomsAvailableText
|
||||
: allRoomsAvailableText}
|
||||
</p>
|
||||
)}
|
||||
</Typography>
|
||||
<div className={styles.filters}>
|
||||
<RemoveBookingCodeButton />
|
||||
<RoomPackageFilter />
|
||||
</div>
|
||||
</div>
|
||||
<Typography variant="Title/Subtitle/md" className={styles.availableRooms}>
|
||||
<p>
|
||||
{availableRooms !== totalRooms
|
||||
? notAllRoomsAvailableText
|
||||
: allRoomsAvailableText}
|
||||
</p>
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,21 +3,20 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import RoomSize from "./RoomSize"
|
||||
|
||||
import styles from "./details.module.css"
|
||||
|
||||
export default function Details({ roomTypeCode }: { roomTypeCode: string }) {
|
||||
import type { RoomInfo } from "@/contexts/SelectRate/types"
|
||||
|
||||
type Props = {
|
||||
roomInfo: RoomInfo
|
||||
}
|
||||
|
||||
export default function Details({ roomInfo }: Props) {
|
||||
const intl = useIntl()
|
||||
const roomCategories = useRatesStore((state) => state.roomCategories)
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
)
|
||||
|
||||
const { name, occupancy, roomSize } = selectedRoom || {}
|
||||
const { name, occupancy, roomSize } = roomInfo || {}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,30 +6,32 @@ import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/booki
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import { getBreakfastMessage } from "./getBreakfastMessage"
|
||||
|
||||
import styles from "./breakfastMessage.module.css"
|
||||
|
||||
export default function BreakfastMessage({
|
||||
export function BreakfastMessage({
|
||||
breakfastIncludedMember,
|
||||
breakfastIncludedStandard,
|
||||
hasRegularRates,
|
||||
roomIndex,
|
||||
}: {
|
||||
breakfastIncludedMember: boolean
|
||||
breakfastIncludedStandard: boolean
|
||||
hasRegularRates: boolean
|
||||
roomIndex: number
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
const { roomNr, selectedFilter } = useRoomContext()
|
||||
const { hotel } = useSelectRateContext()
|
||||
const roomNr = roomIndex + 1
|
||||
|
||||
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||
const hotelType = hotel.data?.hotel.hotelType
|
||||
|
||||
const { hotelType } = useRatesStore((state) => ({
|
||||
hotelType: state.hotelType,
|
||||
}))
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
@@ -10,33 +9,34 @@ import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@/components/HotelReservation/utils"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
import { useIsUserLoggedIn } from "@/hooks/useIsUserLoggedIn"
|
||||
|
||||
import { isSelectedPriceProduct } from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "@/contexts/SelectRate/types"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
|
||||
interface CampaignProps extends SharedRateCardProps {
|
||||
campaign: PriceProduct[]
|
||||
type CampaignProps = {
|
||||
nights: number
|
||||
campaign: AvailabilityWithRoomInfo["campaign"]
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export default function Campaign({
|
||||
campaign,
|
||||
handleSelectRate,
|
||||
roomIndex,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
selectedPackages,
|
||||
}: CampaignProps) {
|
||||
const intl = useIntl()
|
||||
const { roomNr, selectedFilter, selectedPackages, selectedRate } =
|
||||
useRoomContext()
|
||||
const rateTitles = useRateTitles()
|
||||
const { data: session } = useSession()
|
||||
const isPrimaryRoomAndLoggedIn = isValidClientSession(session) && roomNr === 1
|
||||
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||
|
||||
const isCampaignRate = campaign.some(
|
||||
(c) =>
|
||||
@@ -47,19 +47,52 @@ export default function Campaign({
|
||||
return null
|
||||
}
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||
campaign = campaign.filter((product) => product.bookingCode)
|
||||
}
|
||||
|
||||
return campaign.map((product, ix) => {
|
||||
return (
|
||||
<Inner
|
||||
key={ix}
|
||||
product={product}
|
||||
nights={nights}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function Inner({
|
||||
product,
|
||||
roomIndex,
|
||||
roomTypeCode,
|
||||
selectedPackages,
|
||||
nights,
|
||||
}: {
|
||||
roomIndex: number
|
||||
nights: number
|
||||
roomTypeCode: string
|
||||
product: AvailabilityWithRoomInfo["campaign"][number]
|
||||
selectedPackages: Package[]
|
||||
}) {
|
||||
const roomNr = roomIndex + 1
|
||||
const {
|
||||
isRateSelected,
|
||||
actions: { selectRate },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const rateTitles = useRateTitles()
|
||||
const isUserLoggedIn = useIsUserLoggedIn()
|
||||
const intl = useIntl()
|
||||
const night = intl
|
||||
.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})
|
||||
.toUpperCase()
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||
campaign = campaign.filter((product) => product.bookingCode)
|
||||
}
|
||||
|
||||
const pkgsSum = sumPackages(selectedPackages)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||
|
||||
const standardPriceMsg = intl.formatMessage({
|
||||
defaultMessage: "Standard price",
|
||||
})
|
||||
@@ -68,165 +101,184 @@ export default function Campaign({
|
||||
defaultMessage: "Member price",
|
||||
})
|
||||
|
||||
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 rateTermDetails = product.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinition.title
|
||||
: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinitionMember.title
|
||||
: memberPriceMsg,
|
||||
terms: product.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinition.title
|
||||
: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
|
||||
const isSelected = isSelectedPriceProduct(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
let bannerText = intl.formatMessage({
|
||||
defaultMessage: "Campaign",
|
||||
})
|
||||
if (product.bookingCode) {
|
||||
bannerText = product.bookingCode
|
||||
}
|
||||
|
||||
if (product.rateDefinition.breakfastIncluded) {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast included",
|
||||
})}`
|
||||
} else {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast excluded",
|
||||
})}`
|
||||
}
|
||||
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
product.public.localPrice.pricePerNight,
|
||||
product.public.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
const pricePerNightMember = product.member
|
||||
? calculatePricePerNightPriceProduct(
|
||||
product.member.localPrice.pricePerNight,
|
||||
product.member.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
|
||||
let approximateRatePrice = undefined
|
||||
if (isPrimaryRoomAndLoggedIn && pricePerNightMember) {
|
||||
approximateRatePrice = pricePerNightMember.totalRequestedPrice
|
||||
} else 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({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price: approximateRatePrice,
|
||||
unit: product.public.requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
const campaignMemberLabel =
|
||||
product.rateDefinitionMember?.title || memberPriceMsg
|
||||
if (!product.public) {
|
||||
return (
|
||||
<CampaignRateCard
|
||||
<NoRateAvailableCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
isHighlightedRate={
|
||||
!!product.rateDefinition?.displayPriceRed || isPrimaryRoomAndLoggedIn
|
||||
}
|
||||
memberRate={
|
||||
pricePerNightMember && !isPrimaryRoomAndLoggedIn
|
||||
? {
|
||||
label: memberPriceMsg,
|
||||
price: pricePerNightMember.totalPrice,
|
||||
unit: `${product.member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comparisonRate={
|
||||
isPrimaryRoomAndLoggedIn
|
||||
? {
|
||||
price: pricePerNight.totalPrice,
|
||||
unit: product.public.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
name={`rateCode-${roomNr}-${product.public.rateCode}`}
|
||||
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: isPrimaryRoomAndLoggedIn
|
||||
? campaignMemberLabel
|
||||
: standardPriceMsg,
|
||||
price:
|
||||
isPrimaryRoomAndLoggedIn && pricePerNightMember
|
||||
? pricePerNightMember.totalPrice
|
||||
: pricePerNight.totalPrice,
|
||||
unit: `${product.public.localPrice.currency}/${night}`,
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
omnibusRate={
|
||||
product.public.localPrice.omnibusPricePerNight
|
||||
? {
|
||||
label: intl
|
||||
.formatMessage({
|
||||
defaultMessage: "Lowest price (last 30 days)",
|
||||
})
|
||||
.toUpperCase(),
|
||||
price:
|
||||
product.public.localPrice.omnibusPricePerNight.toString(),
|
||||
unit: product.public.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={product.public.rateCode}
|
||||
variant="Campaign"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const rateTermDetails = product.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinition.title
|
||||
: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinitionMember.title
|
||||
: memberPriceMsg,
|
||||
terms: product.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: product.bookingCode
|
||||
? product.rateDefinition.title
|
||||
: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
const isSelected = isRateSelected({
|
||||
roomIndex,
|
||||
rate: { ...product, type: "campaign" },
|
||||
roomTypeCode,
|
||||
})
|
||||
|
||||
let bannerText = intl.formatMessage({
|
||||
defaultMessage: "Campaign",
|
||||
})
|
||||
if (product.bookingCode) {
|
||||
bannerText = product.bookingCode
|
||||
}
|
||||
|
||||
if (product.rateDefinition.breakfastIncluded) {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast included",
|
||||
})}`
|
||||
} else {
|
||||
bannerText = `${bannerText} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast excluded",
|
||||
})}`
|
||||
}
|
||||
|
||||
const pkgsSum = sumPackages(selectedPackages)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
product.public.localPrice.pricePerNight,
|
||||
product.public.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
const pricePerNightMember = product.member
|
||||
? calculatePricePerNightPriceProduct(
|
||||
product.member.localPrice.pricePerNight,
|
||||
product.member.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
|
||||
const isMainRoom = roomIndex === 0
|
||||
const isMainRoomAndLoggedIn = isMainRoom && isUserLoggedIn
|
||||
|
||||
let approximateRatePrice = undefined
|
||||
if (isMainRoomAndLoggedIn && pricePerNightMember) {
|
||||
approximateRatePrice = pricePerNightMember.totalRequestedPrice
|
||||
} else 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({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price: approximateRatePrice,
|
||||
unit: product.public.requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const rateCode = isMainRoomAndLoggedIn
|
||||
? product.member!.rateCode
|
||||
: product.public!.rateCode
|
||||
|
||||
const counterRateCode = isMainRoomAndLoggedIn
|
||||
? product.public?.rateCode
|
||||
: product.member?.rateCode
|
||||
|
||||
const campaignMemberLabel =
|
||||
product.rateDefinitionMember?.title || memberPriceMsg
|
||||
|
||||
return (
|
||||
<CampaignRateCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() =>
|
||||
selectRate({
|
||||
roomIndex,
|
||||
rateCode: rateCode,
|
||||
counterRateCode: counterRateCode,
|
||||
roomTypeCode,
|
||||
bookingCode: product.bookingCode,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected}
|
||||
isHighlightedRate={
|
||||
!!product.rateDefinition?.displayPriceRed || isMainRoomAndLoggedIn
|
||||
}
|
||||
memberRate={
|
||||
pricePerNightMember && !isMainRoomAndLoggedIn
|
||||
? {
|
||||
label: memberPriceMsg,
|
||||
price: pricePerNightMember.totalPrice,
|
||||
unit: `${product.member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
comparisonRate={
|
||||
isMainRoomAndLoggedIn
|
||||
? {
|
||||
price: pricePerNight.totalPrice,
|
||||
unit: product.public.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
name={`rateCode-${roomNr}-${product.public.rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: isMainRoomAndLoggedIn ? campaignMemberLabel : standardPriceMsg,
|
||||
price:
|
||||
isMainRoomAndLoggedIn && pricePerNightMember
|
||||
? pricePerNightMember.totalPrice
|
||||
: pricePerNight.totalPrice,
|
||||
|
||||
unit: `${product.public.localPrice.currency}/${night}`,
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
omnibusRate={
|
||||
product.public.localPrice.omnibusPricePerNight
|
||||
? {
|
||||
label: intl
|
||||
.formatMessage({
|
||||
defaultMessage: "Lowest price (last 30 days)",
|
||||
})
|
||||
.toUpperCase(),
|
||||
price: product.public.localPrice.omnibusPricePerNight.toString(),
|
||||
unit: product.public.localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={product.public.rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,230 +4,387 @@ import { useIntl } from "react-intl"
|
||||
|
||||
import CodeRateCard from "@scandic-hotels/design-system/CodeRateCard"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@/components/HotelReservation/utils"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import {
|
||||
isSelectedCorporateCheque,
|
||||
isSelectedPriceProduct,
|
||||
isSelectedVoucher,
|
||||
} from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { CodeProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import type { Package } from "@/contexts/SelectRate/types"
|
||||
|
||||
interface CodeProps extends SharedRateCardProps {
|
||||
type CodeProps = {
|
||||
nights: number
|
||||
roomTypeCode: string
|
||||
code: CodeProduct[]
|
||||
roomIndex: number
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export default function Code({
|
||||
code,
|
||||
handleSelectRate,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: CodeProps) {
|
||||
return code.map((product) => {
|
||||
return (
|
||||
<InnerCode
|
||||
key={product.rate}
|
||||
codeProduct={product}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
nights={nights}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function InnerCode({
|
||||
codeProduct,
|
||||
roomIndex,
|
||||
roomTypeCode,
|
||||
nights,
|
||||
selectedPackages,
|
||||
}: {
|
||||
codeProduct: CodeProduct
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
nights: number
|
||||
selectedPackages: Package[]
|
||||
}) {
|
||||
const {
|
||||
input: { bookingCode },
|
||||
actions: { selectRate },
|
||||
isRateSelected,
|
||||
} = useSelectRateContext()
|
||||
|
||||
function handleSelectRate(rateCode: string) {
|
||||
selectRate({ roomIndex, rateCode, roomTypeCode })
|
||||
}
|
||||
|
||||
const bannerText = useBannerText({
|
||||
bookingCode: bookingCode ?? "",
|
||||
breakfastIncluded: codeProduct.rateDefinition.breakfastIncluded,
|
||||
})
|
||||
|
||||
const pkgsSum = sumPackages(selectedPackages)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||
|
||||
const isSelected = isRateSelected({
|
||||
roomIndex,
|
||||
roomTypeCode,
|
||||
rate: { ...codeProduct, type: "code" },
|
||||
})
|
||||
|
||||
if ("corporateCheque" in codeProduct) {
|
||||
return (
|
||||
<CorporateChequeCode
|
||||
codeProduct={codeProduct}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
bannerText={bannerText}
|
||||
packagesSum={pkgsSum}
|
||||
handleSelectRate={handleSelectRate}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if ("voucher" in codeProduct) {
|
||||
return (
|
||||
<VoucherCode
|
||||
codeProduct={codeProduct}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
bannerText={bannerText}
|
||||
packagesSum={pkgsSum}
|
||||
handleSelectRate={handleSelectRate}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (codeProduct.public) {
|
||||
return (
|
||||
<PublicCode
|
||||
codeProduct={codeProduct}
|
||||
roomIndex={roomIndex}
|
||||
roomTypeCode={roomTypeCode}
|
||||
bannerText={bannerText}
|
||||
packagesSum={pkgsSum}
|
||||
packagesSumRequested={pkgsSumRequested}
|
||||
nights={nights}
|
||||
handleSelectRate={handleSelectRate}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function useBannerText({
|
||||
bookingCode,
|
||||
breakfastIncluded,
|
||||
}: {
|
||||
breakfastIncluded: boolean
|
||||
bookingCode: string
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
if (breakfastIncluded) {
|
||||
return `${bookingCode} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast included",
|
||||
})}`
|
||||
} else {
|
||||
return `${bookingCode} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast excluded",
|
||||
})}`
|
||||
}
|
||||
}
|
||||
|
||||
function CorporateChequeCode({
|
||||
codeProduct,
|
||||
roomIndex,
|
||||
bannerText,
|
||||
packagesSum,
|
||||
handleSelectRate,
|
||||
isSelected,
|
||||
}: {
|
||||
codeProduct: Extract<CodeProduct, { corporateCheque: any }>
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
bannerText: string
|
||||
packagesSum: ReturnType<typeof sumPackages>
|
||||
handleSelectRate: (rateCode: string) => void
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const roomNr = roomIndex + 1
|
||||
const intl = useIntl()
|
||||
const { roomNr, selectedPackages, selectedRate } = useRoomContext()
|
||||
const bookingCode = useRatesStore((state) => state.booking.bookingCode)
|
||||
const rateTitles = useRateTitles()
|
||||
const { localPrice, rateCode, requestedPrice } = codeProduct.corporateCheque
|
||||
|
||||
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||
|
||||
let price = `${localPrice.numberOfCheques} CC`
|
||||
|
||||
if (localPrice.additionalPricePerStay) {
|
||||
price = `${price} + ${localPrice.additionalPricePerStay + packagesSum.price}`
|
||||
} else if (packagesSum.price) {
|
||||
price = `${price} + ${packagesSum.price}`
|
||||
}
|
||||
|
||||
const currency =
|
||||
localPrice.additionalPricePerStay > 0 || packagesSum.price > 0
|
||||
? (localPrice.currency ?? packagesSum.currency ?? "")
|
||||
: ""
|
||||
|
||||
const approximateRate =
|
||||
requestedPrice?.additionalPricePerStay && requestedPrice?.currency
|
||||
? {
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price:
|
||||
`${requestedPrice.numberOfCheques} CC + ` +
|
||||
requestedPrice.additionalPricePerStay,
|
||||
unit: requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={codeProduct.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() =>
|
||||
handleSelectRate(codeProduct.corporateCheque.rateCode)
|
||||
}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||
rate={{
|
||||
label: codeProduct.rateDefinition?.title,
|
||||
price,
|
||||
unit: currency,
|
||||
}}
|
||||
rateTitle={rateTitles[codeProduct.rate].title}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PublicCode({
|
||||
codeProduct,
|
||||
roomIndex,
|
||||
bannerText,
|
||||
packagesSum,
|
||||
packagesSumRequested,
|
||||
nights,
|
||||
handleSelectRate,
|
||||
isSelected,
|
||||
}: {
|
||||
codeProduct: Extract<CodeProduct, { public: unknown }>
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
bannerText: string
|
||||
packagesSum: ReturnType<typeof sumPackages>
|
||||
packagesSumRequested: ReturnType<typeof sumPackagesRequestedPrice>
|
||||
nights: number
|
||||
handleSelectRate: (rateCode: string) => void
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const roomNr = roomIndex + 1
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
if (!codeProduct.public) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||
|
||||
const night = intl
|
||||
.formatMessage({
|
||||
defaultMessage: "night",
|
||||
})
|
||||
.toUpperCase()
|
||||
|
||||
return code.map((product) => {
|
||||
let bannerText = ""
|
||||
if (product.rateDefinition.breakfastIncluded) {
|
||||
bannerText = `${bookingCode} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast included",
|
||||
})}`
|
||||
} else {
|
||||
bannerText = `${bookingCode} ∙ ${intl.formatMessage({
|
||||
defaultMessage: "Breakfast excluded",
|
||||
})}`
|
||||
}
|
||||
const rateTermDetails = product.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: product.rateDefinition.title,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: product.rateDefinitionMember.title,
|
||||
terms: product.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: product.rateDefinition.title,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
const { localPrice, rateCode, requestedPrice } = codeProduct.public
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.pricePerNight,
|
||||
requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
packagesSum.price,
|
||||
packagesSumRequested.price
|
||||
)
|
||||
|
||||
const pkgsSum = sumPackages(selectedPackages)
|
||||
const pkgsSumRequested = sumPackagesRequestedPrice(selectedPackages)
|
||||
const approximateRate =
|
||||
pricePerNight.totalRequestedPrice && requestedPrice?.currency
|
||||
? {
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price: pricePerNight.totalRequestedPrice,
|
||||
unit: requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
if ("corporateCheque" in product) {
|
||||
const { localPrice, rateCode, requestedPrice } = product.corporateCheque
|
||||
let price = `${localPrice.numberOfCheques} CC`
|
||||
if (localPrice.additionalPricePerStay) {
|
||||
price = `${price} + ${localPrice.additionalPricePerStay + pkgsSum.price}`
|
||||
} else if (pkgsSum.price) {
|
||||
price = `${price} + ${pkgsSum.price}`
|
||||
}
|
||||
const regularPricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.regularPricePerNight,
|
||||
requestedPrice?.regularPricePerNight,
|
||||
nights,
|
||||
packagesSum.price,
|
||||
packagesSumRequested.price
|
||||
)
|
||||
|
||||
const isSelected = isSelectedCorporateCheque(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
const comparisonRate =
|
||||
+regularPricePerNight.totalPrice > +pricePerNight.totalPrice
|
||||
? {
|
||||
price: regularPricePerNight.totalPrice,
|
||||
unit: localPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const currency =
|
||||
localPrice.additionalPricePerStay > 0 || pkgsSum.price > 0
|
||||
? (localPrice.currency ?? pkgsSum.currency ?? "")
|
||||
: ""
|
||||
|
||||
const approximateRate =
|
||||
requestedPrice?.additionalPricePerStay && requestedPrice?.currency
|
||||
? {
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price:
|
||||
`${requestedPrice.numberOfCheques} CC + ` +
|
||||
requestedPrice.additionalPricePerStay,
|
||||
unit: requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rate={{
|
||||
label: product.rateDefinition?.title,
|
||||
price,
|
||||
unit: currency,
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if ("voucher" in product) {
|
||||
const { numberOfVouchers, rateCode } = product.voucher
|
||||
const isSelected = isSelectedVoucher(product, selectedRate, roomTypeCode)
|
||||
|
||||
const voucherMsg = intl
|
||||
.formatMessage({
|
||||
defaultMessage: "Voucher",
|
||||
})
|
||||
.toUpperCase()
|
||||
let price = `${numberOfVouchers} ${voucherMsg}`
|
||||
if (pkgsSum.price) {
|
||||
price = `${price} + ${pkgsSum.price}`
|
||||
}
|
||||
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: pkgsSum.currency ?? "",
|
||||
}}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (product.public) {
|
||||
const { localPrice, rateCode, requestedPrice } = product.public
|
||||
const pricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.pricePerNight,
|
||||
requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
const approximateRate =
|
||||
pricePerNight.totalRequestedPrice && requestedPrice?.currency
|
||||
? {
|
||||
label: intl.formatMessage({
|
||||
defaultMessage: "Approx.",
|
||||
}),
|
||||
price: pricePerNight.totalRequestedPrice,
|
||||
unit: requestedPrice.currency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const regularPricePerNight = calculatePricePerNightPriceProduct(
|
||||
localPrice.regularPricePerNight,
|
||||
requestedPrice?.regularPricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
|
||||
const comparisonRate =
|
||||
+regularPricePerNight.totalPrice > +pricePerNight.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}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={codeProduct.rate}
|
||||
approximateRate={approximateRate}
|
||||
bannerText={bannerText}
|
||||
comparisonRate={comparisonRate}
|
||||
handleChange={() => handleSelectRate(codeProduct.public!.rateCode)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||
rate={{
|
||||
label: codeProduct.rateDefinition?.title,
|
||||
price: pricePerNight.totalPrice,
|
||||
unit: `${localPrice.currency}/${night}`,
|
||||
}}
|
||||
rateTitle={rateTitles[codeProduct.rate].title}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function VoucherCode({
|
||||
codeProduct,
|
||||
bannerText,
|
||||
packagesSum,
|
||||
roomIndex,
|
||||
handleSelectRate,
|
||||
isSelected,
|
||||
}: {
|
||||
codeProduct: Extract<CodeProduct, { voucher: any }>
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
bannerText: string
|
||||
packagesSum: ReturnType<typeof sumPackages>
|
||||
handleSelectRate: (rateCode: string) => void
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const roomNr = roomIndex + 1
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const { numberOfVouchers, rateCode } = codeProduct.voucher
|
||||
|
||||
const rateTermDetails = getRateTermDetails(codeProduct)
|
||||
|
||||
const voucherMsg = intl
|
||||
.formatMessage({
|
||||
defaultMessage: "Voucher",
|
||||
})
|
||||
.toUpperCase()
|
||||
let price = `${numberOfVouchers} ${voucherMsg}`
|
||||
if (packagesSum.price) {
|
||||
price = `${price} + ${packagesSum.price}`
|
||||
}
|
||||
return (
|
||||
<CodeRateCard
|
||||
key={codeProduct.rate}
|
||||
bannerText={bannerText}
|
||||
handleChange={() => handleSelectRate(codeProduct.voucher.rateCode)}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
paymentTerm={rateTitles[codeProduct.rate].paymentTerm}
|
||||
rate={{
|
||||
label: codeProduct.rateDefinition?.title,
|
||||
price,
|
||||
unit: packagesSum.currency ?? "",
|
||||
}}
|
||||
rateTitle={rateTitles[codeProduct.rate].title}
|
||||
rateTermDetails={rateTermDetails}
|
||||
value={rateCode}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function getRateTermDetails(codeProduct: CodeProduct): RateTermDetails {
|
||||
return codeProduct.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: codeProduct.rateDefinition.title,
|
||||
terms: codeProduct.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: codeProduct.rateDefinitionMember.title,
|
||||
terms: codeProduct.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: codeProduct.rateDefinition.title,
|
||||
terms: codeProduct.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
type RateTermDetails = { title: string; terms: string[] }[]
|
||||
|
||||
@@ -5,25 +5,37 @@ import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/booki
|
||||
import PointsRateCard from "@scandic-hotels/design-system/PointsRateCard"
|
||||
|
||||
import { sumPackages } from "@/components/HotelReservation/utils"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
|
||||
import type { RedemptionProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type {
|
||||
AvailabilityWithRoomInfo,
|
||||
Package,
|
||||
} from "@/contexts/SelectRate/types"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
|
||||
interface RedemptionsProps extends SharedRateCardProps {
|
||||
redemptions: RedemptionProduct[]
|
||||
type RedemptionsProps = {
|
||||
redemptions: AvailabilityWithRoomInfo["redemptions"]
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
roomIndex: number
|
||||
}
|
||||
|
||||
export default function Redemptions({
|
||||
handleSelectRate,
|
||||
redemptions,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: RedemptionsProps) {
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const { selectedFilter, selectedPackages, selectedRate } = useRoomContext()
|
||||
const {
|
||||
actions: { selectRate },
|
||||
selectedRates,
|
||||
} = useSelectRateContext()
|
||||
|
||||
// TODO: Replace with context value when we have support for dropdown "Show all rates"
|
||||
const selectedFilter = BookingCodeFilterEnum.All as BookingCodeFilterEnum
|
||||
const selectedRate = selectedRates.forRoom(roomIndex)
|
||||
|
||||
if (
|
||||
selectedFilter === BookingCodeFilterEnum.Discounted ||
|
||||
@@ -44,22 +56,12 @@ export default function Redemptions({
|
||||
defaultMessage: "Breakfast excluded",
|
||||
})
|
||||
|
||||
let selectedRateCode = ""
|
||||
if (selectedRate?.product && "redemption" in selectedRate.product) {
|
||||
selectedRateCode =
|
||||
selectedRate.roomTypeCode === roomTypeCode
|
||||
? selectedRate.product.redemption.rateCode
|
||||
: ""
|
||||
}
|
||||
|
||||
function handleSelect(rateCode: string) {
|
||||
const selectedRedemption = redemptions.find(
|
||||
(r) => r.redemption.rateCode === rateCode
|
||||
)
|
||||
if (selectedRedemption) {
|
||||
handleSelectRate(selectedRedemption)
|
||||
}
|
||||
}
|
||||
const selectedRateCode =
|
||||
selectedRate &&
|
||||
"redemption" in selectedRate &&
|
||||
selectedRate.roomInfo.roomTypeCode === roomTypeCode
|
||||
? selectedRate.redemption.rateCode
|
||||
: ""
|
||||
|
||||
const rates = redemptions.map((r) => {
|
||||
let additionalPrice
|
||||
@@ -107,7 +109,13 @@ export default function Redemptions({
|
||||
<PointsRateCard
|
||||
key={firstRedemption.rate}
|
||||
bannerText={bannerText}
|
||||
onRateSelect={handleSelect}
|
||||
onRateSelect={(rateCode: string) => {
|
||||
selectRate({
|
||||
roomIndex: roomIndex,
|
||||
rateCode: rateCode,
|
||||
roomTypeCode: roomTypeCode,
|
||||
})
|
||||
}}
|
||||
paymentTerm={rateTitles[firstRedemption.rate].paymentTerm}
|
||||
rates={rates}
|
||||
rateTitle={rateTitles[firstRedemption.rate].title}
|
||||
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
sumPackages,
|
||||
sumPackagesRequestedPrice,
|
||||
} from "@/components/HotelReservation/utils"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
import useRateTitles from "@/hooks/booking/useRateTitles"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import { isSelectedPriceProduct } from "./isSelected"
|
||||
import { calculatePricePerNightPriceProduct } from "./totalPricePerNight"
|
||||
|
||||
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { SharedRateCardProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||
|
||||
interface Rate {
|
||||
label: string
|
||||
@@ -31,25 +30,64 @@ interface Rates {
|
||||
memberRate?: Rate
|
||||
rate?: Rate
|
||||
}
|
||||
|
||||
interface RegularProps extends SharedRateCardProps {
|
||||
regular: PriceProduct[]
|
||||
type RegularRateProps = {
|
||||
nights: number
|
||||
regular: AvailabilityWithRoomInfo["regular"]
|
||||
roomIndex: number
|
||||
roomTypeCode: string
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
|
||||
export default function Regular({
|
||||
handleSelectRate,
|
||||
export function RegularRate({
|
||||
nights,
|
||||
regular,
|
||||
roomTypeCode,
|
||||
}: RegularProps) {
|
||||
const intl = useIntl()
|
||||
const rateTitles = useRateTitles()
|
||||
const { isMainRoom, roomNr, selectedFilter, selectedPackages, selectedRate } =
|
||||
useRoomContext()
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: RegularRateProps) {
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
|
||||
if (selectedFilter === BookingCodeFilterEnum.Discounted) {
|
||||
return regular.map((product, ix) => (
|
||||
<Inner
|
||||
key={ix}
|
||||
product={product}
|
||||
isUserLoggedIn={isUserLoggedIn}
|
||||
nights={nights}
|
||||
roomTypeCode={roomTypeCode}
|
||||
roomIndex={roomIndex}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function Inner({
|
||||
product,
|
||||
isUserLoggedIn,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}: {
|
||||
product: AvailabilityWithRoomInfo["regular"][number]
|
||||
isUserLoggedIn: boolean
|
||||
nights: number
|
||||
roomTypeCode: string
|
||||
roomIndex: number
|
||||
selectedPackages: Package[]
|
||||
}) {
|
||||
const intl = useIntl()
|
||||
|
||||
const rateTitles = useRateTitles()
|
||||
const {
|
||||
isRateSelected,
|
||||
bookingCodeFilter,
|
||||
actions: { selectRate },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const isMainRoom = roomIndex === 0
|
||||
|
||||
if (bookingCodeFilter === BookingCodeFilterEnum.Discounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -73,139 +111,147 @@ export default function Regular({
|
||||
defaultMessage: "Approx.",
|
||||
})
|
||||
|
||||
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 isMemberRateActive = isMainRoomAndLoggedIn && !!member
|
||||
const isNotLoggedInAndOnlyMemberRate = !isUserLoggedIn && !standard
|
||||
const rateCode = isMemberRateActive ? 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,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
const standardPricePerNight = standard
|
||||
? calculatePricePerNightPriceProduct(
|
||||
standard.localPrice.pricePerNight,
|
||||
standard.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
|
||||
let approximateMemberRatePrice = null
|
||||
const rates: Rates = {}
|
||||
if (memberPricePerNight) {
|
||||
rates.memberRate = {
|
||||
label: memberPriceMsg,
|
||||
price: memberPricePerNight.totalPrice,
|
||||
unit: `${member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
|
||||
if (memberPricePerNight.totalRequestedPrice) {
|
||||
approximateMemberRatePrice = memberPricePerNight.totalRequestedPrice
|
||||
}
|
||||
}
|
||||
|
||||
let approximateStandardRatePrice = null
|
||||
if (standardPricePerNight) {
|
||||
const standardPriceUnit = isMemberRateActive
|
||||
? standard!.localPrice.currency
|
||||
: `${standard!.localPrice.currency}/${night}`
|
||||
rates.rate = {
|
||||
label: standardPriceMsg,
|
||||
price: standardPricePerNight.totalPrice,
|
||||
unit: standardPriceUnit,
|
||||
}
|
||||
|
||||
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
|
||||
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: approxMsg,
|
||||
price: approximatePrice,
|
||||
unit: requestedCurrency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const isSelected = isSelectedPriceProduct(
|
||||
product,
|
||||
selectedRate,
|
||||
roomTypeCode
|
||||
)
|
||||
|
||||
const rateTermDetails = product.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: memberPriceMsg,
|
||||
terms: product.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
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
|
||||
const counterRateCode = isMainRoomAndLoggedIn
|
||||
? standard?.rateCode
|
||||
: member?.rateCode
|
||||
|
||||
if (
|
||||
noRateAvailable ||
|
||||
isMainRoomLoggedInWithoutMember ||
|
||||
!rateCode ||
|
||||
isNotLoggedInAndOnlyMemberRate
|
||||
) {
|
||||
return (
|
||||
<RegularRateCard
|
||||
{...rates}
|
||||
<NoRateAvailableCard
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
handleChange={() => handleSelectRate(product)}
|
||||
isMemberRateActive={isMemberRateActive}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomNr}-${rateCode}`}
|
||||
noPricesAvailableText={rateTitles.noPriceAvailable}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
rateTermDetails={rateTermDetails}
|
||||
variant="Regular"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const memberPricePerNight = member
|
||||
? calculatePricePerNightPriceProduct(
|
||||
member.localPrice.pricePerNight,
|
||||
member.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
const standardPricePerNight = standard
|
||||
? calculatePricePerNightPriceProduct(
|
||||
standard.localPrice.pricePerNight,
|
||||
standard.requestedPrice?.pricePerNight,
|
||||
nights,
|
||||
pkgsSum.price,
|
||||
pkgsSumRequested.price
|
||||
)
|
||||
: undefined
|
||||
|
||||
let approximateMemberRatePrice = null
|
||||
const rates: Rates = {}
|
||||
if (memberPricePerNight) {
|
||||
rates.memberRate = {
|
||||
label: memberPriceMsg,
|
||||
price: memberPricePerNight.totalPrice,
|
||||
unit: `${member!.localPrice.currency}/${night}`,
|
||||
}
|
||||
|
||||
if (memberPricePerNight.totalRequestedPrice) {
|
||||
approximateMemberRatePrice = memberPricePerNight.totalRequestedPrice
|
||||
}
|
||||
}
|
||||
|
||||
let approximateStandardRatePrice = null
|
||||
if (standardPricePerNight) {
|
||||
rates.rate = {
|
||||
label: standardPriceMsg,
|
||||
price: standardPricePerNight.totalPrice,
|
||||
unit: `${standard!.localPrice.currency}/${night}`,
|
||||
}
|
||||
|
||||
if (standardPricePerNight.totalRequestedPrice && !isUserLoggedIn) {
|
||||
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: approxMsg,
|
||||
price: approximatePrice,
|
||||
unit: requestedCurrency,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const rateTermDetails = product.rateDefinitionMember
|
||||
? [
|
||||
{
|
||||
title: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
{
|
||||
title: memberPriceMsg,
|
||||
terms: product.rateDefinitionMember.generalTerms,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: standardPriceMsg,
|
||||
terms: product.rateDefinition.generalTerms,
|
||||
},
|
||||
]
|
||||
|
||||
const isSelected = isRateSelected({
|
||||
roomIndex,
|
||||
rate: { ...product, type: "regular" },
|
||||
roomTypeCode,
|
||||
})
|
||||
|
||||
const isMemberRateActive = isUserLoggedIn && isMainRoom && !!member
|
||||
|
||||
return (
|
||||
<RegularRateCard
|
||||
{...rates}
|
||||
key={product.rate}
|
||||
approximateRate={approximateRate}
|
||||
handleChange={() => {
|
||||
selectRate({
|
||||
roomIndex: roomIndex,
|
||||
rateCode: rateCode,
|
||||
roomTypeCode: roomTypeCode,
|
||||
counterRateCode: counterRateCode,
|
||||
})
|
||||
}}
|
||||
isMemberRateActive={isMemberRateActive}
|
||||
isSelected={isSelected}
|
||||
name={`rateCode-${roomIndex + 1}-${rateCode}`}
|
||||
paymentTerm={rateTitles[product.rate].paymentTerm}
|
||||
rateTitle={rateTitles[product.rate].title}
|
||||
value={rateCode}
|
||||
rateTermDetails={rateTermDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,61 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useSession } from "next-auth/react"
|
||||
|
||||
import { BookingCodeFilterEnum } from "@scandic-hotels/booking-flow/stores/bookingCode-filter"
|
||||
import { dt } from "@scandic-hotels/common/dt"
|
||||
import { Divider } from "@scandic-hotels/design-system/Divider"
|
||||
import SkeletonShimmer from "@scandic-hotels/design-system/SkeletonShimmer"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import BreakfastMessage from "./BreakfastMessage"
|
||||
import { BreakfastMessage } from "./BreakfastMessage"
|
||||
import Campaign from "./Campaign"
|
||||
import Code from "./Code"
|
||||
import Redemptions from "./Redemptions"
|
||||
import Regular from "./Regular"
|
||||
import { RegularRate } from "./Regular"
|
||||
|
||||
import type { Product } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { RatesProps } from "@/types/components/hotelReservation/selectRate/rates"
|
||||
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||
|
||||
export default function Rates({
|
||||
export interface RatesProps {
|
||||
roomConfiguration: AvailabilityWithRoomInfo
|
||||
roomIndex: number
|
||||
selectedPackages: Package[]
|
||||
}
|
||||
export function Rates({
|
||||
roomConfiguration: {
|
||||
breakfastIncludedInAllRates,
|
||||
breakfastIncludedInAllRatesMember,
|
||||
campaign,
|
||||
code,
|
||||
features,
|
||||
redemptions,
|
||||
regular,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
},
|
||||
selectedPackages,
|
||||
roomIndex,
|
||||
}: RatesProps) {
|
||||
const {
|
||||
actions: { selectRate },
|
||||
isFetchingAdditionalRate,
|
||||
selectedFilter,
|
||||
} = useRoomContext()
|
||||
const nights = useRatesStore((state) =>
|
||||
dt(state.booking.toDate).diff(state.booking.fromDate, "days")
|
||||
)
|
||||
const { data: session } = useSession()
|
||||
const isUserLoggedIn = isValidClientSession(session)
|
||||
function handleSelectRate(product: Product) {
|
||||
selectRate({ features, product, roomType, roomTypeCode }, isUserLoggedIn)
|
||||
}
|
||||
bookingCodeFilter,
|
||||
input: { nights },
|
||||
} = useSelectRateContext()
|
||||
|
||||
const sharedProps = {
|
||||
handleSelectRate,
|
||||
nights,
|
||||
roomTypeCode,
|
||||
roomIndex,
|
||||
selectedPackages,
|
||||
}
|
||||
|
||||
const showAllRates = selectedFilter === BookingCodeFilterEnum.All
|
||||
const showAllRates = bookingCodeFilter === BookingCodeFilterEnum.All
|
||||
const hasBookingCodeRates = !!(campaign.length || code.length)
|
||||
const hasRegularRates = !!regular.length
|
||||
const showDivider = showAllRates && hasBookingCodeRates && hasRegularRates
|
||||
@@ -66,18 +55,13 @@ export default function Rates({
|
||||
<Campaign {...sharedProps} campaign={campaign} />
|
||||
<Redemptions {...sharedProps} redemptions={redemptions} />
|
||||
{showDivider ? <Divider color="Border/Divider/Subtle" /> : null}
|
||||
{isFetchingAdditionalRate ? (
|
||||
<>
|
||||
<SkeletonShimmer height="100px" />
|
||||
<SkeletonShimmer height="100px" />
|
||||
</>
|
||||
) : null}
|
||||
<BreakfastMessage
|
||||
breakfastIncludedMember={breakfastIncludedInAllRatesMember}
|
||||
breakfastIncludedStandard={breakfastIncludedInAllRates}
|
||||
hasRegularRates={!!regular.length}
|
||||
hasRegularRates={hasRegularRates && showAllRates}
|
||||
roomIndex={roomIndex}
|
||||
/>
|
||||
<Regular {...sharedProps} regular={regular} />
|
||||
<RegularRate {...sharedProps} regular={regular} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,68 +1,50 @@
|
||||
"use client"
|
||||
import { memo } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import ImageGallery from "@scandic-hotels/design-system/ImageGallery"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import { IconForFeatureCode } from "@/components/HotelReservation/utils"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import ToggleSidePeek from "../Details/ToggleSidePeek"
|
||||
|
||||
import styles from "./image.module.css"
|
||||
|
||||
import type { RoomListItemImageProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||
import type { ApiImage } from "@scandic-hotels/trpc/types/hotel"
|
||||
import type { PackageEnum } from "@scandic-hotels/trpc/types/packages"
|
||||
import type { RoomConfiguration } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
export default function RoomImage({
|
||||
roomPackages,
|
||||
export type RoomListItemImageProps = Pick<
|
||||
RoomConfiguration,
|
||||
"roomType" | "roomTypeCode" | "roomsLeft"
|
||||
> & {
|
||||
selectedPackages: PackageEnum[]
|
||||
images: ApiImage[]
|
||||
hotelId: string
|
||||
}
|
||||
|
||||
const RoomImage = memo(function RoomImage({
|
||||
roomsLeft,
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
selectedPackages,
|
||||
images,
|
||||
hotelId,
|
||||
}: RoomListItemImageProps) {
|
||||
const intl = useIntl()
|
||||
const { selectedPackages } = useRoomContext()
|
||||
const { roomCategories, hotelId } = useRatesStore((state) => ({
|
||||
roomCategories: state.roomCategories,
|
||||
hotelId: state.booking.hotelId,
|
||||
}))
|
||||
|
||||
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
|
||||
|
||||
const selectedRoom = roomCategories.find((roomCategory) =>
|
||||
roomCategory.roomTypes.find((roomType) => roomType.code === roomTypeCode)
|
||||
)
|
||||
|
||||
const galleryImages = mapApiImagesToGalleryImages(selectedRoom?.images || [])
|
||||
const galleryImages = mapApiImagesToGalleryImages(images || [])
|
||||
|
||||
return (
|
||||
<div className={styles.imageContainer}>
|
||||
<div className={styles.chipContainer}>
|
||||
{showLowInventory ? (
|
||||
<span className={styles.chip}>
|
||||
<Typography variant="Tag/sm">
|
||||
<p className={styles.inventory}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{amount, number} left",
|
||||
},
|
||||
{ amount: roomsLeft }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
<LowInventoryTag roomsLeft={roomsLeft} />
|
||||
|
||||
{selectedPackages.map((pkg) => (
|
||||
<span className={styles.chip} key={pkg}>
|
||||
{IconForFeatureCode({ featureCode: pkg, size: 16 })}
|
||||
</span>
|
||||
) : null}
|
||||
{roomPackages
|
||||
.filter((pkg) =>
|
||||
selectedPackages.find((spkg) => spkg.code === pkg.code)
|
||||
)
|
||||
.map((pkg) => (
|
||||
<span className={styles.chip} key={pkg.code}>
|
||||
{IconForFeatureCode({ featureCode: pkg.code, size: 16 })}
|
||||
</span>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
<ImageGallery
|
||||
images={galleryImages}
|
||||
@@ -77,4 +59,30 @@ export default function RoomImage({
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default RoomImage
|
||||
|
||||
function LowInventoryTag({ roomsLeft }: { roomsLeft: number }) {
|
||||
const intl = useIntl()
|
||||
const showLowInventory = roomsLeft > 0 && roomsLeft < 5
|
||||
|
||||
if (!showLowInventory) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={styles.chip}>
|
||||
<Typography variant="Tag/sm">
|
||||
<p className={styles.inventory}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
defaultMessage: "{amount, number} left",
|
||||
},
|
||||
{ amount: roomsLeft }
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { AvailabilityEnum } from "@scandic-hotels/trpc/enums/selectHotel"
|
||||
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import Details from "./Details"
|
||||
import { listItemVariants } from "./listItemVariants"
|
||||
import Rates from "./Rates"
|
||||
import { Rates } from "./Rates"
|
||||
import RoomImage from "./RoomImage"
|
||||
import RoomNotAvailable from "./RoomNotAvailable"
|
||||
|
||||
import styles from "./roomListItem.module.css"
|
||||
|
||||
import type { RoomListItemProps } from "@/types/components/hotelReservation/selectRate/roomListItem"
|
||||
import type { Package } from "@scandic-hotels/trpc/types/packages"
|
||||
|
||||
import type { AvailabilityWithRoomInfo } from "@/contexts/SelectRate/types"
|
||||
|
||||
export type RoomListItemProps = {
|
||||
room: AvailabilityWithRoomInfo
|
||||
selectedPackages: Package[]
|
||||
roomIndex: number
|
||||
hotelId: string
|
||||
}
|
||||
|
||||
export function RoomListItem({
|
||||
room,
|
||||
selectedPackages,
|
||||
roomIndex,
|
||||
hotelId,
|
||||
}: RoomListItemProps) {
|
||||
if (!room || !room.roomInfo) {
|
||||
return null
|
||||
}
|
||||
|
||||
export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
|
||||
const { roomPackages } = useRoomContext()
|
||||
const classNames = listItemVariants({
|
||||
availability:
|
||||
roomConfiguration.status === AvailabilityEnum.NotAvailable
|
||||
room.status === AvailabilityEnum.NotAvailable
|
||||
? "noAvailability"
|
||||
: "default",
|
||||
})
|
||||
@@ -26,18 +39,24 @@ export default function RoomListItem({ roomConfiguration }: RoomListItemProps) {
|
||||
return (
|
||||
<li className={classNames}>
|
||||
<RoomImage
|
||||
roomPackages={roomPackages}
|
||||
roomType={roomConfiguration.roomType}
|
||||
roomTypeCode={roomConfiguration.roomTypeCode}
|
||||
roomsLeft={roomConfiguration.roomsLeft}
|
||||
roomType={room.roomType}
|
||||
roomTypeCode={room.roomTypeCode}
|
||||
roomsLeft={room.roomsLeft}
|
||||
selectedPackages={selectedPackages.map((pkg) => pkg.code)}
|
||||
images={room.roomInfo.images ?? []}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
<Details roomTypeCode={roomConfiguration.roomTypeCode} />
|
||||
<Details roomInfo={room.roomInfo} />
|
||||
|
||||
<div className={styles.container}>
|
||||
{roomConfiguration.status === AvailabilityEnum.NotAvailable ? (
|
||||
{room.status === AvailabilityEnum.NotAvailable ? (
|
||||
<RoomNotAvailable />
|
||||
) : (
|
||||
<Rates roomConfiguration={roomConfiguration} />
|
||||
<Rates
|
||||
roomConfiguration={room}
|
||||
roomIndex={roomIndex}
|
||||
selectedPackages={selectedPackages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -1,43 +1,52 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function ScrollToList() {
|
||||
const { isSingleRoomAndHasSelection } = useRatesStore((state) => ({
|
||||
isSingleRoomAndHasSelection:
|
||||
state.booking.rooms.length === 1 && !!state.rateSummary.length,
|
||||
}))
|
||||
const {
|
||||
input: { isMultiRoom },
|
||||
selectedRates,
|
||||
} = useSelectRateContext()
|
||||
const selectedRateCode = selectedRates.rates[0]
|
||||
? `${selectedRates.rates[0].rateDefinition.rateCode}${selectedRates.rates[0].roomInfo.roomTypeCode}`
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (isSingleRoomAndHasSelection) {
|
||||
// Required to prevent the history.pushState on the first selection
|
||||
// to scroll user back to top
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 173 // summary on mobile is 163px
|
||||
|
||||
const selectedRateCard: HTMLElement | null = document.querySelector(
|
||||
`.${styles.roomList} label:has(input[type=radio]:checked)`
|
||||
)
|
||||
|
||||
if (selectedRateCard) {
|
||||
const elementPosition = selectedRateCard.getBoundingClientRect().top
|
||||
const windowHeight = window.innerHeight
|
||||
const offsetPosition =
|
||||
elementPosition +
|
||||
window.scrollY -
|
||||
(windowHeight - selectedRateCard.offsetHeight - SCROLL_OFFSET)
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
})
|
||||
if (isMultiRoom) {
|
||||
return
|
||||
}
|
||||
}, [isSingleRoomAndHasSelection])
|
||||
|
||||
if (!selectedRateCode) {
|
||||
return
|
||||
}
|
||||
|
||||
// Required to prevent the history.pushState on the first selection
|
||||
// to scroll user back to top
|
||||
requestAnimationFrame(() => {
|
||||
const SCROLL_OFFSET = 173 // summary on mobile is 163px
|
||||
|
||||
const selectedRateCard: HTMLElement | null = document.querySelector(
|
||||
`.${styles.roomList} label:has(input[type=radio]:checked)`
|
||||
)
|
||||
|
||||
if (selectedRateCard) {
|
||||
const elementPosition = selectedRateCard.getBoundingClientRect().top
|
||||
const windowHeight = window.innerHeight
|
||||
const offsetPosition =
|
||||
elementPosition +
|
||||
window.scrollY -
|
||||
(windowHeight - selectedRateCard.offsetHeight - SCROLL_OFFSET)
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "instant",
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [isMultiRoom, selectedRateCode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
"use client"
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import RoomListItem from "./RoomListItem"
|
||||
import { RoomListItem } from "./RoomListItem"
|
||||
import { RoomsListSkeleton } from "./RoomsListSkeleton"
|
||||
import ScrollToList from "./ScrollToList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function RoomsList() {
|
||||
const { isFetchingPackages, rooms } = useRoomContext()
|
||||
if (isFetchingPackages) {
|
||||
export default function RoomsList({ roomIndex }: { roomIndex: number }) {
|
||||
const { getAvailabilityForRoom, isFetching, input, getPackagesForRoom } =
|
||||
useSelectRateContext()
|
||||
|
||||
if (isFetching) {
|
||||
return <RoomsListSkeleton />
|
||||
}
|
||||
|
||||
const hotelId = input?.data?.booking.hotelId
|
||||
if (!hotelId) {
|
||||
throw new Error("Hotel ID is required to display room availability")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToList />
|
||||
<ul className={styles.roomList}>
|
||||
{rooms.map((roomConfiguration) => (
|
||||
<RoomListItem
|
||||
key={roomConfiguration.roomTypeCode}
|
||||
roomConfiguration={roomConfiguration}
|
||||
/>
|
||||
))}
|
||||
{getAvailabilityForRoom(roomIndex)?.map((room, ix) => {
|
||||
return (
|
||||
<RoomListItem
|
||||
key={room.roomTypeCode + ix}
|
||||
room={room}
|
||||
selectedPackages={getPackagesForRoom(roomIndex).selectedPackages}
|
||||
roomIndex={roomIndex}
|
||||
hotelId={hotelId}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,92 +1,39 @@
|
||||
"use client"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import RoomProvider from "@/providers/SelectRate/RoomProvider"
|
||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||
|
||||
import MultiRoomWrapper from "./MultiRoomWrapper"
|
||||
import { MultiRoomWrapper } from "./MultiRoomWrapper"
|
||||
import NoAvailabilityAlert from "./NoAvailabilityAlert"
|
||||
import RoomsHeader from "./RoomsHeader"
|
||||
import { RoomsHeader } from "./RoomsHeader"
|
||||
import RoomsList from "./RoomsList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
import type { PriceProduct } from "@scandic-hotels/trpc/types/roomAvailability"
|
||||
|
||||
export default function Rooms() {
|
||||
const {
|
||||
arrivalDate,
|
||||
bookingRooms,
|
||||
departureDate,
|
||||
hotelId,
|
||||
rooms,
|
||||
visibleRooms,
|
||||
} = useRatesStore((state) => ({
|
||||
arrivalDate: state.booking.fromDate,
|
||||
bookingRooms: state.booking.rooms,
|
||||
departureDate: state.booking.toDate,
|
||||
hotelId: state.booking.hotelId,
|
||||
rooms: state.rooms,
|
||||
visibleRooms: state.roomConfigurations,
|
||||
}))
|
||||
availability,
|
||||
input: { isMultiRoom },
|
||||
} = useSelectRateContext()
|
||||
|
||||
useEffect(() => {
|
||||
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
|
||||
roomConfiguration.flatMap((room) =>
|
||||
room.products
|
||||
.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)!,
|
||||
price: (product.public?.localPrice.pricePerNight ||
|
||||
product.member?.localPrice.pricePerNight)!,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
// Specific n/a when no prices available in reward night and voucher scenarios
|
||||
const lowestPrice = pricesWithCurrencies.length
|
||||
? pricesWithCurrencies
|
||||
.reduce((minPrice, { price }) => Math.min(minPrice, price), Infinity)
|
||||
.toString()
|
||||
: "n/a"
|
||||
|
||||
const currency = pricesWithCurrencies.length
|
||||
? pricesWithCurrencies[0]?.currency
|
||||
: "n/a"
|
||||
|
||||
trackLowestRoomPrice({
|
||||
hotelId,
|
||||
arrivalDate,
|
||||
departureDate,
|
||||
lowestPrice: lowestPrice,
|
||||
currency: currency,
|
||||
})
|
||||
}, [arrivalDate, departureDate, hotelId, visibleRooms])
|
||||
if (!availability) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{bookingRooms.map((room, idx) => (
|
||||
<RoomProvider
|
||||
key={`${room.rateCode}-${room.roomTypeCode}-${idx}`}
|
||||
idx={idx}
|
||||
room={rooms[idx]}
|
||||
>
|
||||
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
||||
<RoomsHeader />
|
||||
<NoAvailabilityAlert />
|
||||
<RoomsList />
|
||||
{availability.data?.map((_room, idx) => {
|
||||
return (
|
||||
<MultiRoomWrapper
|
||||
key={`${idx}`}
|
||||
roomIndex={idx}
|
||||
isMultiRoom={isMultiRoom}
|
||||
>
|
||||
<RoomsHeader roomIndex={idx} />
|
||||
<NoAvailabilityAlert roomIndex={idx} />
|
||||
<RoomsList roomIndex={idx} />
|
||||
</MultiRoomWrapper>
|
||||
</RoomProvider>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import { notFound, useSearchParams } from "next/navigation"
|
||||
import { TRPCClientError } from "@trpc/client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import {
|
||||
parseSelectRateSearchParams,
|
||||
searchParamsToRecord,
|
||||
} from "@scandic-hotels/booking-flow/utils/url"
|
||||
import { trpc } from "@scandic-hotels/trpc/client"
|
||||
import { selectRateRoomsAvailabilityInputSchema } from "@scandic-hotels/trpc/routers/hotels/input"
|
||||
import { AlertTypeEnum } from "@scandic-hotels/trpc/types/alertType"
|
||||
|
||||
import Alert from "@/components/TempDesignSystem/Alert"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import RatesProvider from "@/providers/RatesProvider"
|
||||
import { useSelectRateContext } from "@/contexts/SelectRate/SelectRateContext"
|
||||
|
||||
import RateSummary from "./RateSummary"
|
||||
import { RateSummary } from "./RateSummary"
|
||||
import Rooms from "./Rooms"
|
||||
import { RoomsContainerSkeleton } from "./RoomsContainerSkeleton"
|
||||
|
||||
import styles from "./index.module.css"
|
||||
|
||||
import type { AppRouter } from "@scandic-hotels/trpc/routers/appRouter"
|
||||
|
||||
import type { RoomsContainerProps } from "@/types/components/hotelReservation/selectRate/roomsContainer"
|
||||
|
||||
export function RoomsContainer({
|
||||
hotelType,
|
||||
roomCategories,
|
||||
vat,
|
||||
}: RoomsContainerProps) {
|
||||
const lang = useLang()
|
||||
export function RoomsContainer({}: RoomsContainerProps) {
|
||||
const intl = useIntl()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const booking = parseSelectRateSearchParams(
|
||||
searchParamsToRecord(searchParams)
|
||||
)
|
||||
|
||||
if (!booking) return notFound()
|
||||
|
||||
const bookingInput = selectRateRoomsAvailabilityInputSchema.safeParse({
|
||||
booking,
|
||||
lang,
|
||||
})
|
||||
|
||||
const { data, isFetching, isError, error } =
|
||||
trpc.hotel.availability.selectRate.rooms.useQuery(bookingInput.data!, {
|
||||
retry(failureCount, error) {
|
||||
if (error.data?.code === "BAD_REQUEST") {
|
||||
return false
|
||||
}
|
||||
|
||||
return failureCount <= 3
|
||||
},
|
||||
enabled: bookingInput.success,
|
||||
})
|
||||
const {
|
||||
availability: { error, isFetching, isError },
|
||||
input: { hasError: hasInputError },
|
||||
} = useSelectRateContext()
|
||||
|
||||
if (isFetching) {
|
||||
return <RoomsContainerSkeleton />
|
||||
}
|
||||
|
||||
if (isError || !bookingInput.success) {
|
||||
const errorMessage = getErrorMessage(
|
||||
error?.data?.zodError?.formErrors,
|
||||
intl
|
||||
)
|
||||
if (isError || hasInputError) {
|
||||
const errorMessage = getErrorMessage(error, intl)
|
||||
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
@@ -73,24 +41,21 @@ export function RoomsContainer({
|
||||
}
|
||||
|
||||
return (
|
||||
<RatesProvider
|
||||
booking={booking}
|
||||
hotelType={hotelType}
|
||||
roomCategories={roomCategories}
|
||||
roomsAvailability={data}
|
||||
vat={vat}
|
||||
>
|
||||
<>
|
||||
<Rooms />
|
||||
<RateSummary />
|
||||
</RatesProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(
|
||||
formErrors: string[] | undefined,
|
||||
intl: ReturnType<typeof useIntl>
|
||||
) {
|
||||
const firstError = formErrors?.at(0)
|
||||
function getErrorMessage(error: unknown, intl: ReturnType<typeof useIntl>) {
|
||||
if (!isTRPCClientError(error)) {
|
||||
return intl.formatMessage({
|
||||
defaultMessage: "Something went wrong",
|
||||
})
|
||||
}
|
||||
|
||||
const firstError = error.data?.zodError?.formErrors?.at(0)
|
||||
|
||||
switch (firstError) {
|
||||
case "FROMDATE_INVALID":
|
||||
@@ -107,3 +72,9 @@ function getErrorMessage(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isTRPCClientError(
|
||||
cause: unknown
|
||||
): cause is TRPCClientError<AppRouter> {
|
||||
return cause instanceof TRPCClientError
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user