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 { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
// import type { BreakfastPackage } from "@/types/components/hotelReservation/breakfast"
|
||||||
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
// import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
import type {
|
// import type {
|
||||||
DetailsSchema,
|
// DetailsSchema,
|
||||||
RoomPrice,
|
// RoomPrice,
|
||||||
RoomRate,
|
// RoomRate,
|
||||||
SignedInDetailsSchema,
|
// SignedInDetailsSchema,
|
||||||
} from "@/types/components/hotelReservation/enterDetails/details"
|
// } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
// import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
// import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { CurrencyEnum } from "@/types/enums/currency"
|
// import { CurrencyEnum } from "@/types/enums/currency"
|
||||||
import { PackageTypeEnum } from "@/types/enums/packages"
|
// import { PackageTypeEnum } from "@/types/enums/packages"
|
||||||
|
|
||||||
export const booking: SelectRateSearchParams = {
|
// export const booking: SelectRateSearchParams = {
|
||||||
city: "Stockholm",
|
// city: "Stockholm",
|
||||||
hotelId: "811",
|
// hotelId: "811",
|
||||||
fromDate: "2030-01-01",
|
// fromDate: "2030-01-01",
|
||||||
toDate: "2030-01-03",
|
// toDate: "2030-01-03",
|
||||||
rooms: [
|
// rooms: [
|
||||||
{
|
// {
|
||||||
adults: 2,
|
// adults: 2,
|
||||||
roomTypeCode: "SKS",
|
// roomTypeCode: "SKS",
|
||||||
rateCode: "",
|
// rateCode: "",
|
||||||
counterRateCode: "",
|
// counterRateCode: "",
|
||||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
// childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||||
packages: [RoomPackageCodeEnum.PET_ROOM],
|
// packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
adults: 2,
|
// adults: 2,
|
||||||
roomTypeCode: "SKS",
|
// roomTypeCode: "SKS",
|
||||||
rateCode: "",
|
// rateCode: "",
|
||||||
counterRateCode: "",
|
// counterRateCode: "",
|
||||||
childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
// childrenInRoom: [{ bed: ChildBedMapEnum.IN_EXTRA_BED, age: 5 }],
|
||||||
packages: [RoomPackageCodeEnum.PET_ROOM],
|
// packages: [RoomPackageCodeEnum.PET_ROOM],
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const breakfastPackage: BreakfastPackage = {
|
// export const breakfastPackage: BreakfastPackage = {
|
||||||
code: "BRF1",
|
// code: "BRF1",
|
||||||
description: "Breakfast with reservation",
|
// description: "Breakfast with reservation",
|
||||||
localPrice: { currency: "SEK", price: 99, totalPrice: 99 },
|
// localPrice: { currency: "SEK", price: 99, totalPrice: 99 },
|
||||||
requestedPrice: {
|
// requestedPrice: {
|
||||||
currency: "EUR",
|
// currency: "EUR",
|
||||||
price: 9,
|
// price: 9,
|
||||||
totalPrice: 9,
|
// totalPrice: 9,
|
||||||
},
|
// },
|
||||||
packageType: PackageTypeEnum.BreakfastAdult as const,
|
// packageType: PackageTypeEnum.BreakfastAdult as const,
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const roomRate: RoomRate = {
|
// export const roomRate: RoomRate = {
|
||||||
memberRate: {
|
// memberRate: {
|
||||||
rateCode: "PLSA2BEU",
|
// rateCode: "PLSA2BEU",
|
||||||
localPrice: {
|
// localPrice: {
|
||||||
pricePerNight: 1508,
|
// pricePerNight: 1508,
|
||||||
pricePerStay: 1508,
|
// pricePerStay: 1508,
|
||||||
currency: CurrencyEnum.SEK,
|
// currency: CurrencyEnum.SEK,
|
||||||
},
|
// },
|
||||||
requestedPrice: {
|
// requestedPrice: {
|
||||||
pricePerNight: 132,
|
// pricePerNight: 132,
|
||||||
pricePerStay: 132,
|
// pricePerStay: 132,
|
||||||
currency: CurrencyEnum.EUR,
|
// currency: CurrencyEnum.EUR,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
publicRate: {
|
// publicRate: {
|
||||||
rateCode: "SAVEEU",
|
// rateCode: "SAVEEU",
|
||||||
localPrice: {
|
// localPrice: {
|
||||||
pricePerNight: 1525,
|
// pricePerNight: 1525,
|
||||||
pricePerStay: 1525,
|
// pricePerStay: 1525,
|
||||||
currency: CurrencyEnum.SEK,
|
// currency: CurrencyEnum.SEK,
|
||||||
},
|
// },
|
||||||
requestedPrice: {
|
// requestedPrice: {
|
||||||
pricePerNight: 133,
|
// pricePerNight: 133,
|
||||||
pricePerStay: 133,
|
// pricePerStay: 133,
|
||||||
currency: CurrencyEnum.EUR,
|
// currency: CurrencyEnum.EUR,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const roomPrice: RoomPrice = {
|
// export const roomPrice: RoomPrice = {
|
||||||
perNight: {
|
// perNight: {
|
||||||
local: {
|
// local: {
|
||||||
currency: "SEK",
|
// currency: "SEK",
|
||||||
price: 1525,
|
// price: 1525,
|
||||||
},
|
// },
|
||||||
requested: {
|
// requested: {
|
||||||
currency: "EUR",
|
// currency: "EUR",
|
||||||
price: 133,
|
// price: 133,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
perStay: {
|
// perStay: {
|
||||||
local: {
|
// local: {
|
||||||
currency: "SEK",
|
// currency: "SEK",
|
||||||
price: 1525,
|
// price: 1525,
|
||||||
},
|
// },
|
||||||
requested: {
|
// requested: {
|
||||||
currency: "EUR",
|
// currency: "EUR",
|
||||||
price: 133,
|
// price: 133,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const bedType: { [x: string]: BedTypeSelection } = {
|
// export const bedType: { [x: string]: BedTypeSelection } = {
|
||||||
king: {
|
// king: {
|
||||||
type: BedTypeEnum.King,
|
// type: BedTypeEnum.King,
|
||||||
description: "King-size bed",
|
// description: "King-size bed",
|
||||||
value: "SKS",
|
// value: "SKS",
|
||||||
size: {
|
// size: {
|
||||||
min: 180,
|
// min: 180,
|
||||||
max: 200,
|
// max: 200,
|
||||||
},
|
// },
|
||||||
extraBed: undefined,
|
// extraBed: undefined,
|
||||||
},
|
// },
|
||||||
queen: {
|
// queen: {
|
||||||
type: BedTypeEnum.Queen,
|
// type: BedTypeEnum.Queen,
|
||||||
description: "Queen-size bed",
|
// description: "Queen-size bed",
|
||||||
value: "QZ",
|
// value: "QZ",
|
||||||
size: {
|
// size: {
|
||||||
min: 160,
|
// min: 160,
|
||||||
max: 200,
|
// max: 200,
|
||||||
},
|
// },
|
||||||
extraBed: undefined,
|
// extraBed: undefined,
|
||||||
},
|
// },
|
||||||
single: {
|
// single: {
|
||||||
type: BedTypeEnum.Single,
|
// type: BedTypeEnum.Single,
|
||||||
description: "Single bed",
|
// description: "Single bed",
|
||||||
size: {
|
// size: {
|
||||||
max: 140,
|
// max: 140,
|
||||||
min: 100,
|
// min: 100,
|
||||||
},
|
// },
|
||||||
value: "CSR",
|
// value: "CSR",
|
||||||
extraBed: undefined,
|
// extraBed: undefined,
|
||||||
},
|
// },
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const guestDetailsNonMember: DetailsSchema = {
|
// export const guestDetailsNonMember: DetailsSchema = {
|
||||||
join: false,
|
// join: false,
|
||||||
countryCode: "SE",
|
// countryCode: "SE",
|
||||||
email: "tester@testersson.com",
|
// email: "tester@testersson.com",
|
||||||
firstName: "Test",
|
// firstName: "Test",
|
||||||
lastName: "Testersson",
|
// lastName: "Testersson",
|
||||||
phoneNumber: "72727272",
|
// phoneNumber: "72727272",
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const guestDetailsMember: SignedInDetailsSchema = {
|
// export const guestDetailsMember: SignedInDetailsSchema = {
|
||||||
join: false,
|
// join: false,
|
||||||
countryCode: "SE",
|
// countryCode: "SE",
|
||||||
email: "tester@testersson.com",
|
// email: "tester@testersson.com",
|
||||||
firstName: "Test",
|
// firstName: "Test",
|
||||||
lastName: "Testersson",
|
// lastName: "Testersson",
|
||||||
phoneNumber: "72727272",
|
// phoneNumber: "72727272",
|
||||||
zipCode: "12345",
|
// zipCode: "12345",
|
||||||
dateOfBirth: "1999-01-01",
|
// dateOfBirth: "1999-01-01",
|
||||||
membershipNo: "12421412211212",
|
// membershipNo: "12421412211212",
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export default async function DetailsPage({
|
|||||||
// redirect back to select-rate if availability call fails
|
// redirect back to select-rate if availability call fails
|
||||||
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
|
redirect(`${selectRate(lang)}?${selectRoomParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
rooms.push({
|
rooms.push({
|
||||||
bedTypes: roomAvailability.bedTypes,
|
bedTypes: roomAvailability.bedTypes,
|
||||||
breakfastIncluded: roomAvailability.breakfastIncluded,
|
breakfastIncluded: roomAvailability.breakfastIncluded,
|
||||||
@@ -106,13 +107,7 @@ export default async function DetailsPage({
|
|||||||
rateType: roomAvailability.rateType,
|
rateType: roomAvailability.rateType,
|
||||||
roomType: roomAvailability.selectedRoom.roomType,
|
roomType: roomAvailability.selectedRoom.roomType,
|
||||||
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
|
roomTypeCode: roomAvailability.selectedRoom.roomTypeCode,
|
||||||
roomRate: {
|
roomRate: roomAvailability.product,
|
||||||
memberRate: roomAvailability?.memberRate,
|
|
||||||
publicRate: roomAvailability.publicRate,
|
|
||||||
redemptionRate: roomAvailability.redemptionRate,
|
|
||||||
voucherRate: roomAvailability.voucherRate,
|
|
||||||
chequeRate: roomAvailability.chequeRate,
|
|
||||||
},
|
|
||||||
isAvailable:
|
isAvailable:
|
||||||
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
|
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type TrackingSDKHotelInfo,
|
type TrackingSDKHotelInfo,
|
||||||
type TrackingSDKPageData,
|
type TrackingSDKPageData,
|
||||||
} from "@/types/components/tracking"
|
} from "@/types/components/tracking"
|
||||||
|
import { CurrencyEnum } from "@/types/enums/currency"
|
||||||
import type { Hotel } from "@/types/hotel"
|
import type { Hotel } from "@/types/hotel"
|
||||||
import type { Room } from "@/types/providers/details/room"
|
import type { Room } from "@/types/providers/details/room"
|
||||||
import type { Lang } from "@/constants/languages"
|
import type { Lang } from "@/constants/languages"
|
||||||
@@ -68,10 +69,24 @@ export function getTracking(
|
|||||||
noOfRooms: booking.rooms.length,
|
noOfRooms: booking.rooms.length,
|
||||||
rateCode: rooms
|
rateCode: rooms
|
||||||
.map((room, idx) => {
|
.map((room, idx) => {
|
||||||
if (idx === 0 && isMember && room.roomRate.memberRate) {
|
const isMainRoom = idx === 0
|
||||||
return room.roomRate.memberRate?.rateCode
|
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("|"),
|
.join("|"),
|
||||||
rateCodeCancellationRule: rooms
|
rateCodeCancellationRule: rooms
|
||||||
@@ -81,11 +96,20 @@ export function getTracking(
|
|||||||
rateCodeType: rooms.map((room) => room.rateType.toLowerCase()).join(","),
|
rateCodeType: rooms.map((room) => room.rateType.toLowerCase()).join(","),
|
||||||
region: hotel?.address.city,
|
region: hotel?.address.city,
|
||||||
revenueCurrencyCode: rooms
|
revenueCurrencyCode: rooms
|
||||||
.map(
|
.map((room) => {
|
||||||
(room) =>
|
if ("corporateCheque" in room.roomRate) {
|
||||||
room.roomRate.publicRate?.localPrice.currency ??
|
return CurrencyEnum.CC
|
||||||
room.roomRate.memberRate?.localPrice.currency
|
} 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(","),
|
.join(","),
|
||||||
searchTerm: city,
|
searchTerm: city,
|
||||||
searchType: "hotel",
|
searchType: "hotel",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import stringify from "json-stable-stringify-without-jsonify"
|
import stringify from "json-stable-stringify-without-jsonify"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { REDEMPTION } from "@/constants/booking"
|
||||||
|
|
||||||
import SelectRate from "@/components/HotelReservation/SelectRate"
|
import SelectRate from "@/components/HotelReservation/SelectRate"
|
||||||
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
import { HotelInfoCardSkeleton } from "@/components/HotelReservation/SelectRate/HotelInfoCard"
|
||||||
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
|
import { RoomsContainerSkeleton } from "@/components/HotelReservation/SelectRate/RoomsContainer/RoomsContainerSkeleton"
|
||||||
|
import { convertSearchParamsToObj } from "@/utils/url"
|
||||||
|
|
||||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import type { LangParams, PageArgs } from "@/types/params"
|
import type { LangParams, PageArgs } from "@/types/params"
|
||||||
@@ -13,6 +17,17 @@ export default async function SelectRatePage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
}: PageArgs<LangParams & { section: string }, SelectRateSearchParams>) {
|
||||||
const suspenseKey = stringify(searchParams)
|
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 (
|
return (
|
||||||
<Suspense
|
<Suspense
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
||||||
|
|
||||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||||
|
|
||||||
import ButtonLink from "@/components/ButtonLink"
|
import ButtonLink from "@/components/ButtonLink"
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import { DayPicker } from "react-day-picker"
|
import { DayPicker } from "react-day-picker"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons"
|
|
||||||
|
|
||||||
import { Lang } from "@/constants/languages"
|
import { Lang } from "@/constants/languages"
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function Breakfast() {
|
|||||||
ancillary={{
|
ancillary={{
|
||||||
title: intl.formatMessage({ id: "Breakfast buffet" }),
|
title: intl.formatMessage({ id: "Breakfast buffet" }),
|
||||||
price: {
|
price: {
|
||||||
totalPrice: pkg.localPrice.price,
|
total: pkg.localPrice.price,
|
||||||
currency: pkg.localPrice.currency,
|
currency: pkg.localPrice.currency,
|
||||||
included:
|
included:
|
||||||
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST,
|
pkg.code === BreakfastPackageEnum.FREE_MEMBER_BREAKFAST,
|
||||||
@@ -100,7 +100,7 @@ export default function Breakfast() {
|
|||||||
ancillary={{
|
ancillary={{
|
||||||
title: intl.formatMessage({ id: "No breakfast" }),
|
title: intl.formatMessage({ id: "No breakfast" }),
|
||||||
price: {
|
price: {
|
||||||
totalPrice: 0,
|
total: 0,
|
||||||
currency: packages?.[0].localPrice.currency ?? "",
|
currency: packages?.[0].localPrice.currency ?? "",
|
||||||
},
|
},
|
||||||
description: intl.formatMessage({
|
description: intl.formatMessage({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function JoinScandicFriendsCard({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { room, roomNr } = useRoomContext()
|
const { room, roomNr } = useRoomContext()
|
||||||
|
|
||||||
if (!room.roomRate.memberRate) {
|
if (!("member" in room.roomRate) || !room.roomRate.member) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ export default function JoinScandicFriendsCard({
|
|||||||
{
|
{
|
||||||
amount: formatPrice(
|
amount: formatPrice(
|
||||||
intl,
|
intl,
|
||||||
room.roomRate.memberRate.localPrice.pricePerStay ?? 0,
|
room.roomRate.member.localPrice.pricePerStay ?? 0,
|
||||||
room.roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown
|
room.roomRate.member.localPrice.currency ?? CurrencyEnum.Unknown
|
||||||
),
|
),
|
||||||
roomNr,
|
roomNr,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function JoinScandicFriendsCard({
|
|||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
const { room } = useRoomContext()
|
const { room } = useRoomContext()
|
||||||
|
|
||||||
if (!room.roomRate.memberRate) {
|
if (!("member" in room.roomRate) || !room.roomRate.member) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +44,8 @@ export default function JoinScandicFriendsCard({
|
|||||||
{
|
{
|
||||||
amount: formatPrice(
|
amount: formatPrice(
|
||||||
intl,
|
intl,
|
||||||
room.roomRate.memberRate.localPrice.pricePerStay ?? 0,
|
room.roomRate.member.localPrice.pricePerStay ?? 0,
|
||||||
room.roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown
|
room.roomRate.member.localPrice.currency ?? CurrencyEnum.Unknown
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ export default function MemberPriceModal({
|
|||||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||||
}) {
|
}) {
|
||||||
const { room } = useRoomContext()
|
const { room } = useRoomContext()
|
||||||
const memberRate = room.roomRate.memberRate
|
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
|
|
||||||
|
if (!memberRate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
const memberPrice = memberRate?.localPrice ?? memberRate?.requestedPrice
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function Details({ user }: DetailsProps) {
|
|||||||
roomNr,
|
roomNr,
|
||||||
} = useRoomContext()
|
} = useRoomContext()
|
||||||
const initialData = room.guest
|
const initialData = room.guest
|
||||||
const memberRate = room.roomRate.memberRate
|
const memberRate = "member" in room.roomRate ? room.roomRate.member : null
|
||||||
|
|
||||||
const isPaymentNext = activeRoom === lastRoom
|
const isPaymentNext = activeRoom === lastRoom
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import type {
|
|||||||
PriceChangeData,
|
PriceChangeData,
|
||||||
} from "@/types/components/hotelReservation/enterDetails/payment"
|
} from "@/types/components/hotelReservation/enterDetails/payment"
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||||
|
|
||||||
const maxRetries = 15
|
const maxRetries = 15
|
||||||
const retryInterval = 2000
|
const retryInterval = 2000
|
||||||
@@ -261,7 +262,6 @@ export default function PaymentClient({
|
|||||||
|
|
||||||
const shouldUsePayment =
|
const shouldUsePayment =
|
||||||
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
|
guarantee || bookingMustBeGuaranteed || !hasOnlyFlexRates
|
||||||
|
|
||||||
const payment = shouldUsePayment
|
const payment = shouldUsePayment
|
||||||
? {
|
? {
|
||||||
paymentMethod: paymentMethod,
|
paymentMethod: paymentMethod,
|
||||||
@@ -271,6 +271,7 @@ export default function PaymentClient({
|
|||||||
cancel: `${paymentRedirectUrl}/cancel`,
|
cancel: `${paymentRedirectUrl}/cancel`,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
trackPaymentEvent({
|
trackPaymentEvent({
|
||||||
event: "paymentAttemptStart",
|
event: "paymentAttemptStart",
|
||||||
hotelId,
|
hotelId,
|
||||||
@@ -286,66 +287,81 @@ export default function PaymentClient({
|
|||||||
hotelId,
|
hotelId,
|
||||||
language: lang,
|
language: lang,
|
||||||
payment,
|
payment,
|
||||||
rooms: rooms.map(({ room }, idx) => ({
|
rooms: rooms.map(({ room }, idx) => {
|
||||||
adults: room.adults,
|
let bookingCode = undefined
|
||||||
childrenAges: room.childrenInRoom?.map((child) => ({
|
if (
|
||||||
age: child.age,
|
room.roomRate.rateDefinition &&
|
||||||
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
room.roomRate.rateDefinition.rateType !== RateTypeEnum.Regular
|
||||||
})),
|
) {
|
||||||
guest: {
|
bookingCode = room.roomRate.rateDefinition.rateCode
|
||||||
becomeMember: room.guest.join,
|
}
|
||||||
countryCode: room.guest.countryCode,
|
return {
|
||||||
email: room.guest.email,
|
adults: room.adults,
|
||||||
firstName: room.guest.firstName,
|
bookingCode,
|
||||||
lastName: room.guest.lastName,
|
childrenAges: room.childrenInRoom?.map((child) => ({
|
||||||
membershipNumber: room.guest.membershipNo,
|
age: child.age,
|
||||||
phoneNumber: room.guest.phoneNumber,
|
bedType: bedTypeMap[parseInt(child.bed.toString())],
|
||||||
// Only allowed for room one
|
})),
|
||||||
...(idx === 0 && {
|
guest: {
|
||||||
dateOfBirth:
|
becomeMember: room.guest.join,
|
||||||
"dateOfBirth" in room.guest && room.guest.dateOfBirth
|
countryCode: room.guest.countryCode,
|
||||||
? room.guest.dateOfBirth
|
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,
|
: undefined,
|
||||||
postalCode:
|
publicPrice:
|
||||||
"zipCode" in room.guest && room.guest.zipCode
|
"public" in room.roomRate
|
||||||
? room.guest.zipCode
|
? room.roomRate.public?.localPrice.pricePerStay
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
},
|
||||||
},
|
roomTypeCode: room.bedType!.roomTypeCode, // A selection has been made in order to get to this step.
|
||||||
packages: {
|
smsConfirmationRequested: data.smsConfirmation,
|
||||||
accessibility:
|
specialRequest: {
|
||||||
room.roomFeatures?.some(
|
comment: room.specialRequest.comment
|
||||||
(feature) =>
|
? room.specialRequest.comment
|
||||||
feature.code === RoomPackageCodeEnum.ACCESSIBILITY_ROOM
|
: undefined,
|
||||||
) ?? 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,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export function calculateTotalRoomPrice(
|
|||||||
let comparisonPrice = totalPrice
|
let comparisonPrice = totalPrice
|
||||||
|
|
||||||
const isMember = room.guest.join || room.guest.membershipNo
|
const isMember = room.guest.join || room.guest.membershipNo
|
||||||
if (isMember) {
|
if (isMember && "member" in room.roomRate) {
|
||||||
const publicPrice = room.roomRate.publicRate?.localPrice.pricePerStay ?? 0
|
const publicPrice = room.roomRate.public?.localPrice.pricePerStay ?? 0
|
||||||
const memberPrice = room.roomRate.memberRate?.localPrice.pricePerStay ?? 0
|
const memberPrice = room.roomRate.member?.localPrice.pricePerStay ?? 0
|
||||||
const diff = publicPrice - memberPrice
|
const diff = publicPrice - memberPrice
|
||||||
comparisonPrice = totalPrice + diff
|
comparisonPrice = totalPrice + diff
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,16 +111,28 @@ export default function PriceDetailsTable({
|
|||||||
return (
|
return (
|
||||||
<table className={styles.priceDetailsTable}>
|
<table className={styles.priceDetailsTable}>
|
||||||
{rooms.map((room, idx) => {
|
{rooms.map((room, idx) => {
|
||||||
|
const isMainRoom = idx === 0
|
||||||
const getMemberRate =
|
const getMemberRate =
|
||||||
room.guest?.join ||
|
room.guest?.join ||
|
||||||
room.guest?.membershipNo ||
|
room.guest?.membershipNo ||
|
||||||
(idx === 0 && isMember)
|
(isMainRoom && isMember)
|
||||||
const price =
|
|
||||||
getMemberRate && room.roomRate.memberRate
|
let price
|
||||||
? room.roomRate.memberRate
|
if (
|
||||||
: room.roomRate.publicRate
|
getMemberRate &&
|
||||||
const voucherPrice = room.roomRate.voucherRate
|
"member" in room.roomRate &&
|
||||||
const chequePrice = room.roomRate.chequeRate
|
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) {
|
if (!price) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -192,10 +204,10 @@ export default function PriceDetailsTable({
|
|||||||
label={intl.formatMessage({ id: "Room charge" })}
|
label={intl.formatMessage({ id: "Room charge" })}
|
||||||
value={formatPrice(
|
value={formatPrice(
|
||||||
intl,
|
intl,
|
||||||
chequePrice.localPrice.numberOfBonusCheques,
|
chequePrice.localPrice.numberOfCheques,
|
||||||
CurrencyEnum.CC,
|
CurrencyEnum.CC,
|
||||||
chequePrice.localPrice.additionalPricePerStay,
|
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 { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
import type { EnterDetailsSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||||
import { CurrencyEnum } from "@/types/enums/currency"
|
|
||||||
|
|
||||||
export default function SummaryUI({
|
export default function SummaryUI({
|
||||||
booking,
|
booking,
|
||||||
@@ -55,14 +54,15 @@ export default function SummaryUI({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMemberPrice(roomRate: RoomRate) {
|
function getMemberPrice(roomRate: RoomRate) {
|
||||||
return roomRate?.memberRate
|
if ("member" in roomRate && roomRate.member) {
|
||||||
? {
|
return {
|
||||||
currency:
|
amount: roomRate.member.localPrice.pricePerStay,
|
||||||
roomRate.memberRate.localPrice.currency ?? CurrencyEnum.Unknown,
|
currency: roomRate.member.localPrice.currency,
|
||||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight,
|
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||||
amount: roomRate.memberRate.localPrice.pricePerStay ?? 0,
|
}
|
||||||
}
|
}
|
||||||
: null
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomOneGuest = rooms[0].room.guest
|
const roomOneGuest = rooms[0].room.guest
|
||||||
@@ -74,11 +74,12 @@ export default function SummaryUI({
|
|||||||
|
|
||||||
const roomOneMemberPrice = getMemberPrice(rooms[0].room.roomRate)
|
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
|
// In case of Redemption, voucher and Corporate cheque do not show approx price
|
||||||
const isSpecialRate =
|
const isSpecialRate =
|
||||||
rooms[0].room.roomRate.chequeRate ||
|
"corporateCheque" in roomOneRoomRate ||
|
||||||
rooms[0].room.roomRate.redemptionRate ||
|
"redemption" in roomOneRoomRate ||
|
||||||
rooms[0].room.roomRate.voucherRate
|
"voucher" in roomOneRoomRate
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.summary}>
|
<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>
|
<Caption>{intl.formatMessage({ id: "From" })}</Caption>
|
||||||
<div className={styles.cheque}>
|
<div className={styles.cheque}>
|
||||||
<Subtitle type="two" color="uiTextHighContrast">
|
<Subtitle type="two" color="uiTextHighContrast">
|
||||||
{productTypeCheque.localPrice.numberOfBonusCheques}
|
{productTypeCheque.localPrice.numberOfCheques}
|
||||||
</Subtitle>
|
</Subtitle>
|
||||||
<Caption color="uiTextHighContrast" className={styles.currency}>
|
<Caption color="uiTextHighContrast" className={styles.currency}>
|
||||||
{CurrencyEnum.CC}
|
{CurrencyEnum.CC}
|
||||||
@@ -44,8 +44,7 @@ export default function HotelChequeCard({
|
|||||||
{intl.formatMessage({ id: "Approx." })}
|
{intl.formatMessage({ id: "Approx." })}
|
||||||
</Caption>
|
</Caption>
|
||||||
<Caption color={"uiTextMediumContrast"}>
|
<Caption color={"uiTextMediumContrast"}>
|
||||||
{productTypeCheque.requestedPrice.numberOfBonusCheques}{" "}
|
{productTypeCheque.requestedPrice.numberOfCheques} {CurrencyEnum.CC}
|
||||||
{CurrencyEnum.CC}
|
|
||||||
{productTypeCheque.requestedPrice.additionalPricePerStay
|
{productTypeCheque.requestedPrice.additionalPricePerStay
|
||||||
? " + "
|
? " + "
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ function HotelCard({
|
|||||||
{price?.bonusCheque && (
|
{price?.bonusCheque && (
|
||||||
<HotelChequeCard productTypeCheque={price.bonusCheque} />
|
<HotelChequeCard productTypeCheque={price.bonusCheque} />
|
||||||
)}
|
)}
|
||||||
{!!price?.redemptions?.length && (
|
{price?.redemptions?.length ? (
|
||||||
<div className={styles.pointsCard}>
|
<div className={styles.pointsCard}>
|
||||||
<Caption>
|
<Caption>
|
||||||
{intl.formatMessage({ id: "Available rates" })}
|
{intl.formatMessage({ id: "Available rates" })}
|
||||||
@@ -201,12 +201,12 @@ function HotelCard({
|
|||||||
redemption.localPrice.additionalPricePerStay
|
redemption.localPrice.additionalPricePerStay
|
||||||
}
|
}
|
||||||
additionalPriceCurrency={
|
additionalPriceCurrency={
|
||||||
redemption.localPrice.additionalPriceCurrency
|
redemption.localPrice.currency ?? undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
theme="base"
|
theme="base"
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ export default function PriceDetails({
|
|||||||
|
|
||||||
const totalPrice = isBreakfast
|
const totalPrice = isBreakfast
|
||||||
? breakfastData!.priceAdult * breakfastData!.nrOfAdults +
|
? breakfastData!.priceAdult * breakfastData!.nrOfAdults +
|
||||||
breakfastData!.priceChild * breakfastData!.nrOfPayingChildren
|
breakfastData!.priceChild * breakfastData!.nrOfPayingChildren
|
||||||
: quantityWithCard && selectedAncillary
|
: quantityWithCard && selectedAncillary
|
||||||
? selectedAncillary.price.totalPrice * quantityWithCard
|
? selectedAncillary.price.total * quantityWithCard
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const totalPoints =
|
const totalPoints =
|
||||||
@@ -101,15 +101,15 @@ export default function PriceDetails({
|
|||||||
const items = isBreakfast
|
const items = isBreakfast
|
||||||
? getBreakfastItems(selectedAncillary, breakfastData)
|
? getBreakfastItems(selectedAncillary, breakfastData)
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
title: selectedAncillary.title,
|
title: selectedAncillary.title,
|
||||||
totalPrice: selectedAncillary.price.totalPrice,
|
totalPrice: selectedAncillary.price.total,
|
||||||
currency: selectedAncillary.price.currency,
|
currency: selectedAncillary.price.currency,
|
||||||
points: selectedAncillary.points,
|
points: selectedAncillary.points,
|
||||||
quantityWithCard,
|
quantityWithCard,
|
||||||
quantityWithPoints,
|
quantityWithPoints,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect } from "react"
|
|
||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
import { useIntl } from "react-intl"
|
import { useIntl } from "react-intl"
|
||||||
|
|
||||||
@@ -117,7 +116,7 @@ function BreakfastInfo() {
|
|||||||
<Alert
|
<Alert
|
||||||
type={AlertTypeEnum.Info}
|
type={AlertTypeEnum.Info}
|
||||||
text={intl.formatMessage({
|
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 ||
|
{(breakfastData.nrOfPayingChildren > 0 ||
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ export default function AddAncillaryFlowModal({
|
|||||||
if (booking.confirmationNumber) {
|
if (booking.confirmationNumber) {
|
||||||
const card = savedCreditCard
|
const card = savedCreditCard
|
||||||
? {
|
? {
|
||||||
alias: savedCreditCard.alias,
|
alias: savedCreditCard.alias,
|
||||||
expiryDate: savedCreditCard.expirationDate,
|
expiryDate: savedCreditCard.expirationDate,
|
||||||
cardType: savedCreditCard.cardType,
|
cardType: savedCreditCard.cardType,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
guaranteeBooking.mutate({
|
guaranteeBooking.mutate({
|
||||||
confirmationNumber: booking.confirmationNumber,
|
confirmationNumber: booking.confirmationNumber,
|
||||||
@@ -313,7 +313,7 @@ export default function AddAncillaryFlowModal({
|
|||||||
) : (
|
) : (
|
||||||
formatPrice(
|
formatPrice(
|
||||||
intl,
|
intl,
|
||||||
selectedAncillary.price.totalPrice,
|
selectedAncillary.price.total,
|
||||||
selectedAncillary.price.currency
|
selectedAncillary.price.currency
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ export function Ancillaries({
|
|||||||
description: intl.formatMessage({ id: "Buffet" }),
|
description: intl.formatMessage({ id: "Buffet" }),
|
||||||
id: breakfastPackage.code,
|
id: breakfastPackage.code,
|
||||||
title: intl.formatMessage({ id: "Breakfast" }),
|
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
|
// TODO: Change this to the correct URL, whatever that is
|
||||||
imageUrl:
|
imageUrl:
|
||||||
"https://images-test.scandichotels.com/publishedmedia/hcf9hchiad7zrvlkc2pt/Breakfast_-_Scandic_Sweden_-_Free_to_use.jpg",
|
"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 }
|
return { success: false, noAvailability: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const roomPrice = isLoggedIn
|
let roomPrice = 0
|
||||||
? data.memberRate?.localPrice.pricePerStay
|
if (isLoggedIn && "member" in data.product && data.product.member) {
|
||||||
: data.publicRate?.localPrice.pricePerStay
|
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)
|
availabilityResults.push(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error checking room availability:", error)
|
console.error("Error checking room availability:", error)
|
||||||
|
|||||||
@@ -97,11 +97,19 @@ export default function PriceDetailsTable({
|
|||||||
return (
|
return (
|
||||||
<table className={styles.priceDetailsTable}>
|
<table className={styles.priceDetailsTable}>
|
||||||
{rooms.map((room, idx) => {
|
{rooms.map((room, idx) => {
|
||||||
const getMemberRate = idx === 0 && isMember
|
const isMainRoom = idx === 0
|
||||||
const price =
|
const getMemberRate = isMainRoom && isMember
|
||||||
getMemberRate && room.roomRate.memberRate
|
|
||||||
? room.roomRate.memberRate
|
let price
|
||||||
: room.roomRate.publicRate
|
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) {
|
if (!price) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatPriceWithAdditionalPrice,
|
formatPriceWithAdditionalPrice,
|
||||||
} from "@/utils/numberFormatting"
|
} from "@/utils/numberFormatting"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||||
import PriceDetailsTable from "./PriceDetailsTable"
|
import PriceDetailsTable from "./PriceDetailsTable"
|
||||||
|
|
||||||
import styles from "./summary.module.css"
|
import styles from "./summary.module.css"
|
||||||
@@ -27,7 +28,6 @@ import styles from "./summary.module.css"
|
|||||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||||
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
import type { SelectRateSummaryProps } from "@/types/components/hotelReservation/summary"
|
||||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
|
||||||
|
|
||||||
export default function Summary({
|
export default function Summary({
|
||||||
booking,
|
booking,
|
||||||
@@ -48,19 +48,21 @@ export default function Summary({
|
|||||||
)
|
)
|
||||||
|
|
||||||
function getMemberPrice(roomRate: RoomRate) {
|
function getMemberPrice(roomRate: RoomRate) {
|
||||||
return roomRate?.memberRate
|
if ("member" in roomRate && roomRate.member) {
|
||||||
? {
|
return {
|
||||||
currency: roomRate.memberRate.localPrice.currency ?? "",
|
amount: roomRate.member.localPrice.pricePerStay,
|
||||||
pricePerNight: roomRate.memberRate.localPrice.pricePerNight ?? 0,
|
currency: roomRate.member.localPrice.currency,
|
||||||
amount: roomRate.memberRate.localPrice.pricePerStay ?? 0,
|
pricePerNight: roomRate.member.localPrice.pricePerNight,
|
||||||
}
|
}
|
||||||
: null
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||||
|
|
||||||
const containsBookingCodeRate = rooms.find(
|
const containsBookingCodeRate = rooms.find((r) =>
|
||||||
(room) => room.roomRate.publicRate?.rateType !== RateTypeEnum.Regular
|
isBookingCodeRate(r.roomRate)
|
||||||
)
|
)
|
||||||
const showDiscounted = containsBookingCodeRate || isMember
|
const showDiscounted = containsBookingCodeRate || isMember
|
||||||
|
|
||||||
@@ -119,9 +121,8 @@ export default function Summary({
|
|||||||
|
|
||||||
const memberPrice = getMemberPrice(room.roomRate)
|
const memberPrice = getMemberPrice(room.roomRate)
|
||||||
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
const showMemberPrice = !!(isMember && memberPrice && roomNumber === 1)
|
||||||
const isBookingCodeRate =
|
const showDiscounted =
|
||||||
room.roomRate.publicRate?.rateType !== RateTypeEnum.Regular
|
isBookingCodeRate(room.roomRate) || showMemberPrice
|
||||||
const showDiscounted = isBookingCodeRate || showMemberPrice
|
|
||||||
|
|
||||||
const adultsMsg = intl.formatMessage(
|
const adultsMsg = intl.formatMessage(
|
||||||
{ id: "{totalAdults, plural, one {# adult} other {# adults}}" },
|
{ 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 Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||||
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
|
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
|
||||||
|
|
||||||
|
import { isBookingCodeRate } from "./isBookingCodeRate"
|
||||||
|
import { mapRate } from "./mapRate"
|
||||||
import Summary from "./Summary"
|
import Summary from "./Summary"
|
||||||
|
|
||||||
import styles from "./mobileSummary.module.css"
|
import styles from "./mobileSummary.module.css"
|
||||||
|
|
||||||
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
import type { MobileSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||||
import { RateTypeEnum } from "@/types/enums/rateType"
|
|
||||||
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
import type { RoomsAvailability } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
export default function MobileSummary({
|
export default function MobileSummary({
|
||||||
@@ -69,54 +70,12 @@ export default function MobileSummary({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const rateDefinitions = roomRateDefinitions.rateDefinitions
|
const rooms = rateSummary.map((room, index) =>
|
||||||
|
mapRate(room, index, bookingRooms)
|
||||||
|
)
|
||||||
|
|
||||||
const rooms = rateSummary.map((room, index) => ({
|
const containsBookingCodeRate = rateSummary.find((r) =>
|
||||||
adults: bookingRooms[index].adults,
|
isBookingCodeRate(r.product)
|
||||||
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 showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
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 MobileSummary from "./MobileSummary"
|
||||||
import {
|
import {
|
||||||
calculateChequePrice,
|
calculateCorporateChequePrice,
|
||||||
|
calculateRedemptionTotalPrice,
|
||||||
calculateTotalPrice,
|
calculateTotalPrice,
|
||||||
calculateVoucherPrice,
|
calculateVoucherPrice,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
|
|
||||||
import styles from "./rateSummary.module.css"
|
import styles from "./rateSummary.module.css"
|
||||||
|
|
||||||
import {
|
import type { Price } from "@/types/components/hotelReservation/price"
|
||||||
PointsPriceSchema,
|
|
||||||
type Price,
|
|
||||||
} from "@/types/components/hotelReservation/price"
|
|
||||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
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) {
|
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||||
const {
|
const {
|
||||||
bookingCode,
|
bookingCode,
|
||||||
isRedemption,
|
|
||||||
bookingRooms,
|
bookingRooms,
|
||||||
dates,
|
dates,
|
||||||
petRoomPackage,
|
petRoomPackage,
|
||||||
@@ -105,7 +102,9 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
|||||||
|
|
||||||
const totalRoomsRequired = bookingRooms.length
|
const totalRoomsRequired = bookingRooms.length
|
||||||
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
|
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 showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||||
|
|
||||||
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
const freeCancelation = intl.formatMessage({ id: "Free cancellation" })
|
||||||
@@ -139,21 +138,31 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isBookingCodeRate = rateSummary.some(
|
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 =
|
const showDiscounted =
|
||||||
isUserLoggedIn || isBookingCodeRate || isVoucherRate || isChequeRate
|
isUserLoggedIn ||
|
||||||
|
isBookingCodeRate ||
|
||||||
|
isVoucherRate ||
|
||||||
|
isCorporateChequeRate
|
||||||
|
|
||||||
|
const mainRoomProduct = rateSummary[0]
|
||||||
let totalPriceToShow: Price
|
let totalPriceToShow: Price
|
||||||
if (isVoucherRate) {
|
if ("redemption" in mainRoomProduct.product) {
|
||||||
totalPriceToShow = calculateVoucherPrice(rateSummary)
|
|
||||||
} else if (isChequeRate) {
|
|
||||||
totalPriceToShow = calculateChequePrice(rateSummary)
|
|
||||||
} else if (rateSummary[0].redemption) {
|
|
||||||
// In case of reward night (redemption) only single room booking is supported by business rules
|
// 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 {
|
} else {
|
||||||
totalPriceToShow = calculateTotalPrice(
|
totalPriceToShow = calculateTotalPrice(
|
||||||
rateSummary,
|
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 (
|
return (
|
||||||
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
||||||
<div className={styles.summary}>
|
<div className={styles.summary}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.summaryText}>
|
<div className={styles.summaryText}>
|
||||||
{rateSummary.map((room, index) => {
|
{rateSummary.map((room, index) => (
|
||||||
return (
|
<div key={index} className={styles.roomSummary}>
|
||||||
<div key={index} className={styles.roomSummary}>
|
{rateSummary.length > 1 ? (
|
||||||
{rateSummary.length > 1 ? (
|
<>
|
||||||
<>
|
<Subtitle color="uiTextHighContrast">
|
||||||
<Subtitle color="uiTextHighContrast">
|
{intl.formatMessage(
|
||||||
{intl.formatMessage(
|
{ id: "Room {roomIndex}" },
|
||||||
{ id: "Room {roomIndex}" },
|
{ roomIndex: index + 1 }
|
||||||
{ roomIndex: index + 1 }
|
)}
|
||||||
)}
|
</Subtitle>
|
||||||
</Subtitle>
|
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
<Caption color="uiTextMediumContrast">
|
||||||
<Caption color="uiTextMediumContrast">
|
{getRateDetails(room.rate)}
|
||||||
{getRateDetails(room.rate)}
|
</Caption>
|
||||||
</Caption>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Subtitle color="uiTextHighContrast">
|
||||||
<Subtitle color="uiTextHighContrast">
|
{room.roomType}
|
||||||
{room.roomType}
|
</Subtitle>
|
||||||
</Subtitle>
|
<Body color="uiTextMediumContrast">
|
||||||
<Body color="uiTextMediumContrast">
|
{getRateDetails(room.rate)}
|
||||||
{getRateDetails(room.rate)}
|
</Body>
|
||||||
</Body>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
))}
|
||||||
)
|
|
||||||
})}
|
|
||||||
{/* Render unselected rooms */}
|
{/* Render unselected rooms */}
|
||||||
{Array.from({
|
{Array.from({
|
||||||
length: totalRoomsRequired - rateSummary.length,
|
length: totalRoomsRequired - rateSummary.length,
|
||||||
@@ -218,28 +240,34 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
|||||||
<div className={styles.promoContainer}>
|
<div className={styles.promoContainer}>
|
||||||
<SignupPromoDesktop
|
<SignupPromoDesktop
|
||||||
memberPrice={{
|
memberPrice={{
|
||||||
amount: rateSummary.reduce((total, room) => {
|
amount: rateSummary.reduce(
|
||||||
const memberPrice = room.member?.localPrice.pricePerStay
|
(total, { features, package: roomPackage, product }) => {
|
||||||
if (!memberPrice) {
|
if (!("member" in product) || !product.member) {
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
const hasSelectedPetRoom =
|
const memberPrice =
|
||||||
room.package === RoomPackageCodeEnum.PET_ROOM
|
product.member.localPrice.pricePerStay
|
||||||
if (!hasSelectedPetRoom) {
|
if (!memberPrice) {
|
||||||
return total + memberPrice
|
return total
|
||||||
}
|
}
|
||||||
const isPetRoom = room.features.find(
|
const hasSelectedPetRoom =
|
||||||
(feature) =>
|
roomPackage === RoomPackageCodeEnum.PET_ROOM
|
||||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
if (!hasSelectedPetRoom) {
|
||||||
)
|
return total + memberPrice
|
||||||
const petRoomPrice =
|
}
|
||||||
isPetRoom && petRoomPackage
|
const isPetRoom = features.find(
|
||||||
? Number(petRoomPackage.localPrice.totalPrice)
|
(feature) =>
|
||||||
: 0
|
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
return total + memberPrice + petRoomPrice
|
)
|
||||||
}, 0),
|
const petRoomPrice =
|
||||||
currency: (rateSummary[0].member?.localPrice.currency ??
|
isPetRoom && petRoomPackage
|
||||||
rateSummary[0].public?.localPrice.currency)!,
|
? Number(petRoomPackage.localPrice.totalPrice)
|
||||||
|
: 0
|
||||||
|
return total + memberPrice + petRoomPrice
|
||||||
|
},
|
||||||
|
0
|
||||||
|
),
|
||||||
|
currency: mainRoomCurrency,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ import {
|
|||||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
import { CurrencyEnum } from "@/types/enums/currency"
|
import { CurrencyEnum } from "@/types/enums/currency"
|
||||||
|
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
export const calculateTotalPrice = (
|
export function calculateTotalPrice(
|
||||||
selectedRateSummary: Rate[],
|
selectedRateSummary: Rate[],
|
||||||
isUserLoggedIn: boolean,
|
isUserLoggedIn: boolean,
|
||||||
petRoomPackage: RoomPackage | undefined
|
petRoomPackage: RoomPackage | undefined
|
||||||
) => {
|
) {
|
||||||
return selectedRateSummary.reduce<Price>(
|
return selectedRateSummary.reduce<Price>(
|
||||||
(total, room, idx) => {
|
(total, room, idx) => {
|
||||||
const rate =
|
if (!("member" in room.product) || !("public" in room.product)) {
|
||||||
isUserLoggedIn && room.member && idx + 1 === 1
|
return total
|
||||||
? room.member
|
}
|
||||||
: room.public
|
|
||||||
|
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) {
|
if (!rate) {
|
||||||
return total
|
return total
|
||||||
@@ -25,7 +34,6 @@ export const calculateTotalPrice = (
|
|||||||
const isPetRoom = room.features.find(
|
const isPetRoom = room.features.find(
|
||||||
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
(feature) => feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||||
)
|
)
|
||||||
|
|
||||||
let petRoomPrice = 0
|
let petRoomPrice = 0
|
||||||
if (
|
if (
|
||||||
petRoomPackage &&
|
petRoomPackage &&
|
||||||
@@ -35,33 +43,47 @@ export const calculateTotalPrice = (
|
|||||||
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
petRoomPrice = Number(petRoomPackage.localPrice.totalPrice)
|
||||||
}
|
}
|
||||||
|
|
||||||
const regularPrice = rate.localPrice.regularPricePerStay
|
total.local.currency = rate.localPrice.currency
|
||||||
? (total.local.regularPrice || 0) +
|
total.local.price =
|
||||||
(rate.localPrice.regularPricePerStay || 0)
|
total.local.price + rate.localPrice.pricePerStay + petRoomPrice
|
||||||
: undefined
|
|
||||||
|
|
||||||
return {
|
if (rate.localPrice.regularPricePerStay) {
|
||||||
local: {
|
total.local.regularPrice =
|
||||||
currency: rate.localPrice.currency,
|
(total.local.regularPrice || 0) +
|
||||||
price:
|
rate.localPrice.regularPricePerStay +
|
||||||
total.local.price + rate.localPrice.pricePerStay + petRoomPrice,
|
petRoomPrice
|
||||||
regularPrice,
|
|
||||||
},
|
|
||||||
requested: rate.requestedPrice
|
|
||||||
? {
|
|
||||||
currency: rate.requestedPrice.currency,
|
|
||||||
price:
|
|
||||||
(total.requested?.price ?? 0) +
|
|
||||||
rate.requestedPrice.pricePerStay +
|
|
||||||
petRoomPrice,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: {
|
local: {
|
||||||
currency: (selectedRateSummary[0].public?.localPrice.currency ||
|
currency: "",
|
||||||
selectedRateSummary[0].member?.localPrice.currency)!,
|
|
||||||
price: 0,
|
price: 0,
|
||||||
regularPrice: undefined,
|
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>(
|
return selectedRateSummary.reduce<Price>(
|
||||||
(total, room) => {
|
(total, room) => {
|
||||||
const rate = room.voucher
|
if (!("voucher" in room.product)) {
|
||||||
if (!rate) {
|
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
const rate = room.product.voucher
|
||||||
|
|
||||||
return <Price>{
|
return {
|
||||||
local: {
|
local: {
|
||||||
currency: total.local.currency,
|
currency: total.local.currency,
|
||||||
price: total.local.price + rate.numberOfVouchers,
|
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>(
|
return selectedRateSummary.reduce<Price>(
|
||||||
(total, room) => {
|
(total, room) => {
|
||||||
const rate = room.bonusCheque
|
if (!("corporateCheque" in room.product)) {
|
||||||
if (!rate) {
|
|
||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
const rate = room.product.corporateCheque
|
||||||
|
|
||||||
const price = total.local.price + rate.localPrice.numberOfBonusCheques
|
total.local.price = total.local.price + rate.localPrice.numberOfCheques
|
||||||
|
if (rate.localPrice.additionalPricePerStay) {
|
||||||
const additionalPrice =
|
total.local.additionalPrice =
|
||||||
rate.localPrice.numberOfBonusCheques &&
|
(total.local.additionalPrice || 0) +
|
||||||
(total.local.additionalPrice ?? 0) +
|
rate.localPrice.additionalPricePerStay
|
||||||
(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,
|
|
||||||
}
|
}
|
||||||
|
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: {
|
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 styles from "./selectedRoomPanel.module.css"
|
||||||
|
|
||||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||||
|
import { CurrencyEnum } from "@/types/enums/currency"
|
||||||
|
|
||||||
export default function SelectedRoomPanel() {
|
export default function SelectedRoomPanel() {
|
||||||
const intl = useIntl()
|
const intl = useIntl()
|
||||||
@@ -58,10 +59,35 @@ export default function SelectedRoomPanel() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedProduct =
|
let selectedProduct
|
||||||
isUserLoggedIn && isMainRoom && selectedRate.product?.member
|
let isPerNight = true
|
||||||
? selectedRate.product?.member
|
if (
|
||||||
: selectedRate.product?.public
|
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 (
|
return (
|
||||||
<div className={styles.selectedRoomPanel}>
|
<div className={styles.selectedRoomPanel}>
|
||||||
@@ -79,9 +105,7 @@ export default function SelectedRoomPanel() {
|
|||||||
{getRateTitle(selectedRate.product.rate)}
|
{getRateTitle(selectedRate.product.rate)}
|
||||||
</Body>
|
</Body>
|
||||||
<Body color="uiTextHighContrast">
|
<Body color="uiTextHighContrast">
|
||||||
{selectedProduct?.localPrice.pricePerNight}{" "}
|
{`${selectedProduct}${isPerNight ? "/" + intl.formatMessage({ id: "night" }) : ""}`}
|
||||||
{selectedProduct?.localPrice.currency}/
|
|
||||||
{intl.formatMessage({ id: "night" })}
|
|
||||||
</Body>
|
</Body>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.imageContainer}>
|
<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() {
|
export default function RoomTypeFilter() {
|
||||||
const filterOptions = useRatesStore((state) => state.filterOptions)
|
const filterOptions = useRatesStore((state) => state.filterOptions)
|
||||||
const {
|
const {
|
||||||
actions: { selectFilter },
|
actions: { selectPackage },
|
||||||
rooms,
|
rooms,
|
||||||
selectedPackage,
|
selectedPackage,
|
||||||
totalRooms,
|
totalRooms,
|
||||||
@@ -37,9 +37,9 @@ export default function RoomTypeFilter() {
|
|||||||
function handleChange(selectedFilter: Set<Key>) {
|
function handleChange(selectedFilter: Set<Key>) {
|
||||||
if (selectedFilter.size) {
|
if (selectedFilter.size) {
|
||||||
const selected = selectedFilter.values().next()
|
const selected = selectedFilter.values().next()
|
||||||
selectFilter(selected.value as RoomPackageCodeEnum)
|
selectPackage(selected.value as RoomPackageCodeEnum)
|
||||||
} else {
|
} else {
|
||||||
selectFilter(undefined)
|
selectPackage(undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@ import { useIntl } from "react-intl"
|
|||||||
|
|
||||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
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) {
|
export default function RoomSize({ roomSize }: RoomSizeProps) {
|
||||||
const intl = useIntl()
|
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 { 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: {
|
variants: {
|
||||||
availability: {
|
availability: {
|
||||||
noAvailability: styles.noAvailability,
|
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));
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.roomList > li {
|
.roomList>li {
|
||||||
width: 100%;
|
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 RoomProvider from "@/providers/SelectRate/RoomProvider"
|
||||||
import { trackLowestRoomPrice } from "@/utils/tracking"
|
import { trackLowestRoomPrice } from "@/utils/tracking"
|
||||||
|
|
||||||
|
import BookingCodeFilter from "./BookingCodeFilter"
|
||||||
import MultiRoomWrapper from "./MultiRoomWrapper"
|
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 styles from "./rooms.module.css"
|
||||||
|
|
||||||
|
import type { PriceProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||||
|
|
||||||
export default function Rooms() {
|
export default function Rooms() {
|
||||||
const {
|
const {
|
||||||
arrivalDate,
|
arrivalDate,
|
||||||
@@ -32,7 +37,13 @@ export default function Rooms() {
|
|||||||
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
|
const pricesWithCurrencies = visibleRooms.flatMap((roomConfiguration) =>
|
||||||
roomConfiguration.flatMap((room) =>
|
roomConfiguration.flatMap((room) =>
|
||||||
room.products
|
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) => ({
|
.map((product) => ({
|
||||||
currency: (product.public?.localPrice.currency ||
|
currency: (product.public?.localPrice.currency ||
|
||||||
product.member?.localPrice.currency)!,
|
product.member?.localPrice.currency)!,
|
||||||
@@ -66,7 +77,10 @@ export default function Rooms() {
|
|||||||
room={rooms[idx]}
|
room={rooms[idx]}
|
||||||
>
|
>
|
||||||
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
<MultiRoomWrapper isMultiRoom={bookingRooms.length > 1}>
|
||||||
<RoomSelectionPanel />
|
<NoAvailabilityAlert />
|
||||||
|
<RoomTypeFilter />
|
||||||
|
<BookingCodeFilter />
|
||||||
|
<RoomsList />
|
||||||
</MultiRoomWrapper>
|
</MultiRoomWrapper>
|
||||||
</RoomProvider>
|
</RoomProvider>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -14,25 +14,16 @@ export function useRoomsAvailability(
|
|||||||
bookingCode?: string,
|
bookingCode?: string,
|
||||||
redemption?: boolean
|
redemption?: boolean
|
||||||
) {
|
) {
|
||||||
const params = {
|
return trpc.hotel.availability.roomsCombinedAvailability.useQuery({
|
||||||
adultsCount,
|
adultsCount,
|
||||||
bookingCode,
|
bookingCode,
|
||||||
childArray,
|
childArray,
|
||||||
hotelId,
|
hotelId,
|
||||||
lang,
|
lang,
|
||||||
|
redemption,
|
||||||
roomStayEndDate: toDateString,
|
roomStayEndDate: toDateString,
|
||||||
roomStayStartDate: fromDateString,
|
roomStayStartDate: fromDateString,
|
||||||
redemption,
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const roomsAvailability = redemption
|
|
||||||
? trpc.hotel.availability.roomsCombinedAvailabilityWithRedemption.useQuery(
|
|
||||||
params
|
|
||||||
)
|
|
||||||
: trpc.hotel.availability.roomsCombinedAvailability.useQuery(params)
|
|
||||||
|
|
||||||
|
|
||||||
return roomsAvailability
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useHotelPackages(
|
export function useHotelPackages(
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ export default async function ContactRow({ contact }: ContactRowProps) {
|
|||||||
|
|
||||||
let Icon = null
|
let Icon = null
|
||||||
if (contact.contact_field.includes("email")) {
|
if (contact.contact_field.includes("email")) {
|
||||||
Icon = function (props: MaterialIconSetIconProps) {
|
Icon = function MailIcon(props: MaterialIconSetIconProps) {
|
||||||
return <MaterialIcon icon="mail" {...props} />
|
return <MaterialIcon icon="mail" {...props} />
|
||||||
}
|
}
|
||||||
} else if (contact.contact_field.includes("phone")) {
|
} else if (contact.contact_field.includes("phone")) {
|
||||||
Icon = function (props: MaterialIconSetIconProps) {
|
Icon = function PhoneIcone(props: MaterialIconSetIconProps) {
|
||||||
return <MaterialIcon icon="phone" {...props} />
|
return <MaterialIcon icon="phone" {...props} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { AncillaryCardProps } from "@/types/components/ancillaryCard"
|
|||||||
export function AncillaryCard({ ancillary }: AncillaryCardProps) {
|
export function AncillaryCard({ ancillary }: AncillaryCardProps) {
|
||||||
const intl = useIntl()
|
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 (
|
return (
|
||||||
<article className={styles.ancillaryCard}>
|
<article className={styles.ancillaryCard}>
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
background-color: var(--Base-Surface-Subtle-Normal);
|
background-color: var(--Base-Surface-Subtle-Normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.borderDividerSubtle {
|
||||||
|
background-color: var(--Border-Divider-Subtle);
|
||||||
|
}
|
||||||
|
|
||||||
.opacity100 {
|
.opacity100 {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const dividerVariants = cva(styles.divider, {
|
|||||||
white: styles.white,
|
white: styles.white,
|
||||||
baseSurfaceSubtleHover: styles.baseSurfaceSubtleHover,
|
baseSurfaceSubtleHover: styles.baseSurfaceSubtleHover,
|
||||||
"Border/Divider/Default": styles["Border-Divider-Default"],
|
"Border/Divider/Default": styles["Border-Divider-Default"],
|
||||||
|
borderDividerSubtle: styles.borderDividerSubtle,
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
100: styles.opacity100,
|
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(
|
function getMemoizedSelectedRoomAvailability(
|
||||||
input: GetSelectedRoomAvailabilityInput
|
input: GetSelectedRoomAvailabilityInput
|
||||||
) {
|
) {
|
||||||
if (input.redemption) {
|
return serverClient().hotel.availability.room(input)
|
||||||
return serverClient().hotel.availability.roomWithRedemption(input)
|
|
||||||
} else {
|
|
||||||
return serverClient().hotel.availability.room(input)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ import { RoomContext } from "@/contexts/Details/Room"
|
|||||||
import type { RoomProviderProps } from "@/types/providers/details/room"
|
import type { RoomProviderProps } from "@/types/providers/details/room"
|
||||||
|
|
||||||
export default function RoomProvider({ children, idx }: RoomProviderProps) {
|
export default function RoomProvider({ children, idx }: RoomProviderProps) {
|
||||||
const actions = useEnterDetailsStore((state) => ({
|
const { actions, activeRoom, currentStep, isComplete, room, steps } =
|
||||||
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 } =
|
|
||||||
useEnterDetailsStore((state) => ({
|
useEnterDetailsStore((state) => ({
|
||||||
|
actions: state.rooms[idx].actions,
|
||||||
activeRoom: state.activeRoom,
|
activeRoom: state.activeRoom,
|
||||||
currentStep: state.rooms[idx].currentStep,
|
currentStep: state.rooms[idx].currentStep,
|
||||||
isComplete: state.rooms[idx].isComplete,
|
isComplete: state.rooms[idx].isComplete,
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export default function EnterDetailsProvider({
|
|||||||
bedType:
|
bedType:
|
||||||
room.bedTypes?.length === 1
|
room.bedTypes?.length === 1
|
||||||
? {
|
? {
|
||||||
roomTypeCode: room.bedTypes[0].value,
|
roomTypeCode: room.bedTypes[0].value,
|
||||||
description: room.bedTypes[0].description,
|
description: room.bedTypes[0].description,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
mustBeGuaranteed: room.mustBeGuaranteed,
|
mustBeGuaranteed: room.mustBeGuaranteed,
|
||||||
isFlexRate: room.isFlexRate,
|
isFlexRate: room.isFlexRate,
|
||||||
@@ -161,9 +161,23 @@ export default function EnterDetailsProvider({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
|
const nights = dt(booking.toDate).diff(booking.fromDate, "days")
|
||||||
const currency = (filteredOutMissingRooms[0].room.roomRate.publicRate
|
|
||||||
?.localPrice.currency ||
|
// We only extract the first room for its currency,
|
||||||
filteredOutMissingRooms[0].room.roomRate.memberRate?.localPrice.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(
|
const totalPrice = calcTotalPrice(
|
||||||
filteredOutMissingRooms,
|
filteredOutMissingRooms,
|
||||||
currency,
|
currency,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
import { trpc } from "@/lib/trpc/client"
|
||||||
import { useRatesStore } from "@/stores/select-rate"
|
import { useRatesStore } from "@/stores/select-rate"
|
||||||
|
|
||||||
import { RoomContext } from "@/contexts/SelectRate/Room"
|
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"
|
import type { RoomProviderProps } from "@/types/providers/select-rate/room"
|
||||||
|
|
||||||
export default function RoomProvider({
|
export default function RoomProvider({
|
||||||
@@ -11,38 +16,72 @@ export default function RoomProvider({
|
|||||||
idx,
|
idx,
|
||||||
room,
|
room,
|
||||||
}: RoomProviderProps) {
|
}: RoomProviderProps) {
|
||||||
const activeRoom = useRatesStore((state) => state.activeRoom)
|
const lang = useLang()
|
||||||
const closeSection = useRatesStore((state) => state.actions.closeSection(idx))
|
const { activeRoom, booking, roomAvailability, selectedFilter } =
|
||||||
const modifyRate = useRatesStore((state) => state.actions.modifyRate(idx))
|
useRatesStore((state) => ({
|
||||||
const roomAvailability = useRatesStore(
|
activeRoom: state.activeRoom,
|
||||||
(state) => state.roomsAvailability?.[idx]
|
booking: state.booking,
|
||||||
)
|
roomAvailability: state.roomsAvailability?.[idx],
|
||||||
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
|
selectedFilter: state.rooms[idx].selectedFilter,
|
||||||
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
|
}))
|
||||||
const selectRateRedemption = useRatesStore((state) =>
|
const { appendRegularRates, ...actions } = room.actions
|
||||||
state.actions.selectRateRedemption(idx)
|
|
||||||
)
|
|
||||||
const selectRateCheque = useRatesStore((state) =>
|
|
||||||
state.actions.selectRateCheque(idx)
|
|
||||||
)
|
|
||||||
const selectRateVoucher = useRatesStore((state) =>
|
|
||||||
state.actions.selectRateVoucher(idx)
|
|
||||||
)
|
|
||||||
const roomNr = idx + 1
|
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 (
|
return (
|
||||||
<RoomContext.Provider
|
<RoomContext.Provider
|
||||||
value={{
|
value={{
|
||||||
...room,
|
...room,
|
||||||
actions: {
|
actions,
|
||||||
closeSection,
|
|
||||||
modifyRate,
|
|
||||||
selectFilter,
|
|
||||||
selectRate,
|
|
||||||
selectRateRedemption,
|
|
||||||
selectRateCheque,
|
|
||||||
selectRateVoucher,
|
|
||||||
},
|
|
||||||
isActiveRoom: activeRoom === idx,
|
isActiveRoom: activeRoom === idx,
|
||||||
|
isFetchingAdditionalRate: isFetched ? false : isFetching,
|
||||||
isMainRoom: roomNr === 1,
|
isMainRoom: roomNr === 1,
|
||||||
roomAvailability,
|
roomAvailability,
|
||||||
roomNr,
|
roomNr,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
|
|||||||
rateCode: z.string().optional(),
|
rateCode: z.string().optional(),
|
||||||
roomStayEndDate: z.string(),
|
roomStayEndDate: z.string(),
|
||||||
roomStayStartDate: z.string(),
|
roomStayStartDate: z.string(),
|
||||||
redemption: z.boolean().optional(),
|
redemption: z.boolean().optional().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const selectedRoomAvailabilityInputSchema = z.object({
|
export const selectedRoomAvailabilityInputSchema = z.object({
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { toLang } from "@/server/utils"
|
import { toLang } from "@/server/utils"
|
||||||
|
|
||||||
|
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||||
|
|
||||||
import { occupancySchema } from "./schemas/availability/occupancy"
|
import { occupancySchema } from "./schemas/availability/occupancy"
|
||||||
import { productTypeSchema } from "./schemas/availability/productType"
|
import { productTypeSchema } from "./schemas/availability/productType"
|
||||||
import { citySchema } from "./schemas/city"
|
import { citySchema } from "./schemas/city"
|
||||||
@@ -23,6 +25,7 @@ import { roomConfigurationSchema } from "./schemas/roomAvailability/configuratio
|
|||||||
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
|
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
|
||||||
|
|
||||||
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import { RateTypeEnum } from "@/types/enums/rateType"
|
||||||
import type {
|
import type {
|
||||||
AdditionalData,
|
AdditionalData,
|
||||||
City,
|
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) {
|
function getRate(rate: RateDefinition) {
|
||||||
switch (rate.cancellationRule) {
|
switch (rate.cancellationRule) {
|
||||||
case "CancellableBefore6PM":
|
case "CancellableBefore6PM":
|
||||||
@@ -124,7 +113,9 @@ function getRate(rate: RateDefinition) {
|
|||||||
case "NotCancellable":
|
case "NotCancellable":
|
||||||
return "save"
|
return "save"
|
||||||
default:
|
default:
|
||||||
console.info(`Should never happen!`)
|
console.info(
|
||||||
|
`Unknown cancellationRule [${rate.cancellationRule}]. This should never happen!`
|
||||||
|
)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,10 +142,11 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
|
|||||||
return statusLookup[a.status] - statusLookup[b.status]
|
return statusLookup[a.status] - statusLookup[b.status]
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseRoomsCombinedAvailabilitySchema = z
|
export const roomsAvailabilitySchema = z
|
||||||
.object({
|
.object({
|
||||||
data: z.object({
|
data: z.object({
|
||||||
attributes: z.object({
|
attributes: z.object({
|
||||||
|
bookingCode: nullableStringValidator,
|
||||||
checkInDate: z.string(),
|
checkInDate: z.string(),
|
||||||
checkOutDate: z.string(),
|
checkOutDate: z.string(),
|
||||||
hotelId: z.number(),
|
hotelId: z.number(),
|
||||||
@@ -204,159 +196,213 @@ const baseRoomsCombinedAvailabilitySchema = z
|
|||||||
type: z.string().optional(),
|
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({
|
function getProductRateCode(product: Product) {
|
||||||
data: { attributes },
|
if ("corporateCheque" in product) {
|
||||||
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
|
return product.corporateCheque.rateCode
|
||||||
const rateDefinitions = attributes.rateDefinitions
|
}
|
||||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
if ("redemption" in product && product.redemption) {
|
||||||
// @ts-expect-error - index of cancellationRule TS
|
return product.redemption.rateCode
|
||||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
}
|
||||||
return acc
|
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
|
function sortProductsBasedOnCancellationRule(a: Product, b: Product) {
|
||||||
.map((room) => {
|
// @ts-expect-error - index
|
||||||
if (room.products.length) {
|
const lookUpA = cancellationRuleLookup[getProductRateCode(a)]
|
||||||
room.breakfastIncludedInAllRatesMember = room.products.every(
|
// @ts-expect-error - index
|
||||||
(product) =>
|
const lookUpB = cancellationRuleLookup[getProductRateCode(b)]
|
||||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
|
return lookUpA - lookUpB
|
||||||
)
|
}
|
||||||
room.breakfastIncludedInAllRatesPublic = room.products.every(
|
|
||||||
(product) =>
|
|
||||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
|
|
||||||
)
|
|
||||||
|
|
||||||
room.products = room.products.map((product) => {
|
function findRateDefintion(rateCode: string) {
|
||||||
const publicRate = product.public
|
return rateDefinitions.find(
|
||||||
if (publicRate?.rateCode) {
|
(rateDefinition) => rateDefinition.rateCode === 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const memberRate = product.member
|
function getRateDetails(product: Product) {
|
||||||
if (memberRate?.rateCode) {
|
let rateCode = ""
|
||||||
const memberRateDefinition = rateDefinitions.find(
|
if ("corporateCheque" in product) {
|
||||||
(rate) => rate.rateCode === memberRate.rateCode
|
rateCode = product.corporateCheque.rateCode
|
||||||
)
|
} else if ("redemption" in product && product.redemption) {
|
||||||
if (memberRateDefinition) {
|
rateCode = product.redemption.rateCode
|
||||||
const rate = getRate(memberRateDefinition)
|
} else if ("voucher" in product && product.voucher) {
|
||||||
if (rate) {
|
rateCode = product.voucher.rateCode
|
||||||
product.rate = rate
|
} else if ("public" in product && product.public) {
|
||||||
if (rate === "flex") {
|
rateCode = product.public.rateCode
|
||||||
product.isFlex = true
|
} else if ("member" in product && product.member) {
|
||||||
}
|
rateCode = product.member.rateCode
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return room
|
if (!rateCode) {
|
||||||
})
|
return null
|
||||||
.sort(sortRoomConfigs)
|
}
|
||||||
|
|
||||||
return {
|
const rateDefinition = findRateDefintion(rateCode)
|
||||||
...attributes,
|
|
||||||
roomConfigurations,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const roomsAvailabilitySchema = z
|
if (!rateDefinition) {
|
||||||
.object({
|
return null
|
||||||
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)
|
|
||||||
|
|
||||||
export const roomsCombinedAvailabilitySchema =
|
const rate = getRate(rateDefinition)
|
||||||
baseRoomsCombinedAvailabilitySchema.transform(transformRoomConfigs)
|
if (!rate) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export const redemptionRoomsCombinedAvailabilitySchema =
|
product.breakfastIncluded = rateDefinition.breakfastIncluded
|
||||||
baseRoomsCombinedAvailabilitySchema.transform((data) => {
|
product.rate = rate
|
||||||
// In Redemption, rates are always Flex terms
|
product.rateDefinition = rateDefinition
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
export const ratesSchema = z.array(rateSchema)
|
||||||
@@ -507,7 +553,7 @@ export const ancillaryPackagesSchema = z
|
|||||||
description: item.descriptions.html,
|
description: item.descriptions.html,
|
||||||
imageUrl: item.images[0]?.imageSizes.small,
|
imageUrl: item.images[0]?.imageSizes.small,
|
||||||
price: {
|
price: {
|
||||||
totalPrice: item.variants.ancillary.price.totalPrice,
|
total: item.variants.ancillary.price.totalPrice,
|
||||||
currency: item.variants.ancillary.price.currency,
|
currency: item.variants.ancillary.price.currency,
|
||||||
},
|
},
|
||||||
points: item.variants.ancillaryLoyalty?.points,
|
points: item.variants.ancillaryLoyalty?.points,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ import { Lang } from "@/constants/languages"
|
|||||||
import { env } from "@/env/server"
|
import { env } from "@/env/server"
|
||||||
import * as api from "@/lib/api"
|
import * as api from "@/lib/api"
|
||||||
import { dt } from "@/lib/dt"
|
import { dt } from "@/lib/dt"
|
||||||
import { badRequestError } from "@/server/errors/trpc"
|
import { badRequestError, unauthorizedError } from "@/server/errors/trpc"
|
||||||
import {
|
import {
|
||||||
contentStackBaseWithServiceProcedure,
|
contentStackBaseWithServiceProcedure,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
protectedServiceProcedure,
|
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
router,
|
router,
|
||||||
safeProtectedServiceProcedure,
|
safeProtectedServiceProcedure,
|
||||||
@@ -52,9 +51,7 @@ import {
|
|||||||
hotelSchema,
|
hotelSchema,
|
||||||
packagesSchema,
|
packagesSchema,
|
||||||
ratesSchema,
|
ratesSchema,
|
||||||
redemptionRoomsCombinedAvailabilitySchema,
|
|
||||||
roomsAvailabilitySchema,
|
roomsAvailabilitySchema,
|
||||||
roomsCombinedAvailabilitySchema,
|
|
||||||
} from "./output"
|
} from "./output"
|
||||||
import tempRatesData from "./tempRatesData.json"
|
import tempRatesData from "./tempRatesData.json"
|
||||||
import {
|
import {
|
||||||
@@ -65,6 +62,7 @@ import {
|
|||||||
getHotelIdsByCountry,
|
getHotelIdsByCountry,
|
||||||
getHotelsByHotelIds,
|
getHotelsByHotelIds,
|
||||||
getLocations,
|
getLocations,
|
||||||
|
getSelectedRoomAvailability,
|
||||||
} from "./utils"
|
} from "./utils"
|
||||||
|
|
||||||
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
|
||||||
@@ -75,8 +73,6 @@ import type { HotelDataWithUrl } from "@/types/hotel"
|
|||||||
import type {
|
import type {
|
||||||
HotelsAvailabilityInputSchema,
|
HotelsAvailabilityInputSchema,
|
||||||
HotelsByHotelIdsAvailabilityInputSchema,
|
HotelsByHotelIdsAvailabilityInputSchema,
|
||||||
RoomsCombinedAvailabilityInputSchema,
|
|
||||||
SelectedRoomAvailabilitySchema,
|
|
||||||
} from "@/types/trpc/routers/hotel/availability"
|
} from "@/types/trpc/routers/hotel/availability"
|
||||||
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
|
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
|
||||||
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
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({
|
export const hotelQueryRouter = router({
|
||||||
availability: router({
|
availability: router({
|
||||||
hotelsByCity: serviceProcedure
|
hotelsByCity: serviceProcedure
|
||||||
@@ -847,33 +496,360 @@ export const hotelQueryRouter = router({
|
|||||||
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
|
return getHotelsAvailabilityByHotelIds(input, apiLang, ctx.serviceToken)
|
||||||
}),
|
}),
|
||||||
|
|
||||||
roomsCombinedAvailability: serviceProcedure
|
roomsCombinedAvailability: safeProtectedServiceProcedure
|
||||||
.input(roomsCombinedAvailabilityInputSchema)
|
.input(roomsCombinedAvailabilityInputSchema)
|
||||||
.query(async ({ input, ctx }) => {
|
.use(async ({ ctx, input, next }) => {
|
||||||
return getRoomsCombinedAvailability(input, ctx.serviceToken)
|
if (input.redemption) {
|
||||||
}),
|
if (ctx.session?.token.access_token) {
|
||||||
roomsCombinedAvailabilityWithRedemption: protectedProcedure
|
return next({
|
||||||
.input(roomsCombinedAvailabilityInputSchema)
|
ctx: {
|
||||||
.query(async ({ input, ctx }) => {
|
token: ctx.session.token.access_token,
|
||||||
return getRoomsCombinedAvailability(
|
},
|
||||||
|
input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw unauthorizedError()
|
||||||
|
}
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
token: ctx.serviceToken,
|
||||||
|
},
|
||||||
input,
|
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
|
const {
|
||||||
.input(selectedRoomAvailabilityInputSchema)
|
adults,
|
||||||
.query(async ({ input, ctx }) => {
|
bookingCode,
|
||||||
return getRoomAvailability(input, ctx.lang, ctx.serviceToken)
|
children,
|
||||||
}),
|
counterRateCode,
|
||||||
roomWithRedemption: protectedServiceProcedure
|
hotelId,
|
||||||
.input(selectedRoomAvailabilityInputSchema)
|
inputLang,
|
||||||
.query(async ({ input, ctx }) => {
|
roomStayEndDate,
|
||||||
return getRoomAvailability(
|
roomStayStartDate,
|
||||||
input,
|
roomTypeCode,
|
||||||
ctx.lang,
|
} = input
|
||||||
ctx.session.token.access_token,
|
|
||||||
|
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
|
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
|
hotelsByCityWithBookingCode: serviceProcedure
|
||||||
.input(hotelsAvailabilityInputSchema)
|
.input(hotelsAvailabilityInputSchema)
|
||||||
@@ -896,23 +872,15 @@ export const hotelQueryRouter = router({
|
|||||||
return null
|
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.
|
// Get regular availability of hotels which don't have availability with booking code.
|
||||||
const unavailableHotelIds = !isVoucherOrChqRate
|
const unavailableHotelIds =
|
||||||
? bookingCodeAvailabilityResponse?.availability
|
bookingCodeAvailabilityResponse?.availability
|
||||||
.filter((hotel) => {
|
.filter((hotel) => {
|
||||||
return hotel.status === "NotAvailable"
|
return hotel.status === "NotAvailable"
|
||||||
})
|
})
|
||||||
.flatMap((hotel) => {
|
.flatMap((hotel) => {
|
||||||
return hotel.hotelId
|
return hotel.hotelId
|
||||||
})
|
})
|
||||||
: null
|
|
||||||
|
|
||||||
// All hotels have availability with booking code no need to fetch regular prices.
|
// All hotels have availability with booking code no need to fetch regular prices.
|
||||||
// return response as is without any filtering as below.
|
// return response as is without any filtering as below.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
productTypeChequeSchema,
|
productTypeCorporateChequeSchema,
|
||||||
productTypePointsSchema,
|
productTypePointsSchema,
|
||||||
productTypePriceSchema,
|
productTypePriceSchema,
|
||||||
productTypeVoucherSchema,
|
productTypeVoucherSchema,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
export const productTypeSchema = z
|
export const productTypeSchema = z
|
||||||
.object({
|
.object({
|
||||||
bonusCheque: productTypeChequeSchema.optional(),
|
bonusCheque: productTypeCorporateChequeSchema.optional(),
|
||||||
public: productTypePriceSchema.optional(),
|
public: productTypePriceSchema.optional(),
|
||||||
member: productTypePriceSchema.optional(),
|
member: productTypePriceSchema.optional(),
|
||||||
redemptions: z.array(productTypePointsSchema).optional(),
|
redemptions: z.array(productTypePointsSchema).optional(),
|
||||||
|
|||||||
@@ -1,63 +1,76 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { nullableNumberValidator } from "@/utils/zod/numberValidator"
|
||||||
|
import { nullableStringValidator } from "@/utils/zod/stringValidator"
|
||||||
|
|
||||||
import { CurrencyEnum } from "@/types/enums/currency"
|
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({
|
export const priceSchema = z.object({
|
||||||
currency: z.nativeEnum(CurrencyEnum),
|
currency: z.nativeEnum(CurrencyEnum),
|
||||||
pricePerNight: z.coerce.number(),
|
omnibusPricePerNight: nullableNumberValidator,
|
||||||
pricePerStay: z.coerce.number(),
|
pricePerNight: nullableNumberValidator,
|
||||||
regularPricePerNight: z.coerce.number().optional(),
|
pricePerStay: nullableNumberValidator,
|
||||||
regularPricePerStay: z.coerce.number().optional(),
|
regularPricePerNight: nullableNumberValidator,
|
||||||
})
|
regularPricePerStay: nullableNumberValidator,
|
||||||
|
|
||||||
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(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const partialPriceSchema = z.object({
|
const partialPriceSchema = z.object({
|
||||||
rateCode: z.string(),
|
rateCode: nullableStringValidator,
|
||||||
rateType: z.string().optional(),
|
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({
|
export const productTypeCorporateChequeSchema = z
|
||||||
localPrice: priceSchema,
|
.object({
|
||||||
requestedPrice: priceSchema.optional(),
|
localPrice: corporateChequeSchema,
|
||||||
})
|
requestedPrice: corporateChequeSchema.nullish(),
|
||||||
|
})
|
||||||
|
.merge(partialPriceSchema)
|
||||||
|
|
||||||
export const productTypePointsSchema = partialPriceSchema.extend({
|
export const productTypePriceSchema = z
|
||||||
localPrice: pointsSchema,
|
.object({
|
||||||
requestedPrice: pointsSchema.optional(),
|
localPrice: priceSchema,
|
||||||
})
|
requestedPrice: priceSchema.nullish(),
|
||||||
|
})
|
||||||
|
.merge(partialPriceSchema)
|
||||||
|
|
||||||
export const productTypeVoucherSchema = partialPriceSchema.extend({
|
export const productTypePointsSchema = z
|
||||||
numberOfVouchers: z.coerce.number(),
|
.object({
|
||||||
})
|
localPrice: redemptionSchema,
|
||||||
|
requestedPrice: redemptionSchema.nullish(),
|
||||||
|
})
|
||||||
|
.merge(partialPriceSchema)
|
||||||
|
|
||||||
export const productTypeChequeSchema = partialPriceSchema.extend({
|
export const productTypeVoucherSchema = z
|
||||||
localPrice: chequeSchema,
|
.object({
|
||||||
requestedPrice: chequeSchema.optional(),
|
numberOfVouchers: nullableNumberValidator,
|
||||||
})
|
})
|
||||||
|
.merge(partialPriceSchema)
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { z } from "zod"
|
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 {
|
||||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
AvailabilityEnum,
|
||||||
|
} from "@/types/components/hotelReservation/selectHotel/selectHotel"
|
||||||
|
import {
|
||||||
|
RoomPackageCodeEnum,
|
||||||
|
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||||
|
|
||||||
export const roomConfigurationSchema = z
|
export const roomConfigurationSchema = z
|
||||||
.object({
|
.object({
|
||||||
breakfastIncludedInAllRatesMember: z.boolean().default(false),
|
breakfastIncludedInAllRatesMember: z.boolean().default(false),
|
||||||
breakfastIncludedInAllRatesPublic: z.boolean().default(false),
|
breakfastIncludedInAllRates: z.boolean().default(false),
|
||||||
features: z
|
features: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -25,36 +35,36 @@ export const roomConfigurationSchema = z
|
|||||||
roomsLeft: z.number(),
|
roomsLeft: z.number(),
|
||||||
roomType: z.string(),
|
roomType: z.string(),
|
||||||
roomTypeCode: z.string(),
|
roomTypeCode: z.string(),
|
||||||
status: z.string(),
|
status: z
|
||||||
})
|
.nativeEnum(AvailabilityEnum)
|
||||||
.transform((data) => {
|
.nullish()
|
||||||
if (data.products.length) {
|
.default(AvailabilityEnum.NotAvailable),
|
||||||
if (data.products[0].redemptions) {
|
|
||||||
// No need of rate check in reward night scenario
|
// Red
|
||||||
return { ...data }
|
campaign: z
|
||||||
} else {
|
.array(priceProduct)
|
||||||
const isVoucher = data.products.some((product) => product.voucher)
|
.nullish()
|
||||||
const isCorpChq = data.products.some((product) => product.bonusCheque)
|
.transform(val => val ? val.filter(Boolean) : []),
|
||||||
if (isVoucher || isCorpChq) {
|
// Blue
|
||||||
return {
|
code: z
|
||||||
...data,
|
.array(
|
||||||
}
|
z
|
||||||
}
|
.union([
|
||||||
/**
|
corporateChequeProduct,
|
||||||
* Just guaranteeing that if all products all miss
|
priceProduct,
|
||||||
* both public and member rateCode that status is
|
voucherProduct,
|
||||||
* set to `NotAvailable`
|
])
|
||||||
*/
|
)
|
||||||
const allProductsMissBothRateCodes = data.products.every(
|
.nullish()
|
||||||
(product) => !product.public?.rateCode && !product.member?.rateCode
|
.transform(val => val ? val.filter(Boolean) : []),
|
||||||
)
|
// Beige
|
||||||
if (allProductsMissBothRateCodes) {
|
regular: z
|
||||||
return {
|
.array(priceProduct)
|
||||||
...data,
|
.nullish()
|
||||||
status: AvailabilityEnum.NotAvailable,
|
.transform(val => val ? val.filter(Boolean) : []),
|
||||||
}
|
// Burgundy
|
||||||
}
|
redemptions: z
|
||||||
}
|
.array(redemptionProduct)
|
||||||
}
|
.nullish()
|
||||||
return data
|
.transform(val => val ? val.filter(Boolean) : []),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,28 +1,161 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
productTypeCorporateChequeSchema,
|
||||||
productTypePointsSchema,
|
productTypePointsSchema,
|
||||||
productTypeChequeSchema,
|
|
||||||
productTypePriceSchema,
|
productTypePriceSchema,
|
||||||
productTypeVoucherSchema,
|
productTypeVoucherSchema,
|
||||||
} from "../productTypePrice"
|
} 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({
|
.object({
|
||||||
// Is product flex rate
|
productType: z
|
||||||
isFlex: z.boolean().default(false),
|
.object({
|
||||||
productType: z.object({
|
bonusCheque: productTypeCorporateChequeSchema,
|
||||||
bonusCheque: productTypeChequeSchema.optional(),
|
})
|
||||||
member: productTypePriceSchema.optional(),
|
.transform((data) => ({
|
||||||
public: productTypePriceSchema.optional(),
|
corporateCheque: data.bonusCheque,
|
||||||
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"),
|
|
||||||
})
|
})
|
||||||
.transform((data) => ({
|
.merge(baseProductSchema)
|
||||||
|
|
||||||
|
function transformCorporateCheque(
|
||||||
|
data: z.output<typeof rawCorporateChequeProduct>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
...data.productType,
|
...data.productType,
|
||||||
isFlex: data.isFlex,
|
...mapBaseProduct(data),
|
||||||
rate: data.rate,
|
}
|
||||||
}))
|
}
|
||||||
|
|
||||||
|
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"
|
import { z } from "zod"
|
||||||
|
|
||||||
export const rateDefinitionSchema = z.object({
|
export const rateDefinitionSchema = z.object({
|
||||||
breakfastIncluded: z.boolean(),
|
breakfastIncluded: z.boolean(),
|
||||||
cancellationRule: z.string(),
|
cancellationRule: z.string(),
|
||||||
cancellationText: z.string().optional(),
|
cancellationText: nullableStringValidator,
|
||||||
|
displayPriceRed: z.boolean().default(false),
|
||||||
generalTerms: z.array(z.string()),
|
generalTerms: z.array(z.string()),
|
||||||
|
isCampaignRate: z.boolean().default(false),
|
||||||
|
isMemberRate: z.boolean().default(false),
|
||||||
|
isPackageRate: z.boolean().default(false),
|
||||||
mustBeGuaranteed: z.boolean(),
|
mustBeGuaranteed: z.boolean(),
|
||||||
rateCode: z.string(),
|
rateCode: z.string(),
|
||||||
rateType: z.string().optional(),
|
rateType: nullableStringValidator,
|
||||||
title: z.string(),
|
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