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