Merged in feat/SW-1356-reward-night-booking-2- (pull request #1559)
feat: SW-1356 Reward night bookingflow * feat: SW-1356 Reward night bookingflow * feat: SW-1356 Removed extra param booking call * feat: SW-1356 Optimized as review comments * feat: SW-1356 Schema validation updates * feat: SW-1356 Fix after rebase * feat: SW-1356 Optimised price.redemptions check * feat: SW-1356 Updated Props naming Approved-by: Arvid Norlin
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { selectRate } from "@/constants/routes/hotelReservation"
|
||||
import {
|
||||
getBreakfastPackages,
|
||||
@@ -83,6 +84,7 @@ export default async function DetailsPage({
|
||||
roomStayEndDate: booking.toDate,
|
||||
roomStayStartDate: booking.fromDate,
|
||||
roomTypeCode: room.roomTypeCode,
|
||||
redemption: booking.searchType === REDEMPTION,
|
||||
})
|
||||
|
||||
if (!roomAvailability) {
|
||||
@@ -107,6 +109,7 @@ export default async function DetailsPage({
|
||||
roomRate: {
|
||||
memberRate: roomAvailability?.memberRate,
|
||||
publicRate: roomAvailability.publicRate,
|
||||
redemptionRate: roomAvailability.redemptionRate,
|
||||
},
|
||||
isAvailable:
|
||||
roomAvailability.selectedRoom.status === AvailabilityEnum.Available,
|
||||
|
||||
@@ -30,9 +30,7 @@ export default function Voucher() {
|
||||
|
||||
return (
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.vouchers}>
|
||||
<BookingCode />
|
||||
</div>
|
||||
<BookingCode />
|
||||
<div className={styles.options}>
|
||||
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? (
|
||||
<>
|
||||
@@ -81,7 +79,6 @@ export function VoucherSkeleton() {
|
||||
const intl = useIntl()
|
||||
|
||||
const vouchers = intl.formatMessage({ id: "Code / Voucher" })
|
||||
const bonus = intl.formatMessage({ id: "Use Bonus Cheque" })
|
||||
const reward = intl.formatMessage({ id: "Book Reward Night" })
|
||||
|
||||
const form = useForm()
|
||||
@@ -89,7 +86,7 @@ export function VoucherSkeleton() {
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<div className={styles.optionsContainer}>
|
||||
<div className={styles.vouchers}>
|
||||
<div>
|
||||
<label>
|
||||
<Caption type="bold" color="red" asChild>
|
||||
<span>{vouchers}</span>
|
||||
@@ -98,21 +95,11 @@ export function VoucherSkeleton() {
|
||||
<SkeletonShimmer width={"100%"} />
|
||||
</div>
|
||||
<div className={styles.options}>
|
||||
{env.NEXT_PUBLIC_HIDE_FOR_NEXT_RELEASE ? null : (
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="useBonus" registerOptions={{ disabled: true }}>
|
||||
<Caption color="disabled" asChild>
|
||||
<span>{bonus}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.option}>
|
||||
<Checkbox name="redemption">
|
||||
<Caption color="uiTextMediumContrast" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</Checkbox>
|
||||
<SkeletonShimmer width="24px" height="24px" />
|
||||
<Caption color="uiTextMediumContrast" asChild>
|
||||
<span>{reward}</span>
|
||||
</Caption>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { formId } from "@/components/HotelReservation/EnterDetails/Payment/Payme
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import styles from "./bottomSheet.module.css"
|
||||
|
||||
@@ -57,10 +57,12 @@ export default function SummaryBottomSheet({ children }: PropsWithChildren) {
|
||||
>
|
||||
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
|
||||
<Subtitle>
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
|
||||
@@ -20,7 +20,10 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import {
|
||||
formatPrice,
|
||||
formatPriceWithAdditionalPrice,
|
||||
} from "@/utils/numberFormatting"
|
||||
|
||||
import PriceDetailsTable from "./PriceDetailsTable"
|
||||
|
||||
@@ -170,10 +173,12 @@ export default function SummaryUI({
|
||||
memberPrice.amount,
|
||||
memberPrice.currency
|
||||
)
|
||||
: formatPrice(
|
||||
: formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
@@ -383,10 +388,12 @@ export default function SummaryUI({
|
||||
</div>
|
||||
<div>
|
||||
<Body textTransform="bold" data-testid="total-price">
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
{totalPrice.local.regularPrice ? (
|
||||
|
||||
@@ -3,17 +3,16 @@ import { useIntl } from "react-intl"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
|
||||
import styles from "./hotelPointsCard.module.css"
|
||||
import styles from "./hotelPointsRow.module.css"
|
||||
|
||||
import type { PointsCardProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||
import type { PointsRowProps } from "@/types/components/hotelReservation/selectHotel/priceCardProps"
|
||||
|
||||
export default function HotelPointsCard({
|
||||
productTypePoints,
|
||||
redemptionPrice,
|
||||
}: PointsCardProps) {
|
||||
export default function HotelPointsRow({
|
||||
pointsPerStay,
|
||||
additionalPricePerStay,
|
||||
additionalPriceCurrency,
|
||||
}: PointsRowProps) {
|
||||
const intl = useIntl()
|
||||
const pointsPerStay =
|
||||
productTypePoints?.localPrice.pointsPerStay ?? redemptionPrice
|
||||
|
||||
return (
|
||||
<div className={styles.poinstRow}>
|
||||
@@ -23,14 +22,14 @@ export default function HotelPointsCard({
|
||||
<Caption color="uiTextHighContrast">
|
||||
{intl.formatMessage({ id: "Points" })}
|
||||
</Caption>
|
||||
{productTypePoints?.localPrice.pricePerStay ? (
|
||||
{additionalPricePerStay ? (
|
||||
<>
|
||||
+
|
||||
<Subtitle type="two" color="uiTextHighContrast">
|
||||
{productTypePoints.localPrice.pricePerStay}
|
||||
{additionalPricePerStay}
|
||||
</Subtitle>
|
||||
<Caption color="uiTextHighContrast">
|
||||
{productTypePoints.localPrice.currency}
|
||||
{additionalPriceCurrency}
|
||||
</Caption>
|
||||
</>
|
||||
) : null}
|
||||
@@ -22,7 +22,7 @@ import { getSingleDecimal } from "@/utils/numberFormatting"
|
||||
|
||||
import ReadMore from "../ReadMore"
|
||||
import TripAdvisorChip from "../TripAdvisorChip"
|
||||
import HotelPointsCard from "./HotelPointsCard"
|
||||
import HotelPointsRow from "./HotelPointsRow"
|
||||
import HotelPriceCard from "./HotelPriceCard"
|
||||
import NoPriceAvailableCard from "./NoPriceAvailableCard"
|
||||
import { hotelCardVariants } from "./variants"
|
||||
@@ -154,9 +154,7 @@ function HotelCard({
|
||||
) : (
|
||||
<>
|
||||
{bookingCode && (
|
||||
<span
|
||||
className={`${styles.bookingCode} ${fullPrice ? styles.strikedText : ""}`}
|
||||
>
|
||||
<span className={`${fullPrice ? styles.strikedText : ""}`}>
|
||||
<PriceTagIcon height={20} width={20} />
|
||||
{bookingCode}
|
||||
</span>
|
||||
@@ -173,21 +171,23 @@ function HotelCard({
|
||||
isMemberPrice
|
||||
/>
|
||||
)}
|
||||
{price?.redemption && (
|
||||
{!!price?.redemptions?.length && (
|
||||
<div className={styles.pointsCard}>
|
||||
<Caption>
|
||||
{intl.formatMessage({ id: "Available rates" })}
|
||||
</Caption>
|
||||
{/* Display rate with full points option */}
|
||||
<HotelPointsCard productTypePoints={price.redemption} />
|
||||
{/* Display rate with partial points option A */}
|
||||
{price.redemptionA && (
|
||||
<HotelPointsCard productTypePoints={price.redemptionA} />
|
||||
)}
|
||||
{/* Display rate with partial points option B */}
|
||||
{price.redemptionB && (
|
||||
<HotelPointsCard productTypePoints={price.redemptionB} />
|
||||
)}
|
||||
{price.redemptions.map((redemption) => (
|
||||
<HotelPointsRow
|
||||
key={redemption.rateCode}
|
||||
pointsPerStay={redemption.localPrice.pointsPerStay}
|
||||
additionalPricePerStay={
|
||||
redemption.localPrice.additionalPricePerStay
|
||||
}
|
||||
additionalPriceCurrency={
|
||||
redemption.localPrice.additionalPriceCurrency
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,7 @@ import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import HotelPointsCard from "../../HotelCard/HotelPointsCard"
|
||||
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
|
||||
@@ -117,7 +117,7 @@ export default function ListingHotelCardDialog({
|
||||
</Subtitle>
|
||||
)}
|
||||
{redemptionPrice && (
|
||||
<HotelPointsCard redemptionPrice={redemptionPrice} />
|
||||
<HotelPointsRow pointsPerStay={redemptionPrice} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { isValidClientSession } from "@/utils/clientSession"
|
||||
|
||||
import HotelPointsCard from "../../HotelCard/HotelPointsCard"
|
||||
import HotelPointsRow from "../../HotelCard/HotelPointsRow"
|
||||
import NoPriceAvailableCard from "../../HotelCard/NoPriceAvailableCard"
|
||||
import HotelCardDialogImage from "../HotelCardDialogImage"
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function StandaloneHotelCardDialog({
|
||||
</Subtitle>
|
||||
)}
|
||||
{redemptionPrice && (
|
||||
<HotelPointsCard redemptionPrice={redemptionPrice} />
|
||||
<HotelPointsRow pointsPerStay={redemptionPrice} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function HotelCardDialogListing({
|
||||
}: HotelCardDialogListingProps) {
|
||||
const intl = useIntl()
|
||||
const isRedemption = hotels?.find(
|
||||
(hotel) => hotel.availability.productType?.redemption
|
||||
(hotel) => hotel.availability.productType?.redemptions?.length
|
||||
)
|
||||
const currencyValue = isRedemption
|
||||
? intl.formatMessage({ id: "Points" })
|
||||
|
||||
@@ -11,6 +11,9 @@ export function getHotelPins(
|
||||
|
||||
return hotels.map(({ availability, hotel, additionalData }) => {
|
||||
const productType = availability.productType
|
||||
const redemptionRate = productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)
|
||||
return {
|
||||
coordinates: {
|
||||
lat: hotel.location.latitude,
|
||||
@@ -19,8 +22,7 @@ export function getHotelPins(
|
||||
name: hotel.name,
|
||||
publicPrice: productType?.public?.localPrice.pricePerNight ?? null,
|
||||
memberPrice: productType?.member?.localPrice.pricePerNight ?? null,
|
||||
redemptionPrice:
|
||||
productType?.redemption?.localPrice.pointsPerNight ?? null,
|
||||
redemptionPrice: redemptionRate?.localPrice.pointsPerStay ?? null,
|
||||
rateType:
|
||||
productType?.public?.rateType ?? productType?.member?.rateType ?? null,
|
||||
currency:
|
||||
|
||||
@@ -5,6 +5,9 @@ function getPricePerNight(hotel: HotelResponse): number {
|
||||
return (
|
||||
hotel.availability.productType?.member?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.public?.localPrice?.pricePerNight ??
|
||||
hotel.availability.productType?.redemptions?.find(
|
||||
(r) => r?.localPrice.pointsPerStay
|
||||
)?.localPrice?.pointsPerStay ??
|
||||
Infinity
|
||||
)
|
||||
}
|
||||
@@ -49,7 +52,7 @@ export function getSortedHotels({
|
||||
(hotel.availability.productType?.public?.rateType?.toLowerCase() !==
|
||||
"regular" ||
|
||||
hotel.availability.productType?.member?.rateType?.toLowerCase() !==
|
||||
"regular") &&
|
||||
"regular") &&
|
||||
!!hotel.availability.productType
|
||||
)
|
||||
const regularHotels = hotels.filter(
|
||||
|
||||
@@ -77,6 +77,12 @@ async function fetchAvailableHotels(input: AvailabilityInput) {
|
||||
return await serverClient().hotel.availability.hotelsByCity(input)
|
||||
}
|
||||
|
||||
async function fetchAvailableHotelsWithRedemption(input: AvailabilityInput) {
|
||||
return await serverClient().hotel.availability.hotelsByCityWithRedemption(
|
||||
input
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchBookingCodeAvailableHotels(input: AvailabilityInput) {
|
||||
return await serverClient().hotel.availability.hotelsByCityWithBookingCode(
|
||||
input
|
||||
@@ -192,6 +198,22 @@ export async function getHotels(
|
||||
})
|
||||
})
|
||||
)
|
||||
} else if (redemption) {
|
||||
availableHotelsResponse = await Promise.allSettled(
|
||||
booking.rooms.map(
|
||||
async (room) =>
|
||||
await fetchAvailableHotelsWithRedemption({
|
||||
adults: room.adults,
|
||||
children: room.childrenInRoom
|
||||
? generateChildrenString(room.childrenInRoom)
|
||||
: undefined,
|
||||
cityId: city.id,
|
||||
redemption,
|
||||
roomStayEndDate: booking.toDate,
|
||||
roomStayStartDate: booking.fromDate,
|
||||
})
|
||||
)
|
||||
)
|
||||
} else {
|
||||
availableHotelsResponse = await Promise.allSettled(
|
||||
booking.rooms.map(
|
||||
|
||||
@@ -18,7 +18,10 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import useLang from "@/hooks/useLang"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import {
|
||||
formatPrice,
|
||||
formatPriceWithAdditionalPrice,
|
||||
} from "@/utils/numberFormatting"
|
||||
|
||||
import PriceDetailsTable from "./PriceDetailsTable"
|
||||
|
||||
@@ -151,10 +154,12 @@ export default function Summary({
|
||||
<div className={styles.entry}>
|
||||
<Body color="uiTextHighContrast">{room.roomType}</Body>
|
||||
<Body color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
room.roomPrice.perStay.local.price,
|
||||
room.roomPrice.perStay.local.currency
|
||||
room.roomPrice.perStay.local.currency,
|
||||
room.roomPrice.perStay.local.additionalPrice,
|
||||
room.roomPrice.perStay.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
@@ -269,10 +274,12 @@ export default function Summary({
|
||||
textTransform="bold"
|
||||
data-testid="total-price"
|
||||
>
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPrice.local.price,
|
||||
totalPrice.local.currency
|
||||
totalPrice.local.currency,
|
||||
totalPrice.local.additionalPrice,
|
||||
totalPrice.local.additionalPriceCurrency
|
||||
)}
|
||||
</Body>
|
||||
{booking.bookingCode && totalPrice.local.regularPrice && (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRatesStore } from "@/stores/select-rate"
|
||||
import Button from "@/components/TempDesignSystem/Button"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import { formatPriceWithAdditionalPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import Summary from "./Summary"
|
||||
|
||||
@@ -144,11 +144,16 @@ export default function MobileSummary({
|
||||
className={styles.priceDetailsButton}
|
||||
>
|
||||
<Caption>{intl.formatMessage({ id: "Total price" })}</Caption>
|
||||
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
<Subtitle
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
className={styles.wrappedText}
|
||||
>
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Caption color="baseTextHighContrast" type="underline">
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.wrappedText {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.bottomSheet {
|
||||
padding: var(--Spacing-x2) 0 var(--Spacing-x7);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useState, useTransition } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
@@ -13,13 +14,20 @@ import Body from "@/components/TempDesignSystem/Text/Body"
|
||||
import Caption from "@/components/TempDesignSystem/Text/Caption"
|
||||
import Footnote from "@/components/TempDesignSystem/Text/Footnote"
|
||||
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
import {
|
||||
formatPrice,
|
||||
formatPriceWithAdditionalPrice,
|
||||
} from "@/utils/numberFormatting"
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import { calculateTotalPrice } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import {
|
||||
PointsPriceSchema,
|
||||
type Price,
|
||||
} from "@/types/components/hotelReservation/price"
|
||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
@@ -27,6 +35,8 @@ import { RateTypeEnum } from "@/types/enums/rateType"
|
||||
|
||||
export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
const {
|
||||
bookingCode,
|
||||
isRedemption,
|
||||
bookingRooms,
|
||||
dates,
|
||||
petRoomPackage,
|
||||
@@ -34,6 +44,8 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
roomsAvailability,
|
||||
searchParams,
|
||||
} = useRatesStore((state) => ({
|
||||
bookingCode: state.booking.bookingCode,
|
||||
isRedemption: state.booking.searchType === REDEMPTION,
|
||||
bookingRooms: state.booking.rooms,
|
||||
dates: {
|
||||
checkInDate: state.booking.fromDate,
|
||||
@@ -58,7 +70,6 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
const checkInDate = new Date(dates.checkInDate)
|
||||
const checkOutDate = new Date(dates.checkOutDate)
|
||||
const nights = dt(checkOutDate).diff(dt(checkInDate), "days")
|
||||
const bookingCode = params.get("bookingCode")
|
||||
|
||||
const totalNights = intl.formatMessage(
|
||||
{ id: "{totalNights, plural, one {# night} other {# nights}}" },
|
||||
@@ -128,11 +139,11 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
)
|
||||
const showDiscounted = isUserLoggedIn || isBookingCodeRate
|
||||
|
||||
const totalPriceToShow = calculateTotalPrice(
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
petRoomPackage
|
||||
)
|
||||
// In case of reward night (redemption) only single room booking is supported by business rules
|
||||
const totalPriceToShow: Price =
|
||||
isRedemption && rateSummary[0].redemption
|
||||
? PointsPriceSchema.parse(rateSummary[0].redemption)
|
||||
: calculateTotalPrice(rateSummary, isUserLoggedIn, petRoomPackage)
|
||||
|
||||
return (
|
||||
<form action={`details?${params}`} method="GET" onSubmit={handleSubmit}>
|
||||
@@ -231,10 +242,12 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
color={showDiscounted ? "red" : "uiTextHighContrast"}
|
||||
textAlign="right"
|
||||
>
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
{bookingCode && totalPriceToShow.local.regularPrice && (
|
||||
@@ -270,10 +283,12 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
{intl.formatMessage({ id: "Total price" })}
|
||||
</Caption>
|
||||
<Subtitle color={showDiscounted ? "red" : "uiTextHighContrast"}>
|
||||
{formatPrice(
|
||||
{formatPriceWithAdditionalPrice(
|
||||
intl,
|
||||
totalPriceToShow.local.price,
|
||||
totalPriceToShow.local.currency
|
||||
totalPriceToShow.local.currency,
|
||||
totalPriceToShow.local.additionalPrice,
|
||||
totalPriceToShow.local.additionalPriceCurrency
|
||||
)}
|
||||
</Subtitle>
|
||||
<Footnote
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function PriceList({
|
||||
publicPrice = {},
|
||||
memberPrice = {},
|
||||
petRoomPackage,
|
||||
rateTitle,
|
||||
rateName,
|
||||
}: PriceListProps) {
|
||||
const intl = useIntl()
|
||||
const { isMainRoom } = useRoomContext()
|
||||
@@ -69,14 +69,14 @@ export default function PriceList({
|
||||
)
|
||||
|
||||
const priceLabelColor =
|
||||
rateTitle && !memberLocalPrice ? "red" : "uiTextHighContrast"
|
||||
rateName && !memberLocalPrice ? "red" : "uiTextHighContrast"
|
||||
|
||||
return (
|
||||
<dl className={styles.priceList}>
|
||||
{isUserLoggedIn && isMainRoom && memberLocalPrice ? null : (
|
||||
<div className={styles.priceRow}>
|
||||
<dt>
|
||||
{rateTitle ? null : (
|
||||
{rateName ? null : (
|
||||
<Caption
|
||||
type="bold"
|
||||
color={
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.pointsRow {
|
||||
justify-content: flex-start;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
|
||||
.priceTable {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ input[type="radio"]:checked + .card .checkIcon {
|
||||
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);
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function FlexibilityOption({
|
||||
roomType,
|
||||
roomTypeCode,
|
||||
title,
|
||||
rateTitle,
|
||||
rateName,
|
||||
}: FlexibilityOptionProps) {
|
||||
const intl = useIntl()
|
||||
const isUserLoggedIn = useRatesStore((state) => state.isUserLoggedIn)
|
||||
@@ -104,9 +104,9 @@ export default function FlexibilityOption({
|
||||
value={rate.rateCode}
|
||||
/>
|
||||
<div className={styles.card}>
|
||||
{rateTitle ? (
|
||||
{rateName ? (
|
||||
<div className={styles.header}>
|
||||
<Caption>{rateTitle}</Caption>
|
||||
<Caption>{rateName}</Caption>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.header}>
|
||||
@@ -120,8 +120,8 @@ export default function FlexibilityOption({
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={rateTitle ? rateTitle : title}
|
||||
subtitle={rateTitle ? `${title} (${paymentTerm})` : paymentTerm}
|
||||
title={rateName ? rateName : title}
|
||||
subtitle={rateName ? `${title} (${paymentTerm})` : paymentTerm}
|
||||
>
|
||||
<div className={styles.terms}>
|
||||
{priceInformation?.map((info) => (
|
||||
@@ -150,7 +150,7 @@ export default function FlexibilityOption({
|
||||
memberPrice={product.member}
|
||||
petRoomPackage={petRoomPackage}
|
||||
publicPrice={product.public}
|
||||
rateTitle={rateTitle}
|
||||
rateName={rateName}
|
||||
/>
|
||||
|
||||
<div className={styles.checkIcon}>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.pointsList {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pointsRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-start;
|
||||
gap: var(--Spacing-x-half);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { CheckIcon, InfoCircleIcon } from "@/components/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}>
|
||||
<InfoCircleIcon width={16} height={16} color="uiTextMediumContrast" />
|
||||
<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">
|
||||
<InfoCircleIcon
|
||||
width={16}
|
||||
height={16}
|
||||
color="uiTextMediumContrast"
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
title={rewardNightTitle}
|
||||
subtitle={`${title} ${paymentTerm}`}
|
||||
>
|
||||
{priceInformation?.length ? (
|
||||
<div className={styles.terms}>
|
||||
{priceInformation.map((info) => (
|
||||
<Body
|
||||
key={info}
|
||||
color="uiTextHighContrast"
|
||||
className={styles.termsText}
|
||||
>
|
||||
<CheckIcon
|
||||
color="uiSemanticSuccess"
|
||||
width={20}
|
||||
height={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>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from "next/navigation"
|
||||
import { createElement } from "react"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { useRatesStore } from "@/stores/select-rate"
|
||||
|
||||
import ToggleSidePeek from "@/components/HotelReservation/EnterDetails/SelectedRoom/ToggleSidePeek"
|
||||
@@ -18,6 +19,7 @@ import { mapApiImagesToGalleryImages } from "@/utils/imageGallery"
|
||||
|
||||
import { cardVariants } from "./cardVariants"
|
||||
import FlexibilityOption from "./FlexibilityOption"
|
||||
import FlexibilityOptionPoints from "./FlexibilityOptionPoints"
|
||||
import RoomSize from "./RoomSize"
|
||||
|
||||
import styles from "./roomCard.module.css"
|
||||
@@ -72,6 +74,7 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const bookingCode = searchParams.get("bookingCode")
|
||||
const isRedemption = searchParams.get("searchtype") === REDEMPTION
|
||||
|
||||
const { hotelId, hotelType, isUserLoggedIn, petRoomPackage, roomCategories } =
|
||||
useRatesStore((state) => ({
|
||||
@@ -152,6 +155,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
* 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
|
||||
@@ -160,10 +166,18 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
product: Product,
|
||||
rateDefinitions: RateDefinition[]
|
||||
) {
|
||||
return rateDefinitions.find((rateDefinition) =>
|
||||
isUserLoggedIn && product.member && isMainRoom
|
||||
? rateDefinition.rateCode === product.member?.rateCode
|
||||
: rateDefinition.rateCode === product.public?.rateCode
|
||||
let rateCode = ""
|
||||
if (isUserLoggedIn && product.member && isMainRoom) {
|
||||
rateCode = product.member.rateCode
|
||||
} else if (product.public?.rateCode) {
|
||||
rateCode = product.public.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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,7 +277,9 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
|
||||
{isRedemption ? null : (
|
||||
<Caption color="uiTextHighContrast">{breakfastMessage}</Caption>
|
||||
)}
|
||||
{bookingCode ? (
|
||||
<span className={!isBookingCodeRate ? styles.strikedText : ""}>
|
||||
<PriceTagIcon />
|
||||
@@ -275,29 +291,30 @@ export default function RoomCard({ roomConfiguration }: RoomCardProps) {
|
||||
const rateTitle = getRateTitle(product.rate)
|
||||
const isAvailable =
|
||||
product.public ||
|
||||
(product.member && isUserLoggedIn && isMainRoom)
|
||||
(product.member && isUserLoggedIn && isMainRoom) ||
|
||||
product.redemptions?.length
|
||||
const rateDefinition = getRateDefinition(
|
||||
product,
|
||||
roomAvailability.rateDefinitions
|
||||
)
|
||||
return (
|
||||
<FlexibilityOption
|
||||
key={product.rate}
|
||||
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}
|
||||
rateTitle={
|
||||
product.public &&
|
||||
product.public?.rateType !== RateTypeEnum.Regular
|
||||
? rateDefinition?.title
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
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
|
||||
? rateDefinition?.title
|
||||
: undefined,
|
||||
}
|
||||
return isRedemption ? (
|
||||
<FlexibilityOptionPoints key={product.rate} {...props} />
|
||||
) : (
|
||||
<FlexibilityOption key={product.rate} {...props} />
|
||||
)
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -24,6 +25,9 @@ export function RoomsContainer({
|
||||
|
||||
const fromDateString = dt(fromDate).format("YYYY-MM-DD")
|
||||
const toDateString = dt(toDate).format("YYYY-MM-DD")
|
||||
const redemption = booking.searchType
|
||||
? booking.searchType === REDEMPTION
|
||||
: undefined
|
||||
|
||||
const { data: roomsAvailability, isPending: isLoadingAvailability } =
|
||||
useRoomsAvailability(
|
||||
@@ -33,7 +37,8 @@ export function RoomsContainer({
|
||||
toDateString,
|
||||
lang,
|
||||
childArray,
|
||||
booking.bookingCode
|
||||
booking.bookingCode,
|
||||
redemption
|
||||
)
|
||||
|
||||
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(
|
||||
|
||||
@@ -11,9 +11,10 @@ export function useRoomsAvailability(
|
||||
toDateString: string,
|
||||
lang: Lang,
|
||||
childArray: ChildrenInRoom,
|
||||
bookingCode?: string
|
||||
bookingCode?: string,
|
||||
redemption?: boolean
|
||||
) {
|
||||
return trpc.hotel.availability.roomsCombinedAvailability.useQuery({
|
||||
const params = {
|
||||
adultsCount,
|
||||
bookingCode,
|
||||
childArray,
|
||||
@@ -21,7 +22,17 @@ export function useRoomsAvailability(
|
||||
lang,
|
||||
roomStayEndDate: toDateString,
|
||||
roomStayStartDate: fromDateString,
|
||||
})
|
||||
redemption,
|
||||
}
|
||||
|
||||
const roomsAvailability = redemption
|
||||
? trpc.hotel.availability.roomsCombinedAvailabilityWithRedemption.useQuery(
|
||||
params
|
||||
)
|
||||
: trpc.hotel.availability.roomsCombinedAvailability.useQuery(params)
|
||||
|
||||
|
||||
return roomsAvailability
|
||||
}
|
||||
|
||||
export function useHotelPackages(
|
||||
|
||||
@@ -648,6 +648,7 @@
|
||||
"Restaurants": "Restauranter",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Gentag den nye adgangskode",
|
||||
"Reward night": "Bonusnat",
|
||||
"Room": "Værelse",
|
||||
"Room & Terms": "Værelse & Vilkår",
|
||||
"Room amenities": "Værelsesfaciliteter",
|
||||
|
||||
@@ -647,6 +647,7 @@
|
||||
"Restaurants": "Restaurants",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Neues Passwort erneut eingeben",
|
||||
"Reward night": "Bonusnacht",
|
||||
"Room": "Zimmer",
|
||||
"Room & Terms": "Zimmer & Bedingungen",
|
||||
"Room amenities": "Zimmerausstattung",
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"Restaurants": "Restaurants",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Retype new password",
|
||||
"Reward night": "Reward night",
|
||||
"Room": "Room",
|
||||
"Room & Terms": "Room & Terms",
|
||||
"Room amenities": "Room amenities",
|
||||
|
||||
@@ -646,6 +646,7 @@
|
||||
"Restaurants": "Ravintolat",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Kirjoita uusi salasana uudelleen",
|
||||
"Reward night": "Palkintoyö",
|
||||
"Room": "Huone",
|
||||
"Room & Terms": "Huone & Ehdot",
|
||||
"Room amenities": "Huoneen mukavuudet",
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
"Restaurants": "Restauranter",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Skriv inn nytt passord på nytt",
|
||||
"Reward night": "Bonusnatt",
|
||||
"Room": "Rom",
|
||||
"Room & Terms": "Rom & Vilkår",
|
||||
"Room amenities": "Romfasiliteter",
|
||||
|
||||
@@ -645,6 +645,7 @@
|
||||
"Restaurants": "Restauranger",
|
||||
"Restaurants & Bars": "Restaurants & Bars",
|
||||
"Retype new password": "Upprepa nytt lösenord",
|
||||
"Reward night": "Bonusnatt",
|
||||
"Room": "Rum",
|
||||
"Room & Terms": "Rum & Villkor",
|
||||
"Room amenities": "Bekvämligheter på rummet",
|
||||
|
||||
@@ -88,7 +88,11 @@ export const getSelectedRoomAvailability = cache(
|
||||
function getMemoizedSelectedRoomAvailability(
|
||||
input: GetSelectedRoomAvailabilityInput
|
||||
) {
|
||||
return serverClient().hotel.availability.room(input)
|
||||
if (input.redemption) {
|
||||
return serverClient().hotel.availability.roomWithRedemption(input)
|
||||
} else {
|
||||
return serverClient().hotel.availability.room(input)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ export default function RoomProvider({
|
||||
)
|
||||
const selectFilter = useRatesStore((state) => state.actions.selectFilter(idx))
|
||||
const selectRate = useRatesStore((state) => state.actions.selectRate(idx))
|
||||
const selectRateRedemption = useRatesStore((state) =>
|
||||
state.actions.selectRateRedemption(idx)
|
||||
)
|
||||
const roomNr = idx + 1
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
@@ -29,6 +32,7 @@ export default function RoomProvider({
|
||||
modifyRate,
|
||||
selectFilter,
|
||||
selectRate,
|
||||
selectRateRedemption,
|
||||
},
|
||||
isActiveRoom: activeRoom === idx,
|
||||
isMainRoom: roomNr === 1,
|
||||
|
||||
@@ -17,6 +17,7 @@ const roomsSchema = z
|
||||
)
|
||||
.default([]),
|
||||
rateCode: z.string(),
|
||||
redemptionCode: z.string().optional(),
|
||||
roomTypeCode: z.coerce.string(),
|
||||
guest: z.object({
|
||||
becomeMember: z.boolean(),
|
||||
|
||||
@@ -45,6 +45,7 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
|
||||
rateCode: z.string().optional(),
|
||||
roomStayEndDate: z.string(),
|
||||
roomStayStartDate: z.string(),
|
||||
redemption: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const selectedRoomAvailabilityInputSchema = z.object({
|
||||
@@ -59,6 +60,7 @@ export const selectedRoomAvailabilityInputSchema = z.object({
|
||||
counterRateCode: z.string().optional(),
|
||||
packageCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
|
||||
lang: z.nativeEnum(Lang).optional(),
|
||||
redemption: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export type GetSelectedRoomAvailabilityInput = z.input<
|
||||
|
||||
@@ -151,7 +151,7 @@ function sortRoomConfigs(a: RoomConfiguration, b: RoomConfiguration) {
|
||||
return statusLookup[a.status] - statusLookup[b.status]
|
||||
}
|
||||
|
||||
export const roomsCombinedAvailabilitySchema = z
|
||||
const baseRoomsCombinedAvailabilitySchema = z
|
||||
.object({
|
||||
data: z.object({
|
||||
attributes: z.object({
|
||||
@@ -204,83 +204,86 @@ export const roomsCombinedAvailabilitySchema = z
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.transform(({ data: { attributes } }) => {
|
||||
const rateDefinitions = attributes.rateDefinitions
|
||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
||||
// @ts-expect-error - index of cancellationRule TS
|
||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const roomConfigurations = attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
if (room.products.length) {
|
||||
room.breakfastIncludedInAllRatesMember = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
|
||||
)
|
||||
room.breakfastIncludedInAllRatesPublic = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
|
||||
)
|
||||
function transformRoomConfigs({
|
||||
data: { attributes },
|
||||
}: typeof baseRoomsCombinedAvailabilitySchema._type) {
|
||||
const rateDefinitions = attributes.rateDefinitions
|
||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
||||
// @ts-expect-error - index of cancellationRule TS
|
||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
room.products = room.products.map((product) => {
|
||||
const publicRate = product.public
|
||||
if (publicRate?.rateCode) {
|
||||
const publicRateDefinition = rateDefinitions.find(
|
||||
(rateDefinition) =>
|
||||
rateDefinition.rateCode === publicRate.rateCode
|
||||
)
|
||||
if (publicRateDefinition) {
|
||||
const rate = getRate(publicRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
const roomConfigurations = attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
if (room.products.length) {
|
||||
room.breakfastIncludedInAllRatesMember = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
|
||||
)
|
||||
room.breakfastIncludedInAllRatesPublic = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
|
||||
)
|
||||
|
||||
room.products = room.products.map((product) => {
|
||||
const publicRate = product.public
|
||||
if (publicRate?.rateCode) {
|
||||
const publicRateDefinition = rateDefinitions.find(
|
||||
(rateDefinition) =>
|
||||
rateDefinition.rateCode === publicRate.rateCode
|
||||
)
|
||||
if (publicRateDefinition) {
|
||||
const rate = getRate(publicRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const memberRate = product.member
|
||||
if (memberRate?.rateCode) {
|
||||
const memberRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === memberRate.rateCode
|
||||
)
|
||||
if (memberRateDefinition) {
|
||||
const rate = getRate(memberRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
const memberRate = product.member
|
||||
if (memberRate?.rateCode) {
|
||||
const memberRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === memberRate.rateCode
|
||||
)
|
||||
if (memberRateDefinition) {
|
||||
const rate = getRate(memberRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return product
|
||||
})
|
||||
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]
|
||||
)
|
||||
}
|
||||
// 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
|
||||
})
|
||||
.sort(sortRoomConfigs)
|
||||
return room
|
||||
})
|
||||
.sort(sortRoomConfigs)
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
roomConfigurations,
|
||||
}
|
||||
})
|
||||
return {
|
||||
...attributes,
|
||||
roomConfigurations,
|
||||
}
|
||||
}
|
||||
|
||||
export const roomsAvailabilitySchema = z
|
||||
.object({
|
||||
@@ -298,85 +301,30 @@ export const roomsAvailabilitySchema = z
|
||||
type: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
.transform(({ data: { attributes } }) => {
|
||||
const rateDefinitions = attributes.rateDefinitions
|
||||
const cancellationRuleLookup = rateDefinitions.reduce((acc, val) => {
|
||||
// @ts-expect-error - index of cancellationRule TS
|
||||
acc[val.rateCode] = cancellationRules[val.cancellationRule]
|
||||
return acc
|
||||
}, {})
|
||||
.transform(transformRoomConfigs)
|
||||
|
||||
const roomConfigurations = attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
if (room.products.length) {
|
||||
room.breakfastIncludedInAllRatesMember = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "member")
|
||||
)
|
||||
room.breakfastIncludedInAllRatesPublic = room.products.every(
|
||||
(product) =>
|
||||
everyRateHasBreakfastIncluded(product, rateDefinitions, "public")
|
||||
)
|
||||
export const roomsCombinedAvailabilitySchema =
|
||||
baseRoomsCombinedAvailabilitySchema.transform(transformRoomConfigs)
|
||||
|
||||
export const redemptionRoomsCombinedAvailabilitySchema =
|
||||
baseRoomsCombinedAvailabilitySchema.transform((data) => {
|
||||
// In Redemption, rates are always Flex terms
|
||||
data.data.attributes.roomConfigurations =
|
||||
data.data.attributes.roomConfigurations
|
||||
.map((room) => {
|
||||
room.products = room.products.map((product) => {
|
||||
const publicRate = product.public
|
||||
if (publicRate?.rateCode) {
|
||||
const publicRateDefinition = rateDefinitions.find(
|
||||
(rateDefinition) =>
|
||||
rateDefinition.rateCode === publicRate.rateCode
|
||||
)
|
||||
if (publicRateDefinition) {
|
||||
const rate = getRate(publicRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const memberRate = product.member
|
||||
if (memberRate?.rateCode) {
|
||||
const memberRateDefinition = rateDefinitions.find(
|
||||
(rate) => rate.rateCode === memberRate.rateCode
|
||||
)
|
||||
if (memberRateDefinition) {
|
||||
const rate = getRate(memberRateDefinition)
|
||||
if (rate) {
|
||||
product.rate = rate
|
||||
if (rate === "flex") {
|
||||
product.isFlex = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
product.rate = "flex"
|
||||
product.isFlex = true
|
||||
return product
|
||||
})
|
||||
return room
|
||||
})
|
||||
.sort(
|
||||
// @ts-expect-error - array indexing
|
||||
(a, b) => statusLookup[a.status] - statusLookup[b.status]
|
||||
)
|
||||
|
||||
// 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
|
||||
})
|
||||
.sort(
|
||||
// @ts-expect-error - array indexing
|
||||
(a, b) => statusLookup[a.status] - statusLookup[b.status]
|
||||
)
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
roomConfigurations,
|
||||
}
|
||||
return transformRoomConfigs(data)
|
||||
})
|
||||
|
||||
export const ratesSchema = z.array(rateSchema)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { badRequestError } from "@/server/errors/trpc"
|
||||
import {
|
||||
contentStackBaseWithServiceProcedure,
|
||||
protectedProcedure,
|
||||
protectedServcieProcedure,
|
||||
publicProcedure,
|
||||
router,
|
||||
safeProtectedServiceProcedure,
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
hotelSchema,
|
||||
packagesSchema,
|
||||
ratesSchema,
|
||||
redemptionRoomsCombinedAvailabilitySchema,
|
||||
roomsAvailabilitySchema,
|
||||
roomsCombinedAvailabilitySchema,
|
||||
} from "./output"
|
||||
@@ -72,9 +74,12 @@ import type { HotelDataWithUrl } from "@/types/hotel"
|
||||
import type {
|
||||
HotelsAvailabilityInputSchema,
|
||||
HotelsByHotelIdsAvailabilityInputSchema,
|
||||
RoomsCombinedAvailabilityInputSchema,
|
||||
SelectedRoomAvailabilitySchema,
|
||||
} from "@/types/trpc/routers/hotel/availability"
|
||||
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
|
||||
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
|
||||
import type { Lang } from "@/constants/routes/hotelReservation"
|
||||
|
||||
export const getHotel = cache(
|
||||
async (input: HotelInput, serviceToken: string) => {
|
||||
@@ -467,6 +472,346 @@ 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,
|
||||
} = input
|
||||
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
...(redemption && { isRedemption: "true" }),
|
||||
language: toApiLang(lang),
|
||||
}
|
||||
|
||||
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: lang,
|
||||
},
|
||||
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)
|
||||
)
|
||||
|
||||
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 ?? "",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
availability: router({
|
||||
hotelsByCity: serviceProcedure
|
||||
@@ -497,344 +842,31 @@ export const hotelQueryRouter = router({
|
||||
|
||||
roomsCombinedAvailability: serviceProcedure
|
||||
.input(roomsCombinedAvailabilityInputSchema)
|
||||
.query(
|
||||
async ({
|
||||
ctx,
|
||||
input: {
|
||||
adultsCount,
|
||||
bookingCode,
|
||||
childArray,
|
||||
hotelId,
|
||||
lang,
|
||||
rateCode,
|
||||
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,
|
||||
}
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
},
|
||||
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 =
|
||||
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)
|
||||
|
||||
const data = availabilityResponses.map((availability) => {
|
||||
if (availability.status === "fulfilled") {
|
||||
return availability.value
|
||||
}
|
||||
return {
|
||||
details: availability.reason,
|
||||
error: "request_failure",
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
),
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomsCombinedAvailability(input, ctx.serviceToken)
|
||||
}),
|
||||
roomsCombinedAvailabilityWithRedemption: protectedProcedure
|
||||
.input(roomsCombinedAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomsCombinedAvailability(
|
||||
input,
|
||||
ctx.session.token.access_token
|
||||
)
|
||||
}),
|
||||
room: serviceProcedure
|
||||
.input(selectedRoomAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { lang } = input
|
||||
|
||||
const {
|
||||
hotelId,
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
children,
|
||||
bookingCode,
|
||||
rateCode,
|
||||
counterRateCode,
|
||||
roomTypeCode,
|
||||
} = input
|
||||
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
roomStayStartDate,
|
||||
roomStayEndDate,
|
||||
adults,
|
||||
...(children && { children }),
|
||||
...(bookingCode && { bookingCode }),
|
||||
language: lang ?? toApiLang(ctx.lang),
|
||||
}
|
||||
|
||||
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 ${ctx.serviceToken}`,
|
||||
},
|
||||
},
|
||||
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: lang ?? ctx.lang,
|
||||
},
|
||||
return getRoomAvailability(input, ctx.lang, ctx.serviceToken)
|
||||
}),
|
||||
roomWithRedemption: protectedServcieProcedure
|
||||
.input(selectedRoomAvailabilityInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
return getRoomAvailability(
|
||||
input,
|
||||
ctx.lang,
|
||||
ctx.session.token.access_token,
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 ?? "",
|
||||
isFlexRate:
|
||||
rateDefinition?.cancellationRule ===
|
||||
CancellationRuleEnum.CancellableBefore6PM,
|
||||
memberMustBeGuaranteed: !!memberRateDefinition?.mustBeGuaranteed,
|
||||
memberRate: rates?.member,
|
||||
mustBeGuaranteed: !!rateDefinition?.mustBeGuaranteed,
|
||||
publicRate: rates?.public,
|
||||
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,
|
||||
}
|
||||
}),
|
||||
hotelsByCityWithBookingCode: serviceProcedure
|
||||
.input(hotelsAvailabilityInputSchema)
|
||||
|
||||
@@ -9,8 +9,6 @@ export const productTypeSchema = z
|
||||
.object({
|
||||
public: productTypePriceSchema.optional(),
|
||||
member: productTypePriceSchema.optional(),
|
||||
redemption: productTypePointsSchema.optional(),
|
||||
redemptionA: productTypePointsSchema.optional(),
|
||||
redemptionB: productTypePointsSchema.optional(),
|
||||
redemptions: z.array(productTypePointsSchema).optional(),
|
||||
})
|
||||
.optional()
|
||||
|
||||
@@ -10,22 +10,31 @@ export const priceSchema = z.object({
|
||||
regularPricePerStay: z.coerce.number().optional(),
|
||||
})
|
||||
|
||||
export const pointsSchema = z.object({
|
||||
currency: z.nativeEnum(CurrencyEnum).optional(),
|
||||
pricePerNight: z.coerce.number().optional(),
|
||||
pricePerStay: z.coerce.number().optional(),
|
||||
pointsPerNight: z.number(),
|
||||
pointsPerStay: z.number(),
|
||||
})
|
||||
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,
|
||||
}))
|
||||
|
||||
const partialPriceSchema = z.object({
|
||||
rateCode: z.string(),
|
||||
rateType: z.string().optional(),
|
||||
requestedPrice: priceSchema.optional(),
|
||||
})
|
||||
|
||||
export const productTypePriceSchema = partialPriceSchema.extend({
|
||||
localPrice: priceSchema,
|
||||
requestedPrice: priceSchema.optional(),
|
||||
})
|
||||
|
||||
export const productTypePointsSchema = partialPriceSchema.extend({
|
||||
|
||||
@@ -29,21 +29,25 @@ export const roomConfigurationSchema = z
|
||||
})
|
||||
.transform((data) => {
|
||||
if (data.products.length) {
|
||||
/**
|
||||
* Just guaranteeing that if all products all miss
|
||||
* both public and member rateCode that status is
|
||||
* set to `NotAvailable`
|
||||
*/
|
||||
const allProductsMissBothRateCodes = data.products.every(
|
||||
(product) => !product.public?.rateCode && !product.member?.rateCode
|
||||
)
|
||||
if (allProductsMissBothRateCodes) {
|
||||
return {
|
||||
...data,
|
||||
status: AvailabilityEnum.NotAvailable,
|
||||
if (data.products[0].redemptions) {
|
||||
// No need of rate check in reward night scenario
|
||||
return { ...data }
|
||||
} else {
|
||||
/**
|
||||
* Just guaranteeing that if all products all miss
|
||||
* both public and member rateCode that status is
|
||||
* set to `NotAvailable`
|
||||
*/
|
||||
const allProductsMissBothRateCodes = data.products.every(
|
||||
(product) => !product.public?.rateCode && !product.member?.rateCode
|
||||
)
|
||||
if (allProductsMissBothRateCodes) {
|
||||
return {
|
||||
...data,
|
||||
status: AvailabilityEnum.NotAvailable,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { productTypePriceSchema } from "../productTypePrice"
|
||||
import {
|
||||
productTypePointsSchema,
|
||||
productTypePriceSchema,
|
||||
} from "../productTypePrice"
|
||||
|
||||
export const productSchema = z
|
||||
.object({
|
||||
@@ -9,6 +12,7 @@ export const productSchema = z
|
||||
productType: z.object({
|
||||
member: productTypePriceSchema.optional(),
|
||||
public: productTypePriceSchema.optional(),
|
||||
redemptions: z.array(productTypePointsSchema).optional(),
|
||||
}),
|
||||
// Used to set the rate that we use to chose titles etc.
|
||||
rate: z.enum(["change", "flex", "save"]).default("save"),
|
||||
|
||||
@@ -206,3 +206,6 @@ export const contentStackBaseWithProtectedProcedure =
|
||||
|
||||
export const safeProtectedServiceProcedure =
|
||||
safeProtectedProcedure.unstable_concat(serviceProcedure)
|
||||
|
||||
export const protectedServcieProcedure =
|
||||
protectedProcedure.unstable_concat(serviceProcedure)
|
||||
|
||||
@@ -2,9 +2,10 @@ import isEqual from "fast-deep-equal"
|
||||
|
||||
import { detailsStorageName } from "."
|
||||
|
||||
import type { RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import { type RoomRate } from "@/types/components/hotelReservation/enterDetails/details"
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
@@ -131,18 +132,43 @@ export function getRoomPrice(roomRate: RoomRate, isMember: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
if (roomRate.redemptionRate) {
|
||||
return {
|
||||
// ToDo Handle perNight as undefined
|
||||
perNight: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency:
|
||||
roomRate.redemptionRate.localPrice.currency ?? CurrencyEnum.POINTS,
|
||||
price: roomRate.redemptionRate.localPrice.pointsPerStay,
|
||||
additionalPrice:
|
||||
roomRate.redemptionRate.localPrice.additionalPricePerStay,
|
||||
additionalPriceCurrency:
|
||||
roomRate.redemptionRate.localPrice.additionalPriceCurrency,
|
||||
},
|
||||
},
|
||||
perStay: {
|
||||
requested: undefined,
|
||||
local: {
|
||||
currency:
|
||||
roomRate.redemptionRate.localPrice.currency ?? CurrencyEnum.POINTS,
|
||||
price: roomRate.redemptionRate.localPrice.pointsPerStay,
|
||||
additionalPrice:
|
||||
roomRate.redemptionRate.localPrice.additionalPricePerStay,
|
||||
additionalPriceCurrency:
|
||||
roomRate.redemptionRate.localPrice.additionalPriceCurrency,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unable to calculate RoomPrice since user is neither a member or memberRate is missing, or publicRate is missing`
|
||||
)
|
||||
}
|
||||
|
||||
type TotalPrice = {
|
||||
requested: { currency: string; price: number } | undefined
|
||||
local: { currency: string; price: number; regularPrice?: number }
|
||||
}
|
||||
|
||||
export function getTotalPrice(roomRates: RoomRate[], isMember: boolean) {
|
||||
return roomRates.reduce<TotalPrice>(
|
||||
return roomRates.reduce<Price>(
|
||||
(total, roomRate, idx) => {
|
||||
const isFirstRoom = idx === 0
|
||||
const rate =
|
||||
|
||||
@@ -3,6 +3,7 @@ import { produce } from "immer"
|
||||
import { useContext } from "react"
|
||||
import { create, useStore } from "zustand"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import { DetailsContext } from "@/contexts/Details"
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
} from "./helpers"
|
||||
|
||||
import type { BreakfastPackages } from "@/types/components/hotelReservation/breakfast"
|
||||
import {
|
||||
PointsPriceSchema,
|
||||
type Price} from "@/types/components/hotelReservation/price";
|
||||
import { StepEnum } from "@/types/enums/step"
|
||||
import type {
|
||||
DetailsState,
|
||||
@@ -49,11 +53,20 @@ export function createDetailsStore(
|
||||
breakfastPackages: BreakfastPackages | null
|
||||
) {
|
||||
const isMember = !!user
|
||||
const isRedemption =
|
||||
new URLSearchParams(searchParams).get("searchtype") === REDEMPTION
|
||||
|
||||
const initialTotalPrice = getTotalPrice(
|
||||
initialState.rooms.map((r) => r.roomRate),
|
||||
isMember
|
||||
)
|
||||
let initialTotalPrice: Price
|
||||
if (isRedemption && initialState.rooms[0].roomRate.redemptionRate) {
|
||||
initialTotalPrice = PointsPriceSchema.parse(
|
||||
initialState.rooms[0].roomRate.redemptionRate
|
||||
)
|
||||
} else {
|
||||
initialTotalPrice = getTotalPrice(
|
||||
initialState.rooms.map((r) => r.roomRate),
|
||||
isMember
|
||||
)
|
||||
}
|
||||
|
||||
initialState.rooms.forEach((room) => {
|
||||
if (room.roomFeatures) {
|
||||
|
||||
@@ -91,10 +91,15 @@ export function createRatesStore({
|
||||
rc.products.find(
|
||||
(product) =>
|
||||
product.public?.rateCode === room.rateCode ||
|
||||
product.member?.rateCode === room.rateCode
|
||||
product.member?.rateCode === room.rateCode ||
|
||||
product.redemptions?.find(
|
||||
(redemption) => redemption?.rateCode === room.rateCode
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const redemptionProduct = selectedRoom?.products[0].redemptions?.find(
|
||||
(r) => r?.rateCode === room.rateCode
|
||||
)
|
||||
const product = selectedRoom?.products.find(
|
||||
(p) =>
|
||||
p.public?.rateCode === room.rateCode ||
|
||||
@@ -105,10 +110,19 @@ export function createRatesStore({
|
||||
features: selectedRoom.features,
|
||||
member: product.member,
|
||||
public: product.public,
|
||||
redemption: undefined,
|
||||
rate: product.rate,
|
||||
roomType: selectedRoom.roomType,
|
||||
roomTypeCode: selectedRoom.roomTypeCode,
|
||||
}
|
||||
} else if (selectedRoom && redemptionProduct) {
|
||||
rateSummary[idx] = {
|
||||
features: selectedRoom.features,
|
||||
redemption: redemptionProduct,
|
||||
rate: selectedRoom?.products[0].rate,
|
||||
roomType: selectedRoom.roomType,
|
||||
roomTypeCode: selectedRoom.roomTypeCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -200,6 +214,7 @@ export function createRatesStore({
|
||||
package: state.rooms[idx].selectedPackage,
|
||||
rate: selectedRate.product.rate,
|
||||
public: selectedRate.product.public,
|
||||
redemption: undefined,
|
||||
roomType: selectedRate.roomType,
|
||||
roomTypeCode: selectedRate.roomTypeCode,
|
||||
}
|
||||
@@ -240,6 +255,51 @@ export function createRatesStore({
|
||||
state.activeRoom = idx + 1
|
||||
}
|
||||
|
||||
state.searchParams = new ReadonlyURLSearchParams(searchParams)
|
||||
window.history.pushState(
|
||||
{},
|
||||
"",
|
||||
`${state.pathname}?${searchParams}`
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
selectRateRedemption(idx) {
|
||||
return function (selectedRate, selectedRateCode?: string) {
|
||||
return set(
|
||||
produce((state: RatesState) => {
|
||||
const redemptionRate = selectedRate.product.redemptions?.find(
|
||||
(r) => r?.rateCode === selectedRateCode
|
||||
)
|
||||
if (!redemptionRate) {
|
||||
return
|
||||
}
|
||||
|
||||
state.rooms[idx].selectedRate = selectedRate
|
||||
state.rateSummary[idx] = {
|
||||
features: selectedRate.features,
|
||||
package: state.rooms[idx].selectedPackage,
|
||||
rate: selectedRate.product.rate,
|
||||
roomType: selectedRate.roomType,
|
||||
roomTypeCode: selectedRate.roomTypeCode,
|
||||
redemption: redemptionRate,
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(state.searchParams)
|
||||
|
||||
if (redemptionRate.rateCode) {
|
||||
searchParams.set(
|
||||
`room[${idx}].ratecode`,
|
||||
redemptionRate.rateCode
|
||||
)
|
||||
}
|
||||
|
||||
searchParams.set(
|
||||
`room[${idx}].roomtype`,
|
||||
selectedRate.roomTypeCode
|
||||
)
|
||||
|
||||
state.searchParams = new ReadonlyURLSearchParams(searchParams)
|
||||
window.history.pushState(
|
||||
{},
|
||||
@@ -291,11 +351,11 @@ export function createRatesStore({
|
||||
selectedRate:
|
||||
selectedRate && product
|
||||
? {
|
||||
features: selectedRate.features,
|
||||
product,
|
||||
roomType: selectedRate.roomType,
|
||||
roomTypeCode: selectedRate.roomTypeCode,
|
||||
}
|
||||
features: selectedRate.features,
|
||||
product,
|
||||
roomType: selectedRate.roomType,
|
||||
roomTypeCode: selectedRate.roomTypeCode,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -7,11 +7,13 @@ import type {
|
||||
guestDetailsSchema,
|
||||
signedInDetailsSchema,
|
||||
} from "@/components/HotelReservation/EnterDetails/Details/RoomOne/schema"
|
||||
import type { productTypePointsSchema } from "@/server/routers/hotels/schemas/productTypePrice"
|
||||
import type { Price } from "../price"
|
||||
|
||||
export type DetailsSchema = z.output<typeof guestDetailsSchema>
|
||||
export type MultiroomDetailsSchema = z.output<typeof multiroomDetailsSchema>
|
||||
export type SignedInDetailsSchema = z.output<typeof signedInDetailsSchema>
|
||||
export type ProductTypePointsSchema = z.output<typeof productTypePointsSchema>
|
||||
|
||||
export interface RoomPrice {
|
||||
perNight: Price
|
||||
@@ -29,4 +31,5 @@ export type JoinScandicFriendsCardProps = {
|
||||
export type RoomRate = {
|
||||
memberRate?: Product["member"]
|
||||
publicRate?: Product["public"]
|
||||
redemptionRate?: ProductTypePointsSchema
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
|
||||
interface TPrice {
|
||||
currency: string
|
||||
price: number
|
||||
regularPrice?: number
|
||||
additionalPrice?: number
|
||||
additionalPriceCurrency?: string
|
||||
}
|
||||
|
||||
export interface Price {
|
||||
requested: TPrice | undefined
|
||||
requested?: TPrice
|
||||
local: TPrice
|
||||
}
|
||||
|
||||
export const PointsPriceSchema = z
|
||||
.object({
|
||||
localPrice: z.object({
|
||||
currency: z.nativeEnum(CurrencyEnum),
|
||||
price: z.number(),
|
||||
additionalPrice: z.number().optional(),
|
||||
additionalPriceCurrency: z.nativeEnum(CurrencyEnum).optional(),
|
||||
}),
|
||||
})
|
||||
.transform((data) => ({
|
||||
local: {
|
||||
...data.localPrice,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type {
|
||||
ProductTypePoints,
|
||||
ProductTypePrices,
|
||||
} from "@/types/trpc/routers/hotel/availability"
|
||||
import type { ProductTypePrices } from "@/types/trpc/routers/hotel/availability"
|
||||
|
||||
export type PriceCardProps = {
|
||||
productTypePrices: ProductTypePrices
|
||||
isMemberPrice?: boolean
|
||||
}
|
||||
|
||||
export type PointsCardProps = {
|
||||
productTypePoints?: ProductTypePoints
|
||||
redemptionPrice?: number
|
||||
export type PointsRowProps = {
|
||||
pointsPerStay: number
|
||||
additionalPricePerStay?: number
|
||||
additionalPriceCurrency?: string
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ export type FlexibilityOptionProps = {
|
||||
roomType: RoomConfiguration["roomType"]
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
title: string
|
||||
rateTitle?: string // This is for the rates via booking codes
|
||||
rateName?: string // Obtained in case of booking code and redemption rates
|
||||
}
|
||||
|
||||
export interface PriceListProps {
|
||||
publicPrice?: ProductPrice | Record<string, never>
|
||||
memberPrice?: ProductPrice | Record<string, never>
|
||||
petRoomPackage?: RoomPackage
|
||||
rateTitle?: string // This is for the rates via booking codes
|
||||
rateName?: string // Obtained in case of booking code and redemption rates
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
RoomConfiguration,
|
||||
} from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
import type { ChildBedMapEnum } from "../../bookingWidget/enums"
|
||||
import type { ProductTypePointsSchema } from "../enterDetails/details"
|
||||
import type { RoomPackageCodeEnum } from "./roomFilter"
|
||||
|
||||
export interface Child {
|
||||
@@ -43,20 +44,14 @@ export type Rate = {
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
} & (
|
||||
| {
|
||||
member?: undefined
|
||||
public?: undefined
|
||||
member?: NonNullable<Product["member"]>
|
||||
public?: NonNullable<Product["public"]>
|
||||
redemption?: never
|
||||
}
|
||||
| {
|
||||
member?: never
|
||||
public: NonNullable<Product["public"]>
|
||||
}
|
||||
| {
|
||||
member: NonNullable<Product["member"]>
|
||||
public?: never
|
||||
}
|
||||
| {
|
||||
member: NonNullable<Product["member"]>
|
||||
public: NonNullable<Product["public"]>
|
||||
redemption: NonNullable<ProductTypePointsSchema>
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface RoomContextValue extends SelectedRoom {
|
||||
modifyRate: () => void
|
||||
selectFilter: (code: RoomPackageCodeEnum | undefined) => void
|
||||
selectRate: (rate: SelectedRate) => void
|
||||
selectRateRedemption: (
|
||||
rate: SelectedRate,
|
||||
selectedRateCode?: string
|
||||
) => void
|
||||
}
|
||||
isActiveRoom: boolean
|
||||
isMainRoom: boolean
|
||||
|
||||
@@ -4,5 +4,6 @@ export enum CurrencyEnum {
|
||||
NOK = "NOK",
|
||||
PLN = "PLN",
|
||||
SEK = "SEK",
|
||||
POINTS = "POINTS",
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Actions {
|
||||
modifyRate: (idx: number) => () => void
|
||||
selectFilter: (idx: number) => (code: RoomPackageCodeEnum | undefined) => void
|
||||
selectRate: (idx: number) => (rate: SelectedRate) => void
|
||||
selectRateRedemption: (
|
||||
idx: number
|
||||
) => (rate: SelectedRate, selectedRateCode?: string) => void
|
||||
}
|
||||
|
||||
export interface SelectedRate {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
type getHotelsByHotelIdsAvailabilityInputSchema,
|
||||
type hotelsAvailabilityInputSchema,
|
||||
type roomsCombinedAvailabilityInputSchema,
|
||||
type selectedRoomAvailabilityInputSchema,
|
||||
} from "@/server/routers/hotels/input"
|
||||
|
||||
import type { z } from "zod"
|
||||
|
||||
import type {
|
||||
getHotelsByHotelIdsAvailabilityInputSchema,
|
||||
hotelsAvailabilityInputSchema,
|
||||
} from "@/server/routers/hotels/input"
|
||||
import type { hotelsAvailabilitySchema } from "@/server/routers/hotels/output"
|
||||
import type { productTypeSchema } from "@/server/routers/hotels/schemas/availability/productType"
|
||||
import type {
|
||||
@@ -18,6 +21,12 @@ export type HotelsAvailabilityInputSchema = z.output<
|
||||
export type HotelsByHotelIdsAvailabilityInputSchema = z.output<
|
||||
typeof getHotelsByHotelIdsAvailabilityInputSchema
|
||||
>
|
||||
export type RoomsCombinedAvailabilityInputSchema = z.output<
|
||||
typeof roomsCombinedAvailabilityInputSchema
|
||||
>
|
||||
export type SelectedRoomAvailabilitySchema = z.output<
|
||||
typeof selectedRoomAvailabilityInputSchema
|
||||
>
|
||||
export type ProductType = z.output<typeof productTypeSchema>
|
||||
export type ProductTypePrices = z.output<typeof productTypePriceSchema>
|
||||
export type ProductTypePoints = z.output<typeof productTypePointsSchema>
|
||||
|
||||
@@ -22,3 +22,19 @@ export function formatPrice(intl: IntlShape, price: number, currency: string) {
|
||||
})
|
||||
return `${localizedPrice} ${currency}`
|
||||
}
|
||||
|
||||
// This will handle redemption and bonus cheque (corporate cheque) scneario with partial payments
|
||||
export function formatPriceWithAdditionalPrice(
|
||||
intl: IntlShape,
|
||||
points: number,
|
||||
pointsCurrency: string,
|
||||
additionalPrice?: number,
|
||||
additionalPriceCurrency?: string
|
||||
) {
|
||||
const formattedAdditionalPrice =
|
||||
additionalPrice && additionalPriceCurrency
|
||||
? `+ ${formatPrice(intl, additionalPrice, additionalPriceCurrency)}`
|
||||
: ""
|
||||
|
||||
return `${formatPrice(intl, points, pointsCurrency)} ${formattedAdditionalPrice}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user