feat(SW-1717): rewrite select-rate to show all variants of rate-cards

This commit is contained in:
Simon Emanuelsson
2025-03-25 11:25:44 +01:00
committed by Michael Zetterberg
parent adde77eaa9
commit ebaea78fb3
118 changed files with 4601 additions and 4374 deletions

View File

@@ -1,158 +1,158 @@
import { BedTypeEnum } from "@/constants/booking"
// import { BedTypeEnum } from "@/constants/booking"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import type {
DetailsSchema,
RoomPrice,
RoomRate,
SignedInDetailsSchema,
} from "@/types/components/hotelReservation/enterDetails/details"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency"
import { PackageTypeEnum } from "@/types/enums/packages"
// import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
// import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
// import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
// import type {
// DetailsSchema,
// RoomPrice,
// RoomRate,
// SignedInDetailsSchema,
// } from "@/types/components/hotelReservation/enterDetails/details"
// import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
// import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
// import { CurrencyEnum } from "@/types/enums/currency"
// import { PackageTypeEnum } from "@/types/enums/packages"
export const booking: SelectRateSearchParams = {
city: "Stockholm",
hotelId: "811",
fromDate: "2030-01-01",
toDate: "2030-01-03",
rooms: [
{
adults: 2,
roomTypeCode: "SKS",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
{
adults: 2,
roomTypeCode: "SKS",
rateCode: "",
counterRateCode: "",
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
packages: [RoomPackageCodeEnum.PET_ROOM],
},
],
}
// export const booking: SelectRateSearchParams = {
// city: "Stockholm",
// hotelId: "811",
// fromDate: "2030-01-01",
// toDate: "2030-01-03",
// rooms: [
// {
// adults: 2,
// roomTypeCode: "SKS",
// rateCode: "",
// counterRateCode: "",
// childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
// packages: [RoomPackageCodeEnum.PET_ROOM],
// },
// {
// adults: 2,
// roomTypeCode: "SKS",
// rateCode: "",
// counterRateCode: "",
// childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
// packages: [RoomPackageCodeEnum.PET_ROOM],
// },
// ],
// }
export const breakfastPackage: BreakfastPackage = {
code: "BRF1",
description: "Breakfast with reservation",
localPrice: { currency: "SEK", price: 99, totalPrice: 99 },
requestedPrice: {
currency: "EUR",
price: 9,
totalPrice: 9,
},
packageType: PackageTypeEnum.BreakfastAdult as const,
}
// export const breakfastPackage: BreakfastPackage = {
// code: "BRF1",
// description: "Breakfast with reservation",
// localPrice: { currency: "SEK", price: 99, totalPrice: 99 },
// requestedPrice: {
// currency: "EUR",
// price: 9,
// totalPrice: 9,
// },
// packageType: PackageTypeEnum.BreakfastAdult as const,
// }
export const roomRate: RoomRate = {
memberRate: {
rateCode: "PLSA2BEU",
localPrice: {
pricePerNight: 1508,
pricePerStay: 1508,
currency: CurrencyEnum.SEK,
},
requestedPrice: {
pricePerNight: 132,
pricePerStay: 132,
currency: CurrencyEnum.EUR,
},
},
publicRate: {
rateCode: "SAVEEU",
localPrice: {
pricePerNight: 1525,
pricePerStay: 1525,
currency: CurrencyEnum.SEK,
},
requestedPrice: {
pricePerNight: 133,
pricePerStay: 133,
currency: CurrencyEnum.EUR,
},
},
}
// export const roomRate: RoomRate = {
// memberRate: {
// rateCode: "PLSA2BEU",
// localPrice: {
// pricePerNight: 1508,
// pricePerStay: 1508,
// currency: CurrencyEnum.SEK,
// },
// requestedPrice: {
// pricePerNight: 132,
// pricePerStay: 132,
// currency: CurrencyEnum.EUR,
// },
// },
// publicRate: {
// rateCode: "SAVEEU",
// localPrice: {
// pricePerNight: 1525,
// pricePerStay: 1525,
// currency: CurrencyEnum.SEK,
// },
// requestedPrice: {
// pricePerNight: 133,
// pricePerStay: 133,
// currency: CurrencyEnum.EUR,
// },
// },
// }
export const roomPrice: RoomPrice = {
perNight: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
perStay: {
local: {
currency: "SEK",
price: 1525,
},
requested: {
currency: "EUR",
price: 133,
},
},
}
// export const roomPrice: RoomPrice = {
// perNight: {
// local: {
// currency: "SEK",
// price: 1525,
// },
// requested: {
// currency: "EUR",
// price: 133,
// },
// },
// perStay: {
// local: {
// currency: "SEK",
// price: 1525,
// },
// requested: {
// currency: "EUR",
// price: 133,
// },
// },
// }
export const bedType: { [x: string]: BedTypeSelection } = {
king: {
type: BedTypeEnum.King,
description: "King-size bed",
value: "SKS",
size: {
min: 180,
max: 200,
},
extraBed: undefined,
},
queen: {
type: BedTypeEnum.Queen,
description: "Queen-size bed",
value: "QZ",
size: {
min: 160,
max: 200,
},
extraBed: undefined,
},
single: {
type: BedTypeEnum.Single,
description: "Single bed",
size: {
max: 140,
min: 100,
},
value: "CSR",
extraBed: undefined,
},
}
// export const bedType: { [x: string]: BedTypeSelection } = {
// king: {
// type: BedTypeEnum.King,
// description: "King-size bed",
// value: "SKS",
// size: {
// min: 180,
// max: 200,
// },
// extraBed: undefined,
// },
// queen: {
// type: BedTypeEnum.Queen,
// description: "Queen-size bed",
// value: "QZ",
// size: {
// min: 160,
// max: 200,
// },
// extraBed: undefined,
// },
// single: {
// type: BedTypeEnum.Single,
// description: "Single bed",
// size: {
// max: 140,
// min: 100,
// },
// value: "CSR",
// extraBed: undefined,
// },
// }
export const guestDetailsNonMember: DetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
}
// export const guestDetailsNonMember: DetailsSchema = {
// join: false,
// countryCode: "SE",
// email: "tester@testersson.com",
// firstName: "Test",
// lastName: "Testersson",
// phoneNumber: "72727272",
// }
export const guestDetailsMember: SignedInDetailsSchema = {
join: false,
countryCode: "SE",
email: "tester@testersson.com",
firstName: "Test",
lastName: "Testersson",
phoneNumber: "72727272",
zipCode: "12345",
dateOfBirth: "1999-01-01",
membershipNo: "12421412211212",
}
// export const guestDetailsMember: SignedInDetailsSchema = {
// join: false,
// countryCode: "SE",
// email: "tester@testersson.com",
// firstName: "Test",
// lastName: "Testersson",
// phoneNumber: "72727272",
// zipCode: "12345",
// dateOfBirth: "1999-01-01",
// membershipNo: "12421412211212",
// }

View File

