Merged in feat/SW-2113-allow-feature-combinations (pull request #1719)
Feat/SW-2113 allow feature combinations * feat(SW-2113): Refactor features data to be fetched on filter room filter change * feat(SW-2113): added loading state * fix: now clear room selection when applying filter and room doesnt exists. And added room features to mobile summary * fix * fix: add package to price details * feat(SW-2113): added buttons to room filter * fix: active room * fix: remove console log * fix: added form and close handler to room package filter * fix: add restriction so you cannot select pet room with allergy room and vice versa * fix: fixes from review feedback * fix * fix: hide modify button if on nextcoming rooms if no selection is made, and adjust filter logic in togglePackage * fix: forgot to use roomFeatureCodes from input.. * fix: naming Approved-by: Simon.Emanuelsson
This commit is contained in:
@@ -100,6 +100,10 @@ export default function PriceDetailsTable({
|
||||
const isMainRoom = idx === 0
|
||||
const getMemberRate = isMainRoom && isMember
|
||||
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
let price
|
||||
if (
|
||||
getMemberRate &&
|
||||
@@ -130,6 +134,17 @@ export default function PriceDetailsTable({
|
||||
price.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
{room.packages?.map((pkg) => (
|
||||
<Row
|
||||
key={pkg.code}
|
||||
label={pkg.description}
|
||||
value={formatPrice(
|
||||
intl,
|
||||
+pkg.localPrice.totalPrice,
|
||||
pkg.localPrice.currency
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<Row
|
||||
bold
|
||||
label={intl.formatMessage({ id: "Room charge" })}
|
||||
|
||||
@@ -56,10 +56,11 @@ export default function Summary({
|
||||
return null
|
||||
}
|
||||
|
||||
const memberPrice = getMemberPrice(rooms[0].roomRate)
|
||||
const memberPrice =
|
||||
rooms.length === 1 && rooms[0] ? getMemberPrice(rooms[0].roomRate) : null
|
||||
|
||||
const containsBookingCodeRate = rooms.find((r) =>
|
||||
isBookingCodeRate(r.roomRate)
|
||||
const containsBookingCodeRate = rooms.find(
|
||||
(r) => r && isBookingCodeRate(r.roomRate)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isMember
|
||||
|
||||
@@ -93,6 +94,10 @@ export default function Summary({
|
||||
</header>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
{rooms.map((room, idx) => {
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roomNumber = idx + 1
|
||||
const adults = room.adults
|
||||
const childrenInRoom = room.childrenInRoom
|
||||
@@ -137,6 +142,8 @@ export default function Summary({
|
||||
guestsParts.push(childrenMsg)
|
||||
}
|
||||
|
||||
const roomPackages = room.packages
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
<div
|
||||
@@ -245,6 +252,21 @@ export default function Summary({
|
||||
</Body>
|
||||
</div>
|
||||
) : null}
|
||||
{roomPackages?.map((pkg) => (
|
||||
<div className={styles.entry} key={pkg.code}>
|
||||
<div>
|
||||
<Body color="uiTextHighContrast">{pkg.description}</Body>
|
||||
</div>
|
||||
|
||||
<Body color="uiTextHighContrast">
|
||||
{formatPrice(
|
||||
intl,
|
||||
pkg.localPrice.price,
|
||||
pkg.localPrice.currency
|
||||
)}
|
||||
</Body>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider color="primaryLightSubtle" />
|
||||
</Fragment>
|
||||
|
||||
@@ -27,14 +27,21 @@ export default function MobileSummary({
|
||||
const scrollY = useRef(0)
|
||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false)
|
||||
|
||||
const { booking, bookingRooms, roomsAvailability, rateSummary, vat } =
|
||||
useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
bookingRooms: state.booking.rooms,
|
||||
roomsAvailability: state.roomsAvailability,
|
||||
rateSummary: state.rateSummary,
|
||||
vat: state.vat,
|
||||
}))
|
||||
const {
|
||||
booking,
|
||||
bookingRooms,
|
||||
roomsAvailability,
|
||||
rateSummary,
|
||||
vat,
|
||||
packages,
|
||||
} = useRatesStore((state) => ({
|
||||
booking: state.booking,
|
||||
bookingRooms: state.booking.rooms,
|
||||
roomsAvailability: state.roomsAvailability,
|
||||
rateSummary: state.rateSummary,
|
||||
vat: state.vat,
|
||||
packages: state.packages,
|
||||
}))
|
||||
|
||||
function toggleSummaryOpen() {
|
||||
setIsSummaryOpen(!isSummaryOpen)
|
||||
@@ -71,11 +78,11 @@ export default function MobileSummary({
|
||||
}
|
||||
|
||||
const rooms = rateSummary.map((room, index) =>
|
||||
mapRate(room, index, bookingRooms)
|
||||
room ? mapRate(room, index, bookingRooms, packages) : null
|
||||
)
|
||||
|
||||
const containsBookingCodeRate = rateSummary.find((r) =>
|
||||
isBookingCodeRate(r.product)
|
||||
const containsBookingCodeRate = rateSummary.find(
|
||||
(r) => r && isBookingCodeRate(r.product)
|
||||
)
|
||||
const showDiscounted = containsBookingCodeRate || isUserLoggedIn
|
||||
|
||||
|
||||
@@ -3,8 +3,18 @@ import type {
|
||||
Room,
|
||||
} from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
|
||||
export function mapRate(
|
||||
room: Rate,
|
||||
index: number,
|
||||
bookingRooms: Room[],
|
||||
packages: NonNullable<Packages>
|
||||
) {
|
||||
const roomPackages = room.packages
|
||||
.map((code) => packages.find((pkg) => pkg.code === code))
|
||||
.filter((pkg): pkg is NonNullable<typeof pkg> => Boolean(pkg))
|
||||
|
||||
export function mapRate(room: Rate, index: number, bookingRooms: Room[]) {
|
||||
const rate = {
|
||||
adults: bookingRooms[index].adults,
|
||||
cancellationText: room.product.rateDefinition?.cancellationText ?? "",
|
||||
@@ -29,6 +39,7 @@ export function mapRate(room: Rate, index: number, bookingRooms: Room[]) {
|
||||
},
|
||||
roomRate: room.product,
|
||||
roomType: room.roomType,
|
||||
packages: roomPackages,
|
||||
}
|
||||
|
||||
if ("corporateCheque" in room.product) {
|
||||
|
||||
@@ -16,16 +16,10 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
|
||||
import { formatPrice } from "@/utils/numberFormatting"
|
||||
|
||||
import MobileSummary from "./MobileSummary"
|
||||
import {
|
||||
calculateCorporateChequePrice,
|
||||
calculateRedemptionTotalPrice,
|
||||
calculateTotalPrice,
|
||||
calculateVoucherPrice,
|
||||
} from "./utils"
|
||||
import { getTotalPrice } from "./utils"
|
||||
|
||||
import styles from "./rateSummary.module.css"
|
||||
|
||||
import type { Price } from "@/types/components/hotelReservation/price"
|
||||
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { RateEnum } from "@/types/enums/rate"
|
||||
@@ -96,9 +90,10 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
|
||||
|
||||
const totalRoomsRequired = bookingRooms.length
|
||||
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired
|
||||
const isAllRoomsSelected =
|
||||
rateSummary.filter((rate) => rate !== null).length === totalRoomsRequired
|
||||
const hasMemberRates = rateSummary.some(
|
||||
(room) => "member" in room.product && room.product.member
|
||||
(room) => room && "member" in room.product && room.product.member
|
||||
)
|
||||
const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
|
||||
|
||||
@@ -134,12 +129,15 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
|
||||
const isBookingCodeRate = rateSummary.some(
|
||||
(rate) =>
|
||||
rate &&
|
||||
"public" in rate.product &&
|
||||
rate.product.public?.rateType !== RateTypeEnum.Regular
|
||||
)
|
||||
const isVoucherRate = rateSummary.some((rate) => "voucher" in rate.product)
|
||||
const isVoucherRate = rateSummary.some(
|
||||
(rate) => rate && "voucher" in rate.product
|
||||
)
|
||||
const isCorporateChequeRate = rateSummary.some(
|
||||
(rate) => "corporateCheque" in rate.product
|
||||
(rate) => rate && "corporateCheque" in rate.product
|
||||
)
|
||||
const showDiscounted =
|
||||
isUserLoggedIn ||
|
||||
@@ -148,37 +146,29 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
isCorporateChequeRate
|
||||
|
||||
const mainRoomProduct = rateSummary[0]
|
||||
let totalPriceToShow: Price
|
||||
if ("redemption" in mainRoomProduct.product) {
|
||||
// In case of reward night (redemption) only single room booking is supported by business rules
|
||||
totalPriceToShow = calculateRedemptionTotalPrice(
|
||||
mainRoomProduct.product.redemption
|
||||
)
|
||||
} else if ("voucher" in mainRoomProduct.product) {
|
||||
totalPriceToShow = calculateVoucherPrice(rateSummary)
|
||||
} else if ("corporateCheque" in mainRoomProduct.product) {
|
||||
totalPriceToShow = calculateCorporateChequePrice(rateSummary)
|
||||
} else {
|
||||
totalPriceToShow = calculateTotalPrice(
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
petRoomPackage
|
||||
)
|
||||
const totalPriceToShow = getTotalPrice(
|
||||
mainRoomProduct,
|
||||
rateSummary,
|
||||
isUserLoggedIn,
|
||||
petRoomPackage
|
||||
)
|
||||
|
||||
const rateProduct = rateSummary.find((rate) => rate?.product)?.product
|
||||
|
||||
if (!totalPriceToShow || !rateProduct) {
|
||||
return null
|
||||
}
|
||||
|
||||
let mainRoomCurrency = ""
|
||||
if (
|
||||
"member" in mainRoomProduct.product &&
|
||||
mainRoomProduct.product.member?.localPrice
|
||||
) {
|
||||
mainRoomCurrency = mainRoomProduct.product.member.localPrice.currency
|
||||
if ("member" in rateProduct && rateProduct.member?.localPrice) {
|
||||
mainRoomCurrency = rateProduct.member.localPrice.currency
|
||||
}
|
||||
if (
|
||||
!mainRoomCurrency &&
|
||||
"public" in mainRoomProduct.product &&
|
||||
mainRoomProduct.product.public?.localPrice
|
||||
"public" in rateProduct &&
|
||||
rateProduct.public?.localPrice
|
||||
) {
|
||||
mainRoomCurrency = mainRoomProduct.product.public.localPrice.currency
|
||||
mainRoomCurrency = rateProduct.public.localPrice.currency
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -186,38 +176,56 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.summaryText}>
|
||||
{rateSummary.map((room, index) => (
|
||||
<div key={index} className={styles.roomSummary}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{rateSummary.map((room, index) => {
|
||||
if (!room) {
|
||||
return (
|
||||
<div key={`unselected-${index}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
<Body color="uiTextPlaceholder">
|
||||
{intl.formatMessage({ id: "Select room" })}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{rateSummary.length > 1 ? (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
{ roomIndex: index + 1 }
|
||||
)}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">{room.roomType}</Body>
|
||||
<Caption color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Caption>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Subtitle color="uiTextHighContrast">
|
||||
{room.roomType}
|
||||
</Subtitle>
|
||||
<Body color="uiTextMediumContrast">
|
||||
{getRateDetails(room.rate)}
|
||||
</Body>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Render unselected rooms */}
|
||||
{Array.from({
|
||||
length: totalRoomsRequired - rateSummary.length,
|
||||
}).map((_, index) => (
|
||||
<div key={`unselected-${index}`} className={styles.roomSummary}>
|
||||
<div key={`unselected-${index}`}>
|
||||
<Subtitle color="uiTextPlaceholder">
|
||||
{intl.formatMessage(
|
||||
{ id: "Room {roomIndex}" },
|
||||
@@ -235,47 +243,45 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
|
||||
<div className={styles.promoContainer}>
|
||||
<SignupPromoDesktop
|
||||
memberPrice={{
|
||||
amount: rateSummary.reduce(
|
||||
(
|
||||
total,
|
||||
{ features, packages: roomPackages, product }
|
||||
) => {
|
||||
const memberExists =
|
||||
"member" in product && product.member
|
||||
const publicExists =
|
||||
"public" in product && product.public
|
||||
if (!memberExists) {
|
||||
if (!publicExists) {
|
||||
return total
|
||||
}
|
||||
}
|
||||
amount: rateSummary.reduce((total, rate) => {
|
||||
if (!rate) {
|
||||
return total
|
||||
}
|
||||
|
||||
const price =
|
||||
product.member?.localPrice.pricePerStay ||
|
||||
product.public?.localPrice.pricePerStay
|
||||
const { features, packages: roomPackages, product } = rate
|
||||
|
||||
if (!price) {
|
||||
const memberExists = "member" in product && product.member
|
||||
const publicExists = "public" in product && product.public
|
||||
if (!memberExists) {
|
||||
if (!publicExists) {
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
const hasSelectedPetRoom = roomPackages.includes(
|
||||
RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
if (!hasSelectedPetRoom) {
|
||||
return total + price
|
||||
}
|
||||
const isPetRoom = features.find(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice)
|
||||
: 0
|
||||
return total + price + petRoomPrice
|
||||
},
|
||||
0
|
||||
),
|
||||
const price =
|
||||
product.member?.localPrice.pricePerStay ||
|
||||
product.public?.localPrice.pricePerStay
|
||||
|
||||
if (!price) {
|
||||
return total
|
||||
}
|
||||
|
||||
const hasSelectedPetRoom = roomPackages.includes(
|
||||
RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
if (!hasSelectedPetRoom) {
|
||||
return total + price
|
||||
}
|
||||
const isPetRoom = features.find(
|
||||
(feature) =>
|
||||
feature.code === RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
const petRoomPrice =
|
||||
isPetRoom && petRoomPackage
|
||||
? Number(petRoomPackage.localPrice.totalPrice)
|
||||
: 0
|
||||
return total + price + petRoomPrice
|
||||
}, 0),
|
||||
currency: mainRoomCurrency,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import { CurrencyEnum } from "@/types/enums/currency"
|
||||
import type { Packages } from "@/types/requests/packages"
|
||||
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
|
||||
|
||||
export function calculateTotalPrice(
|
||||
@@ -194,3 +195,32 @@ export function calculateCorporateChequePrice(selectedRateSummary: Rate[]) {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function getTotalPrice(
|
||||
mainRoomProduct: Rate | null,
|
||||
rateSummary: Array<Rate | null>,
|
||||
isUserLoggedIn: boolean,
|
||||
petRoomPackage: NonNullable<Packages>[number] | undefined
|
||||
): Price | null {
|
||||
const summaryArray = rateSummary.filter((rate): rate is Rate => rate !== null)
|
||||
|
||||
if (summaryArray.some((rate) => "corporateCheque" in rate.product)) {
|
||||
return calculateCorporateChequePrice(summaryArray)
|
||||
}
|
||||
|
||||
if (!mainRoomProduct) {
|
||||
return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage)
|
||||
}
|
||||
|
||||
const { product } = mainRoomProduct
|
||||
|
||||
// In case of reward night (redemption) or voucher only single room booking is supported by business rules
|
||||
if ("redemption" in product) {
|
||||
return calculateRedemptionTotalPrice(product.redemption)
|
||||
}
|
||||
if ("voucher" in product) {
|
||||
return calculateVoucherPrice(summaryArray)
|
||||
}
|
||||
|
||||
return calculateTotalPrice(summaryArray, isUserLoggedIn, petRoomPackage)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ import { RateEnum } from "@/types/enums/rate"
|
||||
|
||||
export default function SelectedRoomPanel() {
|
||||
const intl = useIntl()
|
||||
const { isUserLoggedIn, roomCategories } = useRatesStore((state) => ({
|
||||
const { isUserLoggedIn, roomCategories, rooms } = useRatesStore((state) => ({
|
||||
isUserLoggedIn: state.isUserLoggedIn,
|
||||
roomCategories: state.roomCategories,
|
||||
rooms: state.rooms,
|
||||
}))
|
||||
const {
|
||||
actions: { modifyRate },
|
||||
@@ -89,6 +90,10 @@ export default function SelectedRoomPanel() {
|
||||
return null
|
||||
}
|
||||
|
||||
const showModifyButton =
|
||||
isMainRoom ||
|
||||
(!isMainRoom && rooms.slice(0, roomNr).every((room) => room.selectedRate))
|
||||
|
||||
return (
|
||||
<div className={styles.selectedRoomPanel}>
|
||||
<div className={styles.content}>
|
||||
@@ -118,14 +123,16 @@ export default function SelectedRoomPanel() {
|
||||
width={600}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button clean onClick={modifyRate}>
|
||||
<Chip size="small" variant="uiTextHighContrast">
|
||||
<MaterialIcon icon="edit_square" />
|
||||
{intl.formatMessage({ id: "Modify" })}
|
||||
</Chip>
|
||||
</Button>
|
||||
</div>
|
||||
{showModifyButton && (
|
||||
<div className={styles.modifyButtonContainer}>
|
||||
<Button clean onClick={modifyRate}>
|
||||
<Chip size="small" variant="uiTextHighContrast">
|
||||
<MaterialIcon icon="edit_square" />
|
||||
{intl.formatMessage({ id: "Modify" })}
|
||||
</Chip>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
background-color: var(--UI-Input-Controls-Fill-Selected);
|
||||
}
|
||||
|
||||
.checkboxWrapper[data-disabled] .checkbox {
|
||||
border-color: var(--UI-Input-Controls-Border-Disabled);
|
||||
background-color: var(--UI-Input-Controls-Surface-Disabled);
|
||||
}
|
||||
|
||||
.checkboxWrapper[data-disabled] .text {
|
||||
color: var(--Base-Text-Disabled);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.checkboxWrapper:hover {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -14,6 +14,7 @@ interface CheckboxProps {
|
||||
value: string
|
||||
isSelected: boolean
|
||||
iconName: MaterialSymbolProps["icon"]
|
||||
isDisabled: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
@@ -22,12 +23,14 @@ export default function Checkbox({
|
||||
name,
|
||||
value,
|
||||
iconName,
|
||||
isDisabled,
|
||||
onChange,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<AriaCheckbox
|
||||
className={styles.checkboxWrapper}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
onChange={() => onChange(value)}
|
||||
>
|
||||
{({ isSelected }) => (
|
||||
@@ -35,7 +38,10 @@ export default function Checkbox({
|
||||
<span className={styles.checkbox}>
|
||||
{isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />}
|
||||
</span>
|
||||
<Typography variant="Body/Paragraph/mdRegular">
|
||||
<Typography
|
||||
variant="Body/Paragraph/mdRegular"
|
||||
className={styles.text}
|
||||
>
|
||||
<span>{name}</span>
|
||||
</Typography>
|
||||
{iconName ? (
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client"
|
||||
import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components"
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Popover,
|
||||
} from "react-aria-components"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { useIntl } from "react-intl"
|
||||
|
||||
import { Button } from "@scandic-hotels/design-system/Button"
|
||||
import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
|
||||
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
|
||||
import { Typography } from "@scandic-hotels/design-system/Typography"
|
||||
@@ -16,20 +24,46 @@ import { getIconNameByPackageCode } from "./utils"
|
||||
|
||||
import styles from "./roomPackageFilter.module.css"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
|
||||
type FormValues = {
|
||||
selectedPackages: RoomPackageCodeEnum[]
|
||||
}
|
||||
|
||||
export default function RoomPackageFilter() {
|
||||
const intl = useIntl()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const packageOptions = useRatesStore((state) => state.packageOptions)
|
||||
const {
|
||||
actions: { togglePackage },
|
||||
actions: { togglePackages },
|
||||
selectedPackages,
|
||||
} = useRoomContext()
|
||||
const intl = useIntl()
|
||||
|
||||
const { setValue, handleSubmit, control } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
selectedPackages: selectedPackages,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setValue("selectedPackages", selectedPackages)
|
||||
}, [selectedPackages, setValue])
|
||||
|
||||
function onSubmit(data: FormValues) {
|
||||
togglePackages(data.selectedPackages)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.roomPackageFilter}>
|
||||
{selectedPackages.map((pkg) => (
|
||||
<Button
|
||||
<AriaButton
|
||||
key={pkg}
|
||||
onPress={() => togglePackage(pkg)}
|
||||
onPress={() => {
|
||||
const packages = selectedPackages.filter((s) => s !== pkg)
|
||||
togglePackages(packages)
|
||||
}}
|
||||
className={styles.activeFilterButton}
|
||||
>
|
||||
<MaterialIcon
|
||||
@@ -42,9 +76,9 @@ export default function RoomPackageFilter() {
|
||||
size={16}
|
||||
color="Icon/Interactive/Default"
|
||||
/>
|
||||
</Button>
|
||||
</AriaButton>
|
||||
))}
|
||||
<DialogTrigger>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ChipButton variant="Outlined">
|
||||
{intl.formatMessage({ id: "Room preferences" })}
|
||||
<MaterialIcon
|
||||
@@ -55,39 +89,105 @@ export default function RoomPackageFilter() {
|
||||
</ChipButton>
|
||||
<Popover placement="bottom end">
|
||||
<Dialog className={styles.dialog}>
|
||||
<div className={styles.filters}>
|
||||
{packageOptions.map((option) => (
|
||||
<Checkbox
|
||||
key={option.code}
|
||||
name={option.description}
|
||||
value={option.code}
|
||||
iconName={getIconNameByPackageCode(option.code)}
|
||||
isSelected={selectedPackages.includes(option.code)}
|
||||
onChange={() => togglePackage(option.code)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Divider color="borderDividerSubtle" />
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.additionalInformation}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span className={styles.additionalInformationPrice}>
|
||||
{str}
|
||||
</span>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedPackages"
|
||||
render={({ field }) => (
|
||||
<div>
|
||||
{packageOptions.map((option) => {
|
||||
const isPetRoom =
|
||||
option.code === RoomPackageCodeEnum.PET_ROOM
|
||||
|
||||
const isAllergyRoom =
|
||||
option.code === RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
|
||||
const hasPetRoom = field.value.includes(
|
||||
RoomPackageCodeEnum.PET_ROOM
|
||||
)
|
||||
|
||||
const hasAllergyRoom = field.value.includes(
|
||||
RoomPackageCodeEnum.ALLERGY_ROOM
|
||||
)
|
||||
|
||||
const isDisabled =
|
||||
(isPetRoom && hasAllergyRoom) ||
|
||||
(isAllergyRoom && hasPetRoom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
key={option.code}
|
||||
name={option.description}
|
||||
value={option.code}
|
||||
iconName={getIconNameByPackageCode(option.code)}
|
||||
isSelected={field.value.includes(option.code)}
|
||||
isDisabled={isDisabled}
|
||||
onChange={() => {
|
||||
const isSelected = field.value.includes(
|
||||
option.code
|
||||
)
|
||||
const newValue = isSelected
|
||||
? field.value.filter(
|
||||
(pkg) => pkg !== option.code
|
||||
)
|
||||
: [...field.value, option.code]
|
||||
field.onChange(newValue)
|
||||
}}
|
||||
/>
|
||||
{option.code === RoomPackageCodeEnum.PET_ROOM && (
|
||||
<Typography variant="Body/Supporting text (caption)/smRegular">
|
||||
<p className={styles.additionalInformation}>
|
||||
{intl.formatMessage(
|
||||
{
|
||||
id: "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.",
|
||||
},
|
||||
{
|
||||
b: (str) => (
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<span
|
||||
className={
|
||||
styles.additionalInformationPrice
|
||||
}
|
||||
>
|
||||
{str}
|
||||
</span>
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<Divider color="borderDividerSubtle" />
|
||||
<div className={styles.buttonContainer}>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button
|
||||
variant="Text"
|
||||
size="Small"
|
||||
onPress={() => {
|
||||
togglePackages([])
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: "Clear" })}
|
||||
</Button>
|
||||
</Typography>
|
||||
<Typography variant="Body/Supporting text (caption)/smBold">
|
||||
<Button variant="Tertiary" size="Small" type="submit">
|
||||
{intl.formatMessage({ id: "Apply" })}
|
||||
</Button>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
.additionalInformation {
|
||||
color: var(--Text-Tertiary);
|
||||
padding: var(--Space-x1) var(--Space-x15);
|
||||
}
|
||||
|
||||
.additionalInformationPrice {
|
||||
@@ -40,3 +41,9 @@
|
||||
border-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.buttonContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { RoomCardSkeleton } from "@/components/HotelReservation/RoomCardSkeleton/RoomCardSkeleton"
|
||||
|
||||
import styles from "./roomsListSkeleton.module.css"
|
||||
|
||||
type Props = {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export function RoomsListSkeleton({ count = 4 }: Props) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.skeletonContainer}>
|
||||
{Array.from({ length: count }).map((_, index) => (
|
||||
<RoomCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
import { useRoomContext } from "@/contexts/SelectRate/Room"
|
||||
|
||||
import RoomListItem from "./RoomListItem"
|
||||
import { RoomsListSkeleton } from "./RoomsListSkeleton"
|
||||
import ScrollToList from "./ScrollToList"
|
||||
|
||||
import styles from "./rooms.module.css"
|
||||
|
||||
export default function RoomsList() {
|
||||
const { rooms } = useRoomContext()
|
||||
const { rooms, isFetchingRoomFeatures } = useRoomContext()
|
||||
|
||||
if (isFetchingRoomFeatures) {
|
||||
return <RoomsListSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToList />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: grid;
|
||||
gap: var(--Spacing-x2);
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roomList > li {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
.container {
|
||||
max-width: var(--max-width-page);
|
||||
}
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
/* used to hide overflowing rows */
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: 0;
|
||||
overflow: hidden;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--Spacing-x2);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client"
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
|
||||
import { dt } from "@/lib/dt"
|
||||
|
||||
import useLang from "@/hooks/useLang"
|
||||
@@ -25,9 +25,6 @@ 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(
|
||||
@@ -37,8 +34,7 @@ export function RoomsContainer({
|
||||
toDateString,
|
||||
lang,
|
||||
childArray,
|
||||
booking.bookingCode,
|
||||
redemption
|
||||
booking
|
||||
)
|
||||
|
||||
const { data: packages, isPending: isLoadingPackages } = useHotelPackages(
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { REDEMPTION } from "@/constants/booking"
|
||||
import { trpc } from "@/lib/trpc/client"
|
||||
|
||||
import { convertSearchParamsToObj, searchParamsToRecord } from "@/utils/url"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
|
||||
import type { Lang } from "@/constants/languages"
|
||||
@@ -17,79 +13,30 @@ export function useRoomsAvailability(
|
||||
toDateString: string,
|
||||
lang: Lang,
|
||||
childArray: ChildrenInRoom,
|
||||
bookingCode?: string,
|
||||
redemption?: boolean
|
||||
booking: SelectRateSearchParams
|
||||
) {
|
||||
const searchParams = useSearchParams()
|
||||
const searchParamsObj = convertSearchParamsToObj<SelectRateSearchParams>(
|
||||
searchParamsToRecord(searchParams)
|
||||
const redemption = booking.searchType
|
||||
? booking.searchType === REDEMPTION
|
||||
: undefined
|
||||
|
||||
const roomFeatureCodesArray = booking.rooms.map(
|
||||
(room) => room.packages ?? null
|
||||
)
|
||||
|
||||
const hasPackagesParam = searchParamsObj.rooms.some((room) => room.packages)
|
||||
const [hasRoomFeatures, setHasRoomFeatures] = useState(hasPackagesParam)
|
||||
|
||||
useEffect(() => {
|
||||
setHasRoomFeatures(hasPackagesParam)
|
||||
}, [hasPackagesParam, setHasRoomFeatures])
|
||||
|
||||
const { data: roomFeatures, isPending: isRoomFeaturesPending } =
|
||||
trpc.hotel.availability.roomFeatures.useQuery(
|
||||
{
|
||||
hotelId,
|
||||
startDate: fromDateString,
|
||||
endDate: toDateString,
|
||||
adultsCount,
|
||||
childArray,
|
||||
},
|
||||
{
|
||||
enabled: hasRoomFeatures,
|
||||
}
|
||||
)
|
||||
|
||||
const { data: roomsAvailability, isPending: isRoomsAvailabiltyPending } =
|
||||
const roomsAvailability =
|
||||
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
|
||||
adultsCount,
|
||||
bookingCode,
|
||||
childArray,
|
||||
hotelId,
|
||||
lang,
|
||||
redemption,
|
||||
roomStayEndDate: toDateString,
|
||||
roomStayStartDate: fromDateString,
|
||||
bookingCode: booking.bookingCode,
|
||||
roomFeatureCodesArray,
|
||||
})
|
||||
|
||||
const combinedData = useMemo(() => {
|
||||
if (!roomsAvailability) {
|
||||
return undefined
|
||||
}
|
||||
if (!roomFeatures) {
|
||||
return roomsAvailability
|
||||
}
|
||||
|
||||
return roomsAvailability.map((room, idx) => {
|
||||
if ("error" in room) {
|
||||
return room
|
||||
}
|
||||
|
||||
return {
|
||||
...room,
|
||||
roomConfigurations: room.roomConfigurations.map((config) => ({
|
||||
...config,
|
||||
features:
|
||||
roomFeatures?.[idx]?.find(
|
||||
(r) => r.roomTypeCode === config.roomTypeCode
|
||||
)?.features ?? [],
|
||||
})),
|
||||
}
|
||||
})
|
||||
}, [roomFeatures, roomsAvailability])
|
||||
|
||||
return {
|
||||
data: combinedData,
|
||||
isPending: hasRoomFeatures
|
||||
? isRoomsAvailabiltyPending || isRoomFeaturesPending
|
||||
: isRoomsAvailabiltyPending,
|
||||
}
|
||||
return roomsAvailability
|
||||
}
|
||||
|
||||
export function useHotelPackages(
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"City pulse": "Byens puls",
|
||||
"City/State": "By/Stat",
|
||||
"Classroom": "Classroom",
|
||||
"Clear": "Ryd",
|
||||
"Clear all filters": "Ryd alle filtre",
|
||||
"Clear searches": "Ryd søgninger",
|
||||
"Click here to log in": "Klik her for at logge ind",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"City pulse": "Stadtpuls",
|
||||
"City/State": "Stadt/Zustand",
|
||||
"Classroom": "Classroom",
|
||||
"Clear": "Löschen",
|
||||
"Clear all filters": "Alle Filter löschen",
|
||||
"Clear searches": "Suche löschen",
|
||||
"Click here to log in": "Klicken Sie hier, um sich einzuloggen",
|
||||
|
||||
@@ -187,6 +187,7 @@
|
||||
"City pulse": "City pulse",
|
||||
"City/State": "City/State",
|
||||
"Classroom": "Classroom",
|
||||
"Clear": "Clear",
|
||||
"Clear all filters": "Clear all filters",
|
||||
"Clear searches": "Clear searches",
|
||||
"Click here to log in": "Click here to log in",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"City pulse": "Kaupungin syke",
|
||||
"City/State": "Kaupunki/Osavaltio",
|
||||
"Classroom": "Classroom",
|
||||
"Clear": "Tyhjennä",
|
||||
"Clear all filters": "Tyhjennä kaikki suodattimet",
|
||||
"Clear searches": "Tyhjennä haut",
|
||||
"Click here to log in": "Napsauta tästä kirjautuaksesi sisään",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"City pulse": "Byens puls",
|
||||
"City/State": "By/Stat",
|
||||
"Classroom": "Classroom",
|
||||
"Clear": "Rens",
|
||||
"Clear all filters": "Fjern alle filtre",
|
||||
"Clear searches": "Tømme søk",
|
||||
"Click here to log in": "Klikk her for å logge inn",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
"City pulse": "Stadspuls",
|
||||
"City/State": "Ort",
|
||||
"Classroom": "Klassrum",
|
||||
"Clear": "Rensa",
|
||||
"Clear all filters": "Rensa alla filter",
|
||||
"Clear searches": "Rensa tidigare sökningar",
|
||||
"Click here to log in": "Klicka här för att logga in",
|
||||
|
||||
@@ -24,14 +24,16 @@ export default function RoomProvider({
|
||||
roomAvailability,
|
||||
searchParams,
|
||||
selectedFilter,
|
||||
selectedPackages,
|
||||
} = useRatesStore((state) => ({
|
||||
activeRoom: state.activeRoom,
|
||||
booking: state.booking,
|
||||
roomAvailability: state.roomsAvailability?.[idx],
|
||||
searchParams: state.searchParams,
|
||||
selectedFilter: state.rooms[idx].selectedFilter,
|
||||
selectedPackages: state.rooms[idx].selectedPackages,
|
||||
}))
|
||||
const { appendRegularRates, ...actions } = room.actions
|
||||
const { appendRegularRates, addRoomFeatures, ...actions } = room.actions
|
||||
const roomNr = idx + 1
|
||||
|
||||
const redemptionSearch = searchParams.has("searchType")
|
||||
@@ -91,6 +93,40 @@ export default function RoomProvider({
|
||||
}
|
||||
}, [appendRegularRates, data, enabled, isFetched, isFetching])
|
||||
|
||||
const {
|
||||
data: roomFeaturesData,
|
||||
isFetched: isRoomFeaturesFetched,
|
||||
isFetching: isRoomFeaturesFetching,
|
||||
} = trpc.hotel.availability.roomFeatures.useQuery(
|
||||
{
|
||||
adults: room.bookingRoom.adults,
|
||||
childrenInRoom: room.bookingRoom.childrenInRoom,
|
||||
hotelId: booking.hotelId,
|
||||
startDate: booking.fromDate,
|
||||
endDate: booking.toDate,
|
||||
roomFeatureCodes: selectedPackages,
|
||||
roomIndex: idx, // Creates a unique query key for each room
|
||||
},
|
||||
{
|
||||
enabled: !!selectedPackages.length,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isRoomFeaturesFetched &&
|
||||
!isRoomFeaturesFetching &&
|
||||
roomFeaturesData?.length
|
||||
) {
|
||||
addRoomFeatures(roomFeaturesData)
|
||||
}
|
||||
}, [
|
||||
addRoomFeatures,
|
||||
roomFeaturesData,
|
||||
isRoomFeaturesFetched,
|
||||
isRoomFeaturesFetching,
|
||||
])
|
||||
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
@@ -98,6 +134,9 @@ export default function RoomProvider({
|
||||
actions,
|
||||
isActiveRoom: activeRoom === idx,
|
||||
isFetchingAdditionalRate: isFetched ? false : isFetching,
|
||||
isFetchingRoomFeatures: isRoomFeaturesFetched
|
||||
? false
|
||||
: isRoomFeaturesFetching,
|
||||
isMainRoom: roomNr === 1,
|
||||
roomAvailability,
|
||||
roomNr,
|
||||
|
||||
@@ -2,8 +2,6 @@ import { z } from "zod"
|
||||
|
||||
import { Lang } from "@/constants/languages"
|
||||
|
||||
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
|
||||
|
||||
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
import { Country } from "@/types/enums/country"
|
||||
@@ -48,6 +46,9 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
|
||||
roomStayEndDate: z.string(),
|
||||
roomStayStartDate: z.string(),
|
||||
redemption: z.boolean().optional().default(false),
|
||||
roomFeatureCodesArray: z
|
||||
.array(z.array(z.nativeEnum(RoomPackageCodeEnum)).nullable())
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const selectedRoomAvailabilityInputSchema = z.object({
|
||||
@@ -167,16 +168,17 @@ export const roomFeaturesInputSchema = z.object({
|
||||
hotelId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
adultsCount: z.array(z.number()),
|
||||
childArray: z
|
||||
adults: z.number(),
|
||||
childrenInRoom: z
|
||||
.array(
|
||||
nullableArrayObjectValidator(
|
||||
z.object({
|
||||
age: z.number(),
|
||||
bed: z.nativeEnum(ChildBedMapEnum),
|
||||
})
|
||||
)
|
||||
z.object({
|
||||
age: z.number(),
|
||||
bed: z.nativeEnum(ChildBedMapEnum),
|
||||
})
|
||||
)
|
||||
.nullable(),
|
||||
roomFeatureCode: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
|
||||
.optional(),
|
||||
roomFeatureCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
|
||||
roomIndex: z.number().optional(),
|
||||
})
|
||||
|
||||
export type RoomFeaturesInput = z.input<typeof roomFeaturesInputSchema>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
hotelsAvailabilityInputSchema,
|
||||
nearbyHotelIdsInput,
|
||||
ratesInputSchema,
|
||||
type RoomFeaturesInput,
|
||||
roomFeaturesInputSchema,
|
||||
roomPackagesInputSchema,
|
||||
roomsCombinedAvailabilityInputSchema,
|
||||
@@ -471,6 +472,75 @@ export const getHotelsAvailabilityByHotelIds = async (
|
||||
)
|
||||
}
|
||||
|
||||
async function getRoomFeatures(
|
||||
{
|
||||
hotelId,
|
||||
startDate,
|
||||
endDate,
|
||||
adults,
|
||||
childrenInRoom,
|
||||
roomFeatureCodes,
|
||||
}: RoomFeaturesInput,
|
||||
token: string
|
||||
) {
|
||||
const params = {
|
||||
hotelId,
|
||||
roomStayStartDate: startDate,
|
||||
roomStayEndDate: endDate,
|
||||
adults,
|
||||
...(childrenInRoom?.length && {
|
||||
children: generateChildrenString(childrenInRoom),
|
||||
}),
|
||||
roomFeatureCode: roomFeatureCodes,
|
||||
}
|
||||
|
||||
metrics.roomFeatures.counter.add(1, params)
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.roomFeatures(hotelId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = apiResponse.text()
|
||||
console.error(
|
||||
"api.availability.roomfeature error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
metrics.roomFeatures.fail.add(1, params)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data)
|
||||
if (!validatedRoomFeaturesData.success) {
|
||||
console.error(
|
||||
"api.availability.roomfeature error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validatedRoomFeaturesData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
metrics.roomFeatures.success.add(1, params)
|
||||
|
||||
return validatedRoomFeaturesData.data
|
||||
}
|
||||
|
||||
export const hotelQueryRouter = router({
|
||||
availability: router({
|
||||
hotelsByCity: safeProtectedServiceProcedure
|
||||
@@ -576,6 +646,7 @@ export const hotelQueryRouter = router({
|
||||
redemption,
|
||||
roomStayEndDate,
|
||||
roomStayStartDate,
|
||||
roomFeatureCodesArray,
|
||||
},
|
||||
}) => {
|
||||
const apiLang = toApiLang(lang)
|
||||
@@ -643,6 +714,35 @@ export const hotelQueryRouter = router({
|
||||
}
|
||||
}
|
||||
|
||||
const roomFeatureCodes = roomFeatureCodesArray?.[idx]
|
||||
if (roomFeatureCodes?.length) {
|
||||
const roomFeaturesResponse = await getRoomFeatures(
|
||||
{
|
||||
hotelId,
|
||||
startDate: roomStayStartDate,
|
||||
endDate: roomStayEndDate,
|
||||
adults: adultCount,
|
||||
childrenInRoom: kids ?? undefined,
|
||||
roomFeatureCodes,
|
||||
},
|
||||
ctx.serviceToken
|
||||
)
|
||||
|
||||
if (roomFeaturesResponse) {
|
||||
validateAvailabilityData.data.roomConfigurations.forEach(
|
||||
(room) => {
|
||||
const features = roomFeaturesResponse.find(
|
||||
(feat) => feat.roomTypeCode === room.roomTypeCode
|
||||
)?.features
|
||||
|
||||
if (features) {
|
||||
room.features = features
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (rateCode) {
|
||||
validateAvailabilityData.data.mustBeGuaranteed =
|
||||
validateAvailabilityData.data.rateDefinitions.find(
|
||||
@@ -982,73 +1082,7 @@ export const hotelQueryRouter = router({
|
||||
roomFeatures: serviceProcedure
|
||||
.input(roomFeaturesInputSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { hotelId, startDate, endDate, adultsCount, childArray } = input
|
||||
|
||||
const responses = await Promise.allSettled(
|
||||
adultsCount.map(async (adultCount, index) => {
|
||||
const kids = childArray?.[index]
|
||||
const params = {
|
||||
hotelId,
|
||||
roomStayStartDate: startDate,
|
||||
roomStayEndDate: endDate,
|
||||
adults: adultCount,
|
||||
...(kids?.length && { children: generateChildrenString(kids) }),
|
||||
}
|
||||
|
||||
metrics.roomFeatures.counter.add(1, params)
|
||||
|
||||
const apiResponse = await api.get(
|
||||
api.endpoints.v1.Availability.roomFeatures(hotelId),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.serviceToken}`,
|
||||
},
|
||||
},
|
||||
params
|
||||
)
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
const text = apiResponse.text()
|
||||
console.error(
|
||||
"api.availability.roomfeature error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: {
|
||||
status: apiResponse.status,
|
||||
statusText: apiResponse.statusText,
|
||||
text,
|
||||
},
|
||||
})
|
||||
)
|
||||
metrics.roomFeatures.fail.add(1, params)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await apiResponse.json()
|
||||
const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data)
|
||||
if (!validatedRoomFeaturesData.success) {
|
||||
console.error(
|
||||
"api.availability.roomfeature error",
|
||||
JSON.stringify({
|
||||
query: { hotelId, params },
|
||||
error: validatedRoomFeaturesData.error,
|
||||
})
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
metrics.roomFeatures.success.add(1, params)
|
||||
|
||||
return validatedRoomFeaturesData.data
|
||||
})
|
||||
)
|
||||
|
||||
return responses.map((features) => {
|
||||
if (features.status === "fulfilled") {
|
||||
return features.value
|
||||
}
|
||||
return null
|
||||
})
|
||||
return await getRoomFeatures(input, ctx.serviceToken)
|
||||
}),
|
||||
}),
|
||||
rates: router({
|
||||
|
||||
@@ -106,3 +106,16 @@ export function isRoomPackageCode(
|
||||
code as RoomPackageCodeEnum
|
||||
)
|
||||
}
|
||||
|
||||
export function filterRoomsBySelectedPackages(
|
||||
selectedPackages: RoomPackageCodeEnum[],
|
||||
rooms: RoomConfiguration[]
|
||||
) {
|
||||
if (!selectedPackages.length) {
|
||||
return rooms
|
||||
}
|
||||
|
||||
return rooms.filter((r) =>
|
||||
selectedPackages.every((pkg) => r.features.find((f) => f.code === pkg))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { create, useStore } from "zustand"
|
||||
import { RatesContext } from "@/contexts/Rates"
|
||||
|
||||
import {
|
||||
filterRoomsBySelectedPackages,
|
||||
findProductInRoom,
|
||||
findSelectedRate,
|
||||
isRoomPackageCode,
|
||||
} from "./helpers"
|
||||
|
||||
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
|
||||
@@ -72,11 +72,18 @@ export function createRatesStore({
|
||||
for (const [idx, room] of booking.rooms.entries()) {
|
||||
if (room.rateCode && room.roomTypeCode) {
|
||||
const roomConfiguration = roomConfigurations?.[idx]
|
||||
const selectedPackages = room.packages ?? []
|
||||
|
||||
let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages(
|
||||
selectedPackages,
|
||||
roomConfiguration
|
||||
)
|
||||
|
||||
const selectedRoom = findSelectedRate(
|
||||
room.rateCode,
|
||||
room.counterRateCode,
|
||||
room.roomTypeCode,
|
||||
roomConfiguration
|
||||
rooms
|
||||
)
|
||||
|
||||
if (!selectedRoom) {
|
||||
@@ -97,6 +104,8 @@ export function createRatesStore({
|
||||
roomType: selectedRoom.roomType,
|
||||
roomTypeCode: selectedRoom.roomTypeCode,
|
||||
}
|
||||
} else {
|
||||
rateSummary[idx] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,9 +114,10 @@ export function createRatesStore({
|
||||
if (searchParams.has("modifyRateIndex")) {
|
||||
activeRoom = Number(searchParams.get("modifyRateIndex"))
|
||||
} else if (rateSummary.length === booking.rooms.length) {
|
||||
// Since all rooms has selections, all sections should be
|
||||
// closed on load
|
||||
activeRoom = -1
|
||||
// Finds the first unselected room and sets that to active
|
||||
// if no unselected rooms it will return -1 and close all rooms
|
||||
const unselectedRoomIndex = rateSummary.findIndex((rate) => !rate)
|
||||
activeRoom = unselectedRoomIndex
|
||||
}
|
||||
|
||||
return create<RatesState>()((set) => {
|
||||
@@ -126,6 +136,13 @@ export function createRatesStore({
|
||||
roomConfigurations,
|
||||
rooms: booking.rooms.map((room, idx) => {
|
||||
const roomConfiguration = roomConfigurations[idx]
|
||||
const selectedPackages = room.packages ?? []
|
||||
|
||||
let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages(
|
||||
selectedPackages,
|
||||
roomConfiguration
|
||||
)
|
||||
|
||||
const selectedRate =
|
||||
findSelectedRate(
|
||||
room.rateCode,
|
||||
@@ -143,21 +160,6 @@ export function createRatesStore({
|
||||
)
|
||||
}
|
||||
|
||||
// Since features are fetched async based on query string, we need to read from query string to apply correct filtering
|
||||
const packagesParam = searchParams.get(`room[${idx}].packages`)
|
||||
const selectedPackages = packagesParam
|
||||
? packagesParam.split(",").filter(isRoomPackageCode)
|
||||
: []
|
||||
|
||||
let rooms: RoomConfiguration[] = roomConfiguration
|
||||
if (selectedPackages.length) {
|
||||
rooms = roomConfiguration.filter((r) =>
|
||||
selectedPackages.some((pkg) =>
|
||||
r.features.find((f) => f.code === pkg)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
actions: {
|
||||
appendRegularRates(roomConfigurations) {
|
||||
@@ -204,6 +206,48 @@ export function createRatesStore({
|
||||
})
|
||||
)
|
||||
},
|
||||
addRoomFeatures(roomFeatures) {
|
||||
return set(
|
||||
produce((state: RatesState) => {
|
||||
const selectedPackages = state.rooms[idx].selectedPackages
|
||||
const rateSummaryItem = state.rateSummary[idx]
|
||||
|
||||
state.roomConfigurations[idx].forEach((room) => {
|
||||
const features = roomFeatures.find(
|
||||
(feat) => feat.roomTypeCode === room.roomTypeCode
|
||||
)?.features
|
||||
|
||||
if (features) {
|
||||
room.features = features
|
||||
|
||||
if (rateSummaryItem) {
|
||||
rateSummaryItem.packages = selectedPackages
|
||||
rateSummaryItem.features = features
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
state.rateSummary[idx] = rateSummaryItem
|
||||
|
||||
state.rooms[idx].rooms = filterRoomsBySelectedPackages(
|
||||
selectedPackages,
|
||||
state.roomConfigurations[idx]
|
||||
)
|
||||
|
||||
const selectedRate = findSelectedRate(
|
||||
room.rateCode,
|
||||
room.counterRateCode,
|
||||
room.roomTypeCode,
|
||||
state.rooms[idx].rooms
|
||||
)
|
||||
|
||||
if (!selectedRate) {
|
||||
state.rooms[idx].selectedRate = null
|
||||
state.rateSummary[idx] = null
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
closeSection() {
|
||||
return set(
|
||||
produce((state: RatesState) => {
|
||||
@@ -229,44 +273,53 @@ export function createRatesStore({
|
||||
})
|
||||
)
|
||||
},
|
||||
togglePackage(code) {
|
||||
togglePackages(selectedPackages) {
|
||||
return set(
|
||||
produce((state: RatesState) => {
|
||||
const isSelected =
|
||||
state.rooms[idx].selectedPackages.includes(code)
|
||||
const selectedPackages = isSelected
|
||||
? state.rooms[idx].selectedPackages.filter(
|
||||
(pkg) => pkg !== code
|
||||
)
|
||||
: [...state.rooms[idx].selectedPackages, code]
|
||||
state.rooms[idx].selectedPackages = selectedPackages
|
||||
const rateSummaryItem = state.rateSummary[idx]
|
||||
|
||||
const roomConfiguration = state.roomConfigurations[idx]
|
||||
if (roomConfiguration) {
|
||||
const searchParams = new URLSearchParams(state.searchParams)
|
||||
if (selectedPackages.length) {
|
||||
state.rooms[idx].rooms = roomConfiguration.filter(
|
||||
(room) =>
|
||||
selectedPackages.every((pkg) =>
|
||||
room.features.find((feat) => feat.code === pkg)
|
||||
)
|
||||
)
|
||||
searchParams.set(
|
||||
`room[${idx}].packages`,
|
||||
selectedPackages.join(",")
|
||||
)
|
||||
|
||||
if (state.rateSummary[idx]) {
|
||||
state.rateSummary[idx].packages = selectedPackages
|
||||
if (rateSummaryItem) {
|
||||
rateSummaryItem.packages = selectedPackages
|
||||
}
|
||||
} else {
|
||||
state.rooms[idx].rooms = roomConfiguration
|
||||
if (state.rateSummary[idx]) {
|
||||
state.rateSummary[idx].packages = []
|
||||
if (rateSummaryItem) {
|
||||
rateSummaryItem.packages = []
|
||||
}
|
||||
searchParams.delete(`room[${idx}].packages`)
|
||||
}
|
||||
|
||||
// If we already have the features data 'addRoomFeatures' wont run
|
||||
// so we need to do additional filtering here if thats the case
|
||||
const filteredRooms = filterRoomsBySelectedPackages(
|
||||
selectedPackages,
|
||||
state.roomConfigurations[idx]
|
||||
)
|
||||
|
||||
if (filteredRooms.length) {
|
||||
const selectedRate = findSelectedRate(
|
||||
room.rateCode,
|
||||
room.counterRateCode,
|
||||
room.roomTypeCode,
|
||||
state.rooms[idx].rooms
|
||||
)
|
||||
|
||||
if (!selectedRate) {
|
||||
state.rooms[idx].selectedRate = null
|
||||
state.rateSummary[idx] = null
|
||||
}
|
||||
}
|
||||
|
||||
state.searchParams = new ReadonlyURLSearchParams(
|
||||
searchParams
|
||||
)
|
||||
|
||||
@@ -18,20 +18,21 @@ export interface SummaryProps {
|
||||
isMember: boolean
|
||||
}
|
||||
|
||||
export interface SummaryUIProps {
|
||||
export interface EnterDetailsSummaryProps {
|
||||
booking: SelectRateSearchParams
|
||||
isMember: boolean
|
||||
totalPrice: Price
|
||||
toggleSummaryOpen: () => void
|
||||
vat: number
|
||||
}
|
||||
|
||||
export interface EnterDetailsSummaryProps extends SummaryUIProps {
|
||||
rooms: RoomState[]
|
||||
toggleSummaryOpen: () => void
|
||||
}
|
||||
|
||||
export interface SelectRateSummaryProps extends SummaryUIProps {
|
||||
rooms: {
|
||||
export interface SelectRateSummaryProps {
|
||||
booking: SelectRateSearchParams
|
||||
isMember: boolean
|
||||
totalPrice: Price
|
||||
vat: number
|
||||
rooms: Array<{
|
||||
adults: number
|
||||
childrenInRoom: Child[] | undefined
|
||||
roomType: string
|
||||
@@ -39,5 +40,7 @@ export interface SelectRateSummaryProps extends SummaryUIProps {
|
||||
roomRate: RoomRate
|
||||
rateDetails: string[] | undefined
|
||||
cancellationText: string
|
||||
}[]
|
||||
packages?: Packages
|
||||
} | null>
|
||||
toggleSummaryOpen: () => void
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { RatesState, SelectedRoom } from "@/types/stores/rates"
|
||||
|
||||
export interface RoomContextValue extends Omit<SelectedRoom, "actions"> {
|
||||
actions: Omit<SelectedRoom["actions"], "appendRegularRates">
|
||||
actions: Omit<
|
||||
SelectedRoom["actions"],
|
||||
"appendRegularRates" | "addRoomFeatures"
|
||||
>
|
||||
isActiveRoom: boolean
|
||||
isFetchingAdditionalRate: boolean
|
||||
isFetchingRoomFeatures: boolean
|
||||
isMainRoom: boolean
|
||||
roomAvailability:
|
||||
| NonNullable<RatesState["roomsAvailability"]>[number]
|
||||
|
||||
@@ -25,10 +25,16 @@ export interface AvailabilityError {
|
||||
|
||||
interface Actions {
|
||||
appendRegularRates: (roomConfigurations: RoomConfiguration[]) => void
|
||||
addRoomFeatures: (
|
||||
roomFeatures: {
|
||||
roomTypeCode: RoomConfiguration["roomTypeCode"]
|
||||
features: RoomConfiguration["features"]
|
||||
}[]
|
||||
) => void
|
||||
closeSection: () => void
|
||||
modifyRate: () => void
|
||||
selectFilter: (filter: BookingCodeFilterEnum) => void
|
||||
togglePackage: (code: RoomPackageCodeEnum) => void
|
||||
togglePackages: (codes: RoomPackageCodeEnum[]) => void
|
||||
selectRate: (rate: SelectedRate) => void
|
||||
}
|
||||
|
||||
@@ -57,7 +63,7 @@ export interface RatesState {
|
||||
packages: NonNullable<Packages>
|
||||
pathname: string
|
||||
petRoomPackage: NonNullable<Packages>[number] | undefined
|
||||
rateSummary: Rate[]
|
||||
rateSummary: Array<Rate | null>
|
||||
rooms: SelectedRoom[]
|
||||
roomCategories: Room[]
|
||||
roomConfigurations: RoomConfiguration[][]
|
||||
|
||||
Reference in New Issue
Block a user