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