@@ -91,6 +91,7 @@ export default async function DetailsPage({
// redirect back to select-rate if availability call fails
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
}
rooms.push({
bedTypes: roomAvailability.bedTypes,
breakfastIncluded: roomAvailability.breakfastIncluded,
@@ -106,13 +107,7 @@ export default async function DetailsPage({
rateType: roomAvailability.rateType,
roomType: roomAvailability.selectedRoom.roomType,
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
roomRate: {
memberRate: roomAvailability?.memberRate,
publicRate: roomAvailability.publicRate,
redemptionRate: roomAvailability.redemptionRate,
voucherRate: roomAvailability.voucherRate,
chequeRate: roomAvailability.chequeRate,
},
roomRate: roomAvailability.product,
isAvailable:
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
})

View File

@@ -9,6 +9,7 @@ import {
type TrackingSDKHotelInfo,
type TrackingSDKPageData,
} from "@/types/components/tracking"
import { CurrencyEnum } from "@/types/enums/currency"
import type { Hotel } from "@/types/hotel"
import type { Room } from "@/types/providers/details/room"
import type { Lang } from "@/constants/languages"
@@ -68,10 +69,24 @@ export function getTracking(
noOfRooms: booking.rooms.length,
rateCode: rooms
.map((room, idx) => {
if (idx === 0 && isMember && room.roomRate.memberRate) {
return room.roomRate.memberRate?.rateCode
const isMainRoom = idx === 0
if (
"member" in room.roomRate &&
room.roomRate.member &&
isMember &&
isMainRoom
) {
return room.roomRate.member.rateCode
} else if ("public" in room.roomRate && room.roomRate.public) {
return room.roomRate.public.rateCode
} else if ("corporateCheque" in room.roomRate) {
return room.roomRate.corporateCheque.rateCode
} else if ("redemption" in room.roomRate) {
return room.roomRate.redemption.rateCode
} else if ("voucher" in room.roomRate) {
return room.roomRate.voucher.rateCode
}
return room.roomRate.publicRate?.rateCode
return "-"
})
.join("|"),
rateCodeCancellationRule: rooms
@@ -81,11 +96,20 @@ export function getTracking(
rateCodeType: rooms.map((room) => room.rateType.toLowerCase()).join(","),
region: hotel?.address.city,
revenueCurrencyCode: rooms
.map(
(room) =>
room.roomRate.publicRate?.localPrice.currency ??
room.roomRate.memberRate?.localPrice.currency
)
.map((room) => {
if ("corporateCheque" in room.roomRate) {
return CurrencyEnum.CC
} else if ("redemption" in room.roomRate) {
return CurrencyEnum.POINTS
} else if ("voucher" in room.roomRate) {
return CurrencyEnum.Voucher
} else if ("public" in room.roomRate && room.roomRate.public) {
return room.roomRate.public.localPrice.currency
} else if ("member" in room.roomRate && room.roomRate.member) {
return room.roomRate.member.localPrice.currency
}
return CurrencyEnum.Unknown
})
.join(","),
searchTerm: city,
searchType: "hotel",

View File

@@ -1,9 +1,13 @@
import stringify from "json-stable-stringify-without-jsonify"
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { REDEMPTION } from "@/constants/booking"
import SelectRate from "@/components/HotelReservation/SelectRate"
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
import { convertSearchParamsToObj } from "@/utils/url"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { LangParams, PageArgs } from "@/types/params"
@@ -13,6 +17,17 @@ export default async function SelectRatePage({
searchParams,
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
const suspenseKey = stringify(searchParams)
const booking = convertSearchParamsToObj<SelectRateSearchParams>(searchParams)
const isMultiRoom = booking.rooms.length > 1
const isRedemption = booking.searchType === REDEMPTION
const isVoucher = booking.bookingCode
? /(^VO[0-9a-z]*$)/i.test(booking.bookingCode)
: false
if ((isMultiRoom && isRedemption) || (isMultiRoom && isVoucher)) {
return notFound()
}
return (
<Suspense

View File

@@ -1,5 +1,4 @@
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
import { Typography } from "@scandic-hotels/design-system/Typography"
import ButtonLink from "@/components/ButtonLink"

View File

@@ -2,8 +2,6 @@
import { DayPicker } from "react-day-picker"
import { useIntl } from "react-intl"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
import { Lang } from "@/constants/languages"
import { dt } from "@/lib/dt"

View File

@@ -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({

View File

@@ -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,
}

View File

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

View File

@@ -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 (

View File

@@ -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

View File

@@ -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,
},
}
}),
})
},
[

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
? " + "
: ""}

View File

@@ -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"

View File

@@ -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 (
<>

View File

@@ -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 stayand for all guests.",
id: "Breakfast can only be added for the entire duration of the stay and for all guests.",
})}
/>
{(breakfastData.nrOfPayingChildren > 0 ||

View File

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

View File

@@ -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",

View File

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

View File

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

View File

@@ -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}}" },

View File

@@ -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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
.hotelAlert {
margin: 0 auto;
padding: var(--Spacing-x-one-and-half);
width: 100%;
}

View File

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

View File

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

View File

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

View File

@@ -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,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
.pointsList {
margin: 0;
}
.pointsRow {
display: flex;
align-items: baseline;
justify-content: flex-start;
gap: var(--Spacing-x-half);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -31,11 +31,11 @@ export default async function ContactRow({ contact }: ContactRowProps) {
let Icon = null
if (contact.contact_field.includes("email")) {
Icon = function (props: MaterialIconSetIconProps) {
Icon = function MailIcon(props: MaterialIconSetIconProps) {
return <MaterialIcon icon="mail" {...props} />
}
} else if (contact.contact_field.includes("phone")) {
Icon = function (props: MaterialIconSetIconProps) {
Icon = function PhoneIcone(props: MaterialIconSetIconProps) {
return <MaterialIcon icon="phone" {...props} />
}
}

View File

@@ -13,7 +13,7 @@ import type { AncillaryCardProps } from "@/types/components/ancillaryCard"
export function AncillaryCard({ ancillary }: AncillaryCardProps) {
const intl = useIntl()
const priceMsg = `${formatPrice(intl, ancillary.price.totalPrice, ancillary.price.currency)} ${ancillary.price.text ?? ""}`
const priceMsg = `${formatPrice(intl, ancillary.price.total, ancillary.price.currency)} ${ancillary.price.text ?? ""}`
return (
<article className={styles.ancillaryCard}>

View File

@@ -44,6 +44,10 @@
background-color: var(--Base-Surface-Subtle-Normal);
}
.borderDividerSubtle {
background-color: var(--Border-Divider-Subtle);
}
.opacity100 {
opacity: 1;
}
@@ -58,4 +62,4 @@
.Border-Divider-Default {
background-color: var(--Border-Divider-Default);
}
}

View File

@@ -15,6 +15,7 @@ export const dividerVariants = cva(styles.divider, {
white: styles.white,
baseSurfaceSubtleHover: styles.baseSurfaceSubtleHover,
"Border/Divider/Default": styles["Border-Divider-Default"],
borderDividerSubtle: styles.borderDividerSubtle,
},
opacity: {
100: styles.opacity100,

View File

@@ -0,0 +1,24 @@
"use client"
import { useIntl } from "react-intl"
export default function useRateTitles() {
const intl = useIntl()
const payNow = intl.formatMessage({ id: "Pay now" })
return {
change: {
paymentTerm: payNow,
title: intl.formatMessage({ id: "Free rebooking" }),
},
flex: {
paymentTerm: intl.formatMessage({ id: "Pay later" }),
title: intl.formatMessage({ id: "Free cancellation" }),
},
save: {
paymentTerm: payNow,
title: intl.formatMessage({ id: "Non-refundable" }),
},
noPriceAvailable: intl.formatMessage({ id: "No prices available" }),
}
}

View File

@@ -88,11 +88,7 @@ export const getSelectedRoomAvailability = cache(
function getMemoizedSelectedRoomAvailability(
input: GetSelectedRoomAvailabilityInput
) {
if (input.redemption) {
return serverClient().hotel.availability.roomWithRedemption(input)
} else {
return serverClient().hotel.availability.room(input)
}
return serverClient().hotel.availability.room(input)
}
)

View File

@@ -7,14 +7,9 @@ import { RoomContext } from "@/contexts/Details/Room"
import type { RoomProviderProps } from "@/types/providers/details/room"
export default function RoomProvider({ children, idx }: RoomProviderProps) {
const actions = useEnterDetailsStore((state) => ({
setStep: state.actions.setStep(idx),
updateBedType: state.actions.updateBedType(idx),
updateBreakfast: state.actions.updateBreakfast(idx),
updateDetails: state.actions.updateDetails(idx),
}))
const { activeRoom, currentStep, isComplete, room, steps } =
const { actions, activeRoom, currentStep, isComplete, room, steps } =
useEnterDetailsStore((state) => ({
actions: state.rooms[idx].actions,
activeRoom: state.activeRoom,
currentStep: state.rooms[idx].currentStep,
isComplete: state.rooms[idx].isComplete,

View File

@@ -51,9 +51,9 @@ export default function EnterDetailsProvider({
bedType:
room.bedTypes?.length === 1
? {
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
}
roomTypeCode: room.bedTypes[0].value,
description: room.bedTypes[0].description,
}
: undefined,
mustBeGuaranteed: room.mustBeGuaranteed,
isFlexRate: room.isFlexRate,
@@ -161,9 +161,23 @@ export default function EnterDetailsProvider({
)
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
const currency = (filteredOutMissingRooms[0].room.roomRate.publicRate
?.localPrice.currency ||
filteredOutMissingRooms[0].room.roomRate.memberRate?.localPrice.currency)!
// We only extract the first room for its currency,
// the value is the same for the rest of the rooms
const product = filteredOutMissingRooms[0].room.roomRate
let currency = CurrencyEnum.Unknown
if ("corporateCheque" in product) {
currency = CurrencyEnum.CC
} else if ("redemption" in product) {
currency = CurrencyEnum.POINTS
} else if ("voucher" in product) {
currency = CurrencyEnum.Voucher
} else if ("public" in product && product.public) {
currency = product.public.localPrice.currency
} else if ("member" in product && product.member) {
currency = product.member.localPrice.currency
}
const totalPrice = calcTotalPrice(
filteredOutMissingRooms,
currency,

View File

@@ -1,9 +1,14 @@
"use client"
import { useEffect } from "react"
import { trpc } from "@/lib/trpc/client"
import { useRatesStore } from "@/stores/select-rate"
import { RoomContext } from "@/contexts/SelectRate/Room"
import useLang from "@/hooks/useLang"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type { RoomProviderProps } from "@/types/providers/select-rate/room"
export default function RoomProvider({
@@ -11,38 +16,72 @@ export default function RoomProvider({
idx,
room,
}: RoomProviderProps) {
const activeRoom = useRatesStore((state) => state.activeRoom)
const closeSection = useRatesStore((state) => state.actions.closeSection(idx))
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
const roomAvailability = useRatesStore(
(state) => state.roomsAvailability?.[idx]
)
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
const selectRateRedemption = useRatesStore((state) =>
state.actions.selectRateRedemption(idx)
)
const selectRateCheque = useRatesStore((state) =>
state.actions.selectRateCheque(idx)
)
const selectRateVoucher = useRatesStore((state) =>
state.actions.selectRateVoucher(idx)
)
const lang = useLang()
const { activeRoom, booking, roomAvailability, selectedFilter } =
useRatesStore((state) => ({
activeRoom: state.activeRoom,
booking: state.booking,
roomAvailability: state.roomsAvailability?.[idx],
selectedFilter: state.rooms[idx].selectedFilter,
}))
const { appendRegularRates, ...actions } = room.actions
const roomNr = idx + 1
const hasRedemptionRates = room.rooms.some((room) => room.redemptions.length)
const hasCorporateChequeOrVoucherRates = room.rooms.some((room) =>
room.code.some((product) => {
if ("corporateCheque" in product) {
return product.corporateCheque.rateType === RateTypeEnum.CorporateCheque
} else if ("voucher" in product) {
return product.voucher.rateType === RateTypeEnum.Voucher
}
return false
})
)
const dontShowRegularRates =
hasRedemptionRates || hasCorporateChequeOrVoucherRates
// Extra query needed to fetch regular rates upon user
// selecting to view all rates.
// TODO: Setup route to handle singular availability call
const { data, isFetched, isFetching } =
trpc.hotel.availability.roomsCombinedAvailability.useQuery(
{
adultsCount: [room.bookingRoom.adults],
childArray: room.bookingRoom.childrenInRoom
? [room.bookingRoom.childrenInRoom]
: undefined,
hotelId: booking.hotelId,
lang,
roomStayEndDate: booking.toDate,
roomStayStartDate: booking.fromDate,
},
{
enabled: !!(
booking.bookingCode &&
selectedFilter === BookingCodeFilterEnum.All &&
!dontShowRegularRates
),
}
)
useEffect(() => {
if (isFetched && !isFetching && data?.length) {
const regularRates = data[0]
if ("roomConfigurations" in regularRates) {
appendRegularRates(regularRates.roomConfigurations)
}
}
}, [appendRegularRates, data, isFetched, isFetching])
return (
<RoomContext.Provider
value={{
...room,
actions: {
closeSection,
modifyRate,
selectFilter,
selectRate,
selectRateRedemption,
selectRateCheque,
selectRateVoucher,
},
actions,
isActiveRoom: activeRoom === idx,
isFetchingAdditionalRate: isFetched ? false : isFetching,
isMainRoom: roomNr === 1,
roomAvailability,
roomNr,

View File

@@ -45,7 +45,7 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
rateCode: z.string().optional(),
roomStayEndDate: z.string(),
roomStayStartDate: z.string(),
redemption: z.boolean().optional(),
redemption: z.boolean().optional().default(false),
})
export const selectedRoomAvailabilityInputSchema = z.object({

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import { toLang } from "@/server/utils"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { occupancySchema } from "./schemas/availability/occupancy"
import { productTypeSchema } from "./schemas/availability/productType"
import { citySchema } from "./schemas/city"
@@ -23,6 +25,7 @@ import { roomConfigurationSchema } from "./schemas/roomAvailability/configuratio
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RateTypeEnum } from "@/types/enums/rateType"
import type {
AdditionalData,
City,
@@ -101,20 +104,6 @@ export const hotelsAvailabilitySchema = z.object({
),
})
function everyRateHasBreakfastIncluded(
product: Product,
rateDefinitions: RateDefinition[],
userType: "member" | "public"
) {
const rateDefinition = rateDefinitions.find(
(rd) => rd.rateCode === product[userType]?.rateCode
)
if (!rateDefinition) {
return false
}
return rateDefinition.breakfastIncluded
}
function getRate(rate: RateDefinition) {
switch (rate.cancellationRule) {
case "CancellableBefore6PM":
@@ -124,7 +113,9 @@ function getRate(rate: RateDefinition) {
case "NotCancellable":
return "save"
default:
console.info(`Should never happen!`)
console.info(
`Unknown cancellationRule [${rate.cancellationRule}]. This should never happen!`
)
return null
}
}
@@ -151,10 +142,11 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
return statusLookup[a.status] - statusLookup[b.status]
}
const baseRoomsCombinedAvailabilitySchema = z
export const roomsAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
bookingCode: nullableStringValidator,
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
@@ -204,159 +196,213 @@ const baseRoomsCombinedAvailabilitySchema = z
type: z.string().optional(),
}),
})
.transform(({ data: { attributes } }) => {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
function transformRoomConfigs({
data: { attributes },
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
const rateDefinitions = attributes.rateDefinitions
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
// @ts-expect-error - index of cancellationRule TS
acc[val.rateCode] = cancellationRules[val.cancellationRule]
return acc
}, {})
function getProductRateCode(product: Product) {
if ("corporateCheque" in product) {
return product.corporateCheque.rateCode
}
if ("redemption" in product && product.redemption) {
return product.redemption.rateCode
}
if ("voucher" in product) {
return product.voucher.rateCode
}
if ("public" in product && product.public) {
return product.public.rateCode
}
if ("member" in product && product.member) {
return product.member.rateCode
}
return ""
}
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
room.breakfastIncludedInAllRatesMember = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
)
room.breakfastIncludedInAllRatesPublic = room.products.every(
(product) =>
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
)
function sortProductsBasedOnCancellationRule(a: Product, b: Product) {
// @ts-expect-error - index
const lookUpA = cancellationRuleLookup[getProductRateCode(a)]
// @ts-expect-error - index
const lookUpB = cancellationRuleLookup[getProductRateCode(b)]
return lookUpA - lookUpB
}
room.products = room.products.map((product) => {
const publicRate = product.public
if (publicRate?.rateCode) {
const publicRateDefinition = rateDefinitions.find(
(rateDefinition) =>
rateDefinition.rateCode === publicRate.rateCode
)
if (publicRateDefinition) {
const rate = getRate(publicRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
function findRateDefintion(rateCode: string) {
return rateDefinitions.find(
(rateDefinition) => rateDefinition.rateCode === rateCode
)
}
const memberRate = product.member
if (memberRate?.rateCode) {
const memberRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === memberRate.rateCode
)
if (memberRateDefinition) {
const rate = getRate(memberRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
const voucherRate = product.voucher
if (voucherRate?.rateCode) {
const voucherRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === voucherRate.rateCode
)
if (voucherRateDefinition) {
const rate = getRate(voucherRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
const chequeRate = product.bonusCheque
if (chequeRate?.rateCode) {
const chequeRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === chequeRate.rateCode
)
if (chequeRateDefinition) {
const rate = getRate(chequeRateDefinition)
if (rate) {
product.rate = rate
if (rate === "flex") {
product.isFlex = true
}
}
}
}
return product
})
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.products = room.products.sort(
(a, b) =>
// @ts-expect-error - index
cancellationRuleLookup[a.public?.rateCode || a.member?.rateCode] -
// @ts-expect-error - index
cancellationRuleLookup[b.public?.rateCode || b.member?.rateCode]
)
function getRateDetails(product: Product) {
let rateCode = ""
if ("corporateCheque" in product) {
rateCode = product.corporateCheque.rateCode
} else if ("redemption" in product && product.redemption) {
rateCode = product.redemption.rateCode
} else if ("voucher" in product && product.voucher) {
rateCode = product.voucher.rateCode
} else if ("public" in product && product.public) {
rateCode = product.public.rateCode
} else if ("member" in product && product.member) {
rateCode = product.member.rateCode
}
return room
})
.sort(sortRoomConfigs)
if (!rateCode) {
return null
}
return {
...attributes,
roomConfigurations,
}
}
const rateDefinition = findRateDefintion(rateCode)
export const roomsAvailabilitySchema = z
.object({
data: z.object({
attributes: z.object({
checkInDate: z.string(),
checkOutDate: z.string(),
hotelId: z.number(),
mustBeGuaranteed: z.boolean().optional(),
occupancy: occupancySchema.optional(),
rateDefinitions: z.array(rateDefinitionSchema),
roomConfigurations: z.array(roomConfigurationSchema),
}),
relationships: relationshipsSchema.optional(),
type: z.string().optional(),
}),
})
.transform(transformRoomConfigs)
if (!rateDefinition) {
return null
}
export const roomsCombinedAvailabilitySchema =
baseRoomsCombinedAvailabilitySchema.transform(transformRoomConfigs)
const rate = getRate(rateDefinition)
if (!rate) {
return null
}
export const redemptionRoomsCombinedAvailabilitySchema =
baseRoomsCombinedAvailabilitySchema.transform((data) => {
// In Redemption, rates are always Flex terms
data.data.attributes.roomConfigurations =
data.data.attributes.roomConfigurations
.map((room) => {
room.products = room.products.map((product) => {
product.rate = "flex"
product.isFlex = true
return product
})
return room
})
.sort(
// @ts-expect-error - array indexing
(a, b) => statusLookup[a.status] - statusLookup[b.status]
)
product.breakfastIncluded = rateDefinition.breakfastIncluded
product.rate = rate
product.rateDefinition = rateDefinition
return transformRoomConfigs(data)
return product
}
const roomConfigurations = attributes.roomConfigurations
.map((room) => {
if (room.products.length) {
const breakfastIncluded = []
const breakfastIncludedMember = []
for (const product of room.products) {
if ("corporateCheque" in product) {
const rateDetails = getRateDetails(product)
if (rateDetails) {
breakfastIncluded.push(rateDetails.breakfastIncluded)
room.code.push({
...rateDetails,
corporateCheque: product.corporateCheque,
})
}
continue
}
if ("voucher" in product) {
const rateDetails = getRateDetails(product)
if (rateDetails) {
breakfastIncluded.push(rateDetails.breakfastIncluded)
room.code.push({
...rateDetails,
voucher: product.voucher,
})
}
continue
}
// Redemption is an array
if (Array.isArray(product)) {
if (product.length) {
for (const redemption of product) {
const rateDetails = getRateDetails(redemption)
if (rateDetails) {
breakfastIncluded.push(rateDetails.breakfastIncluded)
room.redemptions.push({
...redemption,
...rateDetails,
})
}
}
}
continue
}
if (
("member" in product && product.member) ||
("public" in product && product.public)
) {
const memberRate = product.member
const publicRate = product.public
const rateCode = publicRate?.rateCode ?? memberRate?.rateCode
const rateDetails = getRateDetails(product)
const rateDetailsMember = getRateDetails({
...product,
public: null,
})
if (rateDetailsMember) {
breakfastIncludedMember.push(
rateDetailsMember.breakfastIncluded
)
}
if (rateDetails && rateCode) {
const rateDefinition = findRateDefintion(rateCode)
if (rateDefinition) {
switch (rateDefinition.rateType) {
case RateTypeEnum.PublicPromotion:
room.campaign.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
break
case RateTypeEnum.Regular:
room.regular.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
break
default:
room.code.push({
...rateDetails,
member: memberRate,
public: publicRate,
})
}
}
continue
}
}
}
room.breakfastIncludedInAllRates =
!!breakfastIncluded.length && breakfastIncluded.every(Boolean)
room.breakfastIncludedInAllRatesMember =
!!breakfastIncludedMember.length &&
breakfastIncludedMember.every(Boolean)
// CancellationRule is the same for public and member per product
// Sorting to guarantee order based on rate
room.campaign.sort(sortProductsBasedOnCancellationRule)
room.code.sort(sortProductsBasedOnCancellationRule)
room.redemptions.sort(sortProductsBasedOnCancellationRule)
room.regular.sort(sortProductsBasedOnCancellationRule)
const hasCampaignProducts = room.campaign.length
const hasCodeProducts = room.code.length
const hasRedemptionProducts = room.redemptions.length
const hasRegularProducts = room.regular.length
if (
!hasCampaignProducts &&
!hasCodeProducts &&
!hasRedemptionProducts &&
!hasRegularProducts
) {
room.status = AvailabilityEnum.NotAvailable
}
}
return room
})
.sort(sortRoomConfigs)
return {
...attributes,
roomConfigurations,
}
})
export const ratesSchema = z.array(rateSchema)
@@ -507,7 +553,7 @@ export const ancillaryPackagesSchema = z
description: item.descriptions.html,
imageUrl: item.images[0]?.imageSizes.small,
price: {
totalPrice: item.variants.ancillary.price.totalPrice,
total: item.variants.ancillary.price.totalPrice,
currency: item.variants.ancillary.price.currency,
},
points: item.variants.ancillaryLoyalty?.points,

View File

@@ -3,11 +3,10 @@ import { Lang } from "@/constants/languages"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { badRequestError } from "@/server/errors/trpc"
import { badRequestError, unauthorizedError } from "@/server/errors/trpc"
import {
contentStackBaseWithServiceProcedure,
protectedProcedure,
protectedServiceProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -52,9 +51,7 @@ import {
hotelSchema,
packagesSchema,
ratesSchema,
redemptionRoomsCombinedAvailabilitySchema,
roomsAvailabilitySchema,
roomsCombinedAvailabilitySchema,
} from "./output"
import tempRatesData from "./tempRatesData.json"
import {
@@ -65,6 +62,7 @@ import {
getHotelIdsByCountry,
getHotelsByHotelIds,
getLocations,
getSelectedRoomAvailability,
} from "./utils"
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
@@ -75,8 +73,6 @@ import type { HotelDataWithUrl } from "@/types/hotel"
import type {
HotelsAvailabilityInputSchema,
HotelsByHotelIdsAvailabilityInputSchema,
RoomsCombinedAvailabilityInputSchema,
SelectedRoomAvailabilitySchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
@@ -472,353 +468,6 @@ export const getHotelsAvailabilityByHotelIds = async (
)
}
async function getRoomsCombinedAvailability(
input: RoomsCombinedAvailabilityInputSchema,
token: string // Either service token or user access token in case of redemption search
) {
const { lang } = input
const apiLang = toApiLang(lang)
const {
adultsCount,
bookingCode,
childArray,
hotelId,
rateCode,
roomStayEndDate,
roomStayStartDate,
redemption,
} = input
const metricsData = {
hotelId,
roomStayStartDate,
roomStayEndDate,
adultsCount,
childArray: childArray ? JSON.stringify(childArray) : undefined,
bookingCode,
}
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
console.info(
"api.hotels.roomsCombinedAvailability start",
JSON.stringify({ query: { hotelId, params: metricsData } })
)
const availabilityResponses = await Promise.allSettled(
adultsCount.map(async (adultCount: number, idx: number) => {
const kids = childArray?.[idx]
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults: adultCount,
...(kids?.length && {
children: generateChildrenString(kids),
}),
...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: apiLang,
}
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
console.error("Failed API call", { params, text })
return { error: "http_error", details: text }
}
const apiJson = await apiResponse.json()
const validateAvailabilityData = redemption
? redemptionRoomsCombinedAvailabilitySchema.safeParse(apiJson)
: roomsCombinedAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
console.error("Validation error", {
params,
error: validateAvailabilityData.error,
})
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
return {
error: "validation_error",
details: validateAvailabilityData.error,
}
}
if (rateCode) {
validateAvailabilityData.data.mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.mustBeGuaranteed
}
return validateAvailabilityData.data
})
)
metrics.roomsCombinedAvailability.success.add(1, metricsData)
return availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
}
export const getRoomAvailability = async (
input: SelectedRoomAvailabilitySchema,
lang: Lang,
token: string, // Either service token or user access token in case of redemption search
serviceToken?: string // In Redemption we need serviceToken for hotel api call
) => {
const {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
rateCode,
counterRateCode,
roomTypeCode,
redemption,
inputLang,
} = input
const language = inputLang ?? lang
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
...(redemption && { isRedemption: "true" }),
language: toApiLang(language),
}
metrics.selectedRoomAvailability.counter.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponseAvailability = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${token}`,
},
},
params
)
if (!apiResponseAvailability.ok) {
const text = await apiResponseAvailability.text()
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
}),
})
console.error(
"api.hotels.selectedRoomAvailability error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponseAvailability.status,
statusText: apiResponseAvailability.statusText,
text,
},
})
)
throw new Error("Failed to fetch selected room availability")
}
const apiJsonAvailability = await apiResponseAvailability.json()
const validateAvailabilityData =
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
if (!validateAvailabilityData.success) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.selectedRoomAvailability validation error",
JSON.stringify({
query: { hotelId, params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
const hotelData = await getHotel(
{
hotelId,
isCardOnlyPayment: false,
language,
},
serviceToken ?? token
)
const rooms = validateAvailabilityData.data.roomConfigurations
const selectedRoom = rooms.find((room) => room.roomTypeCode === roomTypeCode)
if (!selectedRoom) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found")
return null
}
const availableRoomsInCategory = rooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
const rateTypes = selectedRoom.products.find(
(rate) =>
rate.public?.rateCode === rateCode ||
rate.member?.rateCode === rateCode ||
rate.redemptions?.find((r) => r?.rateCode === rateCode) ||
rate.bonusCheque?.rateCode === rateCode ||
rate.voucher?.rateCode === rateCode
)
if (!rateTypes) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "not_found",
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
})
console.error("No matching rate found")
return null
}
const rates = rateTypes
const rateDefinition = validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)
const memberRateDefinition =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === counterRateCode
)
const bedTypes = availableRoomsInCategory
.map((availRoom) => {
const matchingRoom = hotelData?.roomCategories
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)
if (matchingRoom) {
return {
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
})
.filter((bed): bed is BedTypeSelection => Boolean(bed))
metrics.selectedRoomAvailability.success.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: { hotelId, params: params },
})
)
return {
bedTypes,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
chequeRate: rates?.bonusCheque,
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
memberRate: rates?.member,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
publicRate: rates?.public,
redemptionRate: rates?.redemptions?.find((r) => r?.rateCode === rateCode),
rate: selectedRoom.products[0].rate,
rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms,
// Send rate Title when it is a booking code rate
rateTitle:
rateDefinition?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
voucherRate: rates?.voucher,
}
}
export const hotelQueryRouter = router({
availability: router({
hotelsByCity: serviceProcedure
@@ -847,33 +496,360 @@ export const hotelQueryRouter = router({
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
}),
roomsCombinedAvailability: serviceProcedure
roomsCombinedAvailability: safeProtectedServiceProcedure
.input(roomsCombinedAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomsCombinedAvailability(input, ctx.serviceToken)
}),
roomsCombinedAvailabilityWithRedemption: protectedProcedure
.input(roomsCombinedAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomsCombinedAvailability(
.use(async ({ ctx, input, next }) => {
if (input.redemption) {
if (ctx.session?.token.access_token) {
return next({
ctx: {
token: ctx.session.token.access_token,
},
input,
})
}
throw unauthorizedError()
}
return next({
ctx: {
token: ctx.serviceToken,
},
input,
ctx.session.token.access_token
})
})
.query(
async ({
ctx,
input: {
adultsCount,
bookingCode,
childArray,
hotelId,
lang,
rateCode,
redemption,
roomStayEndDate,
roomStayStartDate,
},
}) => {
const apiLang = toApiLang(lang)
const metricsData = {
hotelId,
roomStayStartDate,
roomStayEndDate,
adultsCount,
childArray: childArray ? JSON.stringify(childArray) : undefined,
bookingCode,
}
metrics.roomsCombinedAvailability.counter.add(1, metricsData)
console.info(
"api.hotels.roomsCombinedAvailability start",
JSON.stringify({ query: { hotelId, params: metricsData } })
)
const availabilityResponses = await Promise.allSettled(
adultsCount.map(async (adultCount: number, idx: number) => {
const kids = childArray?.[idx]
const params: Record<string, string | number | undefined> = {
roomStayStartDate,
roomStayEndDate,
adults: adultCount,
...(kids?.length && {
children: generateChildrenString(kids),
}),
...(bookingCode && { bookingCode }),
language: apiLang,
...(redemption ? { isRedemption: "true" } : {}),
}
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotel(hotelId.toString()),
{
headers: {
Authorization: `Bearer ${ctx.token}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
console.error("Failed API call", { params, text })
return { error: "http_error", details: text }
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
roomsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
console.error("Validation error", {
params,
error: validateAvailabilityData.error,
})
metrics.roomsCombinedAvailability.fail.add(1, metricsData)
return {
error: "validation_error",
details: validateAvailabilityData.error,
}
}
if (rateCode) {
validateAvailabilityData.data.mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.find(
(rate) => rate.rateCode === rateCode
)?.mustBeGuaranteed
}
return validateAvailabilityData.data
})
)
metrics.roomsCombinedAvailability.success.add(1, metricsData)
const data = availabilityResponses.map((availability) => {
if (availability.status === "fulfilled") {
return availability.value
}
return {
details: availability.reason,
error: "request_failure",
}
})
return data
}
),
room: safeProtectedServiceProcedure
.input(selectedRoomAvailabilityInputSchema)
.use(async ({ ctx, input, next }) => {
if (input.redemption) {
if (ctx.session?.token.access_token) {
return next({
ctx: {
token: ctx.session.token.access_token,
},
input,
})
}
throw unauthorizedError()
}
return next({
ctx: {
token: ctx.serviceToken,
},
input,
})
})
.query(async ({ input, ctx }) => {
let selectedRoomData = await getSelectedRoomAvailability(
input,
toApiLang(ctx.lang),
ctx.token
)
}),
room: serviceProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomAvailability(input, ctx.lang, ctx.serviceToken)
}),
roomWithRedemption: protectedServiceProcedure
.input(selectedRoomAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
return getRoomAvailability(
input,
ctx.lang,
ctx.session.token.access_token,
const {
adults,
bookingCode,
children,
counterRateCode,
hotelId,
inputLang,
roomStayEndDate,
roomStayStartDate,
roomTypeCode,
} = input
if (!selectedRoomData) {
// There is no way to differentiate if a rateCode
// selected is a bookingCode rateCode or just a
// regular rateCode, hence we need to make a second
// request without the bookingCode if no availability
// is found
if (bookingCode) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error(
"No matching room found when making the request with bookingCode, attempting without"
)
metrics.selectedRoomAvailability.counter.add(1, {
adults,
children,
hotelId,
roomStayEndDate,
roomStayStartDate,
})
const { bookingCode: extractedBookingCode, ...regularRatesInput } =
input
selectedRoomData = await getSelectedRoomAvailability(
regularRatesInput,
toApiLang(ctx.lang),
ctx.token
)
if (!selectedRoomData) {
metrics.selectedRoomAvailability.fail.add(1, {
adults,
children,
hotelId,
roomStayEndDate,
roomStayStartDate,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found even without bookingCode")
return null
}
} else {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
roomTypeCode,
error_type: "not_found",
error: `Couldn't find selected room with input: ${roomTypeCode}`,
})
console.error("No matching room found")
return null
}
}
const hotelData = await getHotel(
{
hotelId,
isCardOnlyPayment: false,
language: inputLang ?? ctx.lang,
},
ctx.serviceToken
)
const {
product,
rateDefinition,
rateDefinitions,
rooms,
selectedRoom,
} = selectedRoomData
const availableRoomsInCategory = rooms.filter(
(room) => room.roomType === selectedRoom?.roomType
)
if (!product) {
metrics.selectedRoomAvailability.fail.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "not_found",
error: `Couldn't find rateTypes for selected room: ${JSON.stringify(selectedRoom)}`,
})
console.error("No matching rate found")
return null
}
let memberRateDefinition = undefined
if ("member" in product) {
memberRateDefinition = rateDefinitions.find(
(rate) => rate.rateCode === counterRateCode
)
}
const bedTypes = availableRoomsInCategory
.map((availRoom) => {
const matchingRoom = hotelData?.roomCategories
?.find((room) =>
room.roomTypes
.map((roomType) => roomType.code)
.includes(availRoom.roomTypeCode)
)
?.roomTypes.find(
(roomType) => roomType.code === availRoom.roomTypeCode
)
if (matchingRoom) {
return {
description: matchingRoom.description,
size: matchingRoom.mainBed.widthRange,
value: matchingRoom.code,
type: matchingRoom.mainBed.type,
extraBed: matchingRoom.fixedExtraBed
? {
type: matchingRoom.fixedExtraBed.type,
description: matchingRoom.fixedExtraBed.description,
}
: undefined,
}
}
})
.filter((bed): bed is BedTypeSelection => Boolean(bed))
metrics.selectedRoomAvailability.success.add(1, {
hotelId,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.selectedRoomAvailability success",
JSON.stringify({
query: {
hotelId,
params: {
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: inputLang ?? ctx.lang,
},
},
})
)
return {
bedTypes,
breakfastIncluded: !!rateDefinition?.breakfastIncluded,
cancellationRule: rateDefinition?.cancellationRule,
cancellationText: rateDefinition?.cancellationText ?? "",
isFlexRate:
rateDefinition?.cancellationRule ===
CancellationRuleEnum.CancellableBefore6PM,
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
product,
rate: product.rate,
rateDefinitionTitle: rateDefinition?.title ?? "",
rateDetails: rateDefinition?.generalTerms,
// Send rate Title when it is a booking code rate
rateTitle:
rateDefinition?.rateType !== RateTypeEnum.Regular
? rateDefinition?.title
: undefined,
rateType: rateDefinition?.rateType ?? "",
selectedRoom,
}
}),
hotelsByCityWithBookingCode: serviceProcedure
.input(hotelsAvailabilityInputSchema)
@@ -896,23 +872,15 @@ export const hotelQueryRouter = router({
return null
}
// Do not search for regular rates if voucher or corporate cheque codes
const isVoucherOrChqRate =
bookingCodeAvailabilityResponse?.availability.some(
(hotel) =>
hotel.productType?.bonusCheque || hotel.productType?.voucher
)
// Get regular availability of hotels which don't have availability with booking code.
const unavailableHotelIds = !isVoucherOrChqRate
? bookingCodeAvailabilityResponse?.availability
.filter((hotel) => {
return hotel.status === "NotAvailable"
})
.flatMap((hotel) => {
return hotel.hotelId
})
: null
const unavailableHotelIds =
bookingCodeAvailabilityResponse?.availability
.filter((hotel) => {
return hotel.status === "NotAvailable"
})
.flatMap((hotel) => {
return hotel.hotelId
})
// All hotels have availability with booking code no need to fetch regular prices.
// return response as is without any filtering as below.

View File

@@ -1,7 +1,7 @@
import { z } from "zod"
import {
productTypeChequeSchema,
productTypeCorporateChequeSchema,
productTypePointsSchema,
productTypePriceSchema,
productTypeVoucherSchema,
@@ -9,7 +9,7 @@ import {
export const productTypeSchema = z
.object({
bonusCheque: productTypeChequeSchema.optional(),
bonusCheque: productTypeCorporateChequeSchema.optional(),
public: productTypePriceSchema.optional(),
member: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),

View File

@@ -1,63 +1,76 @@
import { z } from "zod"
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { CurrencyEnum } from "@/types/enums/currency"
import { RateTypeEnum } from "@/types/enums/rateType"
export const corporateChequeSchema = z
.object({
additionalPricePerStay: nullableNumberValidator,
currency: z.nativeEnum(CurrencyEnum).nullish(),
numberOfBonusCheques: nullableNumberValidator,
})
.transform((data) => ({
additionalPricePerStay: data.additionalPricePerStay,
currency: data.currency,
numberOfCheques: data.numberOfBonusCheques,
}))
export const redemptionSchema = z.object({
additionalPricePerStay: nullableNumberValidator,
currency: z.nativeEnum(CurrencyEnum).nullish(),
pointsPerNight: nullableNumberValidator,
pointsPerStay: nullableNumberValidator,
})
export const priceSchema = z.object({
currency: z.nativeEnum(CurrencyEnum),
pricePerNight: z.coerce.number(),
pricePerStay: z.coerce.number(),
regularPricePerNight: z.coerce.number().optional(),
regularPricePerStay: z.coerce.number().optional(),
})
export const pointsSchema = z
.object({
currency: z.nativeEnum(CurrencyEnum).optional(),
pricePerStay: z.coerce.number().optional(),
pointsPerStay: z.coerce.number(),
additionalPricePerStay: z.coerce.number().optional(),
additionalPriceCurrency: z.nativeEnum(CurrencyEnum).optional(),
})
.transform((data) => ({
...data,
additionalPriceCurrency: data.currency,
currency: CurrencyEnum.POINTS,
pricePerStay: data.pointsPerStay,
price: data.pointsPerStay,
additionalPrice: data.additionalPricePerStay,
}))
export const voucherSchema = z.object({
currency: z.nativeEnum(CurrencyEnum),
pricePerStay: z.number(),
})
export const chequeSchema = z.object({
additionalPricePerStay: z.number().optional(),
currency: z.nativeEnum(CurrencyEnum).optional(),
numberOfBonusCheques: z.coerce.number(),
omnibusPricePerNight: nullableNumberValidator,
pricePerNight: nullableNumberValidator,
pricePerStay: nullableNumberValidator,
regularPricePerNight: nullableNumberValidator,
regularPricePerStay: nullableNumberValidator,
})
const partialPriceSchema = z.object({
rateCode: z.string(),
rateType: z.string().optional(),
rateCode: nullableStringValidator,
rateType: z.nativeEnum(RateTypeEnum).catch((err) => {
const issue = err.error.issues[0]
// This is necessary to handle cases were a
// new `rateType` is added in the API that has
// not yet been handled in web
if (issue.code === "invalid_enum_value") {
return issue.received.toString() as RateTypeEnum
}
return RateTypeEnum.Regular
}),
})
export const productTypePriceSchema = partialPriceSchema.extend({
localPrice: priceSchema,
requestedPrice: priceSchema.optional(),
})
export const productTypeCorporateChequeSchema = z
.object({
localPrice: corporateChequeSchema,
requestedPrice: corporateChequeSchema.nullish(),
})
.merge(partialPriceSchema)
export const productTypePointsSchema = partialPriceSchema.extend({
localPrice: pointsSchema,
requestedPrice: pointsSchema.optional(),
})
export const productTypePriceSchema = z
.object({
localPrice: priceSchema,
requestedPrice: priceSchema.nullish(),
})
.merge(partialPriceSchema)
export const productTypeVoucherSchema = partialPriceSchema.extend({
numberOfVouchers: z.coerce.number(),
})
export const productTypePointsSchema = z
.object({
localPrice: redemptionSchema,
requestedPrice: redemptionSchema.nullish(),
})
.merge(partialPriceSchema)
export const productTypeChequeSchema = partialPriceSchema.extend({
localPrice: chequeSchema,
requestedPrice: chequeSchema.optional(),
})
export const productTypeVoucherSchema = z
.object({
numberOfVouchers: nullableNumberValidator,
})
.merge(partialPriceSchema)

View File

@@ -1,14 +1,24 @@
import { z } from "zod"
import { productSchema } from "./product"
import {
corporateChequeProduct,
priceProduct,
productSchema,
redemptionProduct,
voucherProduct,
} from "./product"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import {
AvailabilityEnum,
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
import {
RoomPackageCodeEnum,
} from "@/types/components/hotelReservation/selectRate/roomFilter"
export const roomConfigurationSchema = z
.object({
breakfastIncludedInAllRatesMember: z.boolean().default(false),
breakfastIncludedInAllRatesPublic: z.boolean().default(false),
breakfastIncludedInAllRates: z.boolean().default(false),
features: z
.array(
z.object({
@@ -25,36 +35,36 @@ export const roomConfigurationSchema = z
roomsLeft: z.number(),
roomType: z.string(),
roomTypeCode: z.string(),
status: z.string(),
})
.transform((data) => {
if (data.products.length) {
if (data.products[0].redemptions) {
// No need of rate check in reward night scenario
return { ...data }
} else {
const isVoucher = data.products.some((product) => product.voucher)
const isCorpChq = data.products.some((product) => product.bonusCheque)
if (isVoucher || isCorpChq) {
return {
...data,
}
}
/**
* Just guaranteeing that if all products all miss
* both public and member rateCode that status is
* set to `NotAvailable`
*/
const allProductsMissBothRateCodes = data.products.every(
(product) => !product.public?.rateCode && !product.member?.rateCode
)
if (allProductsMissBothRateCodes) {
return {
...data,
status: AvailabilityEnum.NotAvailable,
}
}
}
}
return data
status: z
.nativeEnum(AvailabilityEnum)
.nullish()
.default(AvailabilityEnum.NotAvailable),
// Red
campaign: z
.array(priceProduct)
.nullish()
.transform(val => val ? val.filter(Boolean) : []),
// Blue
code: z
.array(
z
.union([
corporateChequeProduct,
priceProduct,
voucherProduct,
])
)
.nullish()
.transform(val => val ? val.filter(Boolean) : []),
// Beige
regular: z
.array(priceProduct)
.nullish()
.transform(val => val ? val.filter(Boolean) : []),
// Burgundy
redemptions: z
.array(redemptionProduct)
.nullish()
.transform(val => val ? val.filter(Boolean) : []),
})

View File

@@ -1,28 +1,161 @@
import { z } from "zod"
import {
productTypeCorporateChequeSchema,
productTypePointsSchema,
productTypeChequeSchema,
productTypePriceSchema,
productTypeVoucherSchema,
} from "../productTypePrice"
import { rateDefinitionSchema } from "./rateDefinition"
export const productSchema = z
const baseProductSchema = z.object({
// Is breakfast included on product
breakfastIncluded: z.boolean().default(false),
// Used to set the rate that we use to chose titles etc.
rate: z.enum(["change", "flex", "save"]).default("save"),
rateDefinition: rateDefinitionSchema.nullish().transform((val) =>
val
? val
: {
breakfastIncluded: false,
cancellationRule: "",
cancellationText: "",
displayPriceRed: false,
isCampaignRate: false,
isMemberRate: false,
isPackageRate: false,
generalTerms: [],
mustBeGuaranteed: false,
rateCode: "",
rateType: "",
title: "",
}
),
})
function mapBaseProduct(baseProduct: typeof baseProductSchema._type) {
return {
breakfastIncluded: baseProduct.breakfastIncluded,
rate: baseProduct.rate,
rateDefinition: baseProduct.rateDefinition,
}
}
const rawCorporateChequeProduct = z
.object({
// Is product flex rate
isFlex: z.boolean().default(false),
productType: z.object({
bonusCheque: productTypeChequeSchema.optional(),
member: productTypePriceSchema.optional(),
public: productTypePriceSchema.optional(),
redemptions: z.array(productTypePointsSchema).optional(),
voucher: productTypeVoucherSchema.optional(),
}),
// Used to set the rate that we use to chose titles etc.
rate: z.enum(["change", "flex", "save"]).default("save"),
productType: z
.object({
bonusCheque: productTypeCorporateChequeSchema,
})
.transform((data) => ({
corporateCheque: data.bonusCheque,
})),
})
.transform((data) => ({
.merge(baseProductSchema)
function transformCorporateCheque(
data: z.output<typeof rawCorporateChequeProduct>
) {
return {
...data.productType,
isFlex: data.isFlex,
rate: data.rate,
}))
...mapBaseProduct(data),
}
}
export const corporateChequeProduct = rawCorporateChequeProduct.transform(
transformCorporateCheque
)
const rawPriceProduct = z
.object({
productType: z.object({
member: productTypePriceSchema.nullish().default(null),
public: productTypePriceSchema.nullish().default(null),
}),
})
.merge(baseProductSchema)
function transformPriceProduct(data: z.output<typeof rawPriceProduct>) {
return {
...data.productType,
...mapBaseProduct(data),
}
}
export const priceProduct = rawPriceProduct.transform(transformPriceProduct)
export const redemptionProduct = z
.object({
redemption: productTypePointsSchema,
})
.merge(baseProductSchema)
const rawRedemptionsProduct = z.object({
type: z.literal("REDEMPTION").optional().default("REDEMPTION"),
productType: z.object({
redemptions: z
.array(productTypePointsSchema.merge(baseProductSchema))
.transform((data) =>
data.map(
({ breakfastIncluded, rate, rateDefinition, ...redemption }) => ({
breakfastIncluded,
rate,
rateDefinition,
redemption,
})
)
),
}),
})
export const redemptionsProduct = rawRedemptionsProduct.transform(
(data) => data.productType.redemptions
)
const rawVoucherProduct = z
.object({
type: z.literal("VOUCHER").optional().default("VOUCHER"),
productType: z.object({
voucher: productTypeVoucherSchema,
}),
})
.merge(baseProductSchema)
function transformVoucherProduct(data: z.output<typeof rawVoucherProduct>) {
return {
...data.productType,
...mapBaseProduct(data),
}
}
export const voucherProduct = rawVoucherProduct.transform(
transformVoucherProduct
)
export const productSchema = z.union([
corporateChequeProduct,
redemptionsProduct,
voucherProduct,
priceProduct,
])
// export const productSchema = z.discriminatedUnion(
// "type",
// [
// rawCorporateChequeProduct,
// rawPriceProduct,
// rawRedemptionsProduct,
// rawVoucherProduct,
// ]
// )
// .transform(data => {
// switch (data.type) {
// case "CORPORATECHEQUE":
// return transformCorporateCheque(data)
// case "PRICEPRODUCT":
// return transformPriceProduct(data)
// case "REDEMPTION":
// return data.productType.redemptions
// case "VOUCHER":
// return transformVoucherProduct(data)
// }
// })

View File

@@ -1,12 +1,17 @@
import { nullableStringValidator } from "@/utils/zod/stringValidator"
import { z } from "zod"
export const rateDefinitionSchema = z.object({
breakfastIncluded: z.boolean(),
cancellationRule: z.string(),
cancellationText: z.string().optional(),
cancellationText: nullableStringValidator,
displayPriceRed: z.boolean().default(false),
generalTerms: z.array(z.string()),
isCampaignRate: z.boolean().default(false),
isMemberRate: z.boolean().default(false),
isPackageRate: z.boolean().default(false),
mustBeGuaranteed: z.boolean(),
rateCode: z.string(),
rateType: z.string().optional(),
rateType: nullableStringValidator,
title: z.string(),
})

Some files were not shown because too many files have changed in this diff Show More