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:
Tobias Johansson
2025-04-07 11:36:34 +00:00
parent 8d34e1c8bb
commit e6ae6ff650
31 changed files with 725 additions and 359 deletions

View File

@@ -100,6 +100,10 @@ export default function PriceDetailsTable({
const isMainRoom = idx === 0 const isMainRoom = idx === 0
const getMemberRate = isMainRoom && isMember const getMemberRate = isMainRoom && isMember
if (!room) {
return null
}
let price let price
if ( if (
getMemberRate && getMemberRate &&
@@ -130,6 +134,17 @@ export default function PriceDetailsTable({
price.localPrice.currency price.localPrice.currency
)} )}
/> />
{room.packages?.map((pkg) => (
<Row
key={pkg.code}
label={pkg.description}
value={formatPrice(
intl,
+pkg.localPrice.totalPrice,
pkg.localPrice.currency
)}
/>
))}
<Row <Row
bold bold
label={intl.formatMessage({ id: "Room charge" })} label={intl.formatMessage({ id: "Room charge" })}

View File

@@ -56,10 +56,11 @@ export default function Summary({
return null 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) => const containsBookingCodeRate = rooms.find(
isBookingCodeRate(r.roomRate) (r) => r && isBookingCodeRate(r.roomRate)
) )
const showDiscounted = containsBookingCodeRate || isMember const showDiscounted = containsBookingCodeRate || isMember
@@ -93,6 +94,10 @@ export default function Summary({
</header> </header>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
{rooms.map((room, idx) => { {rooms.map((room, idx) => {
if (!room) {
return null
}
const roomNumber = idx + 1 const roomNumber = idx + 1
const adults = room.adults const adults = room.adults
const childrenInRoom = room.childrenInRoom const childrenInRoom = room.childrenInRoom
@@ -137,6 +142,8 @@ export default function Summary({
guestsParts.push(childrenMsg) guestsParts.push(childrenMsg)
} }
const roomPackages = room.packages
return ( return (
<Fragment key={idx}> <Fragment key={idx}>
<div <div
@@ -245,6 +252,21 @@ export default function Summary({
</Body> </Body>
</div> </div>
) : null} ) : 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> </div>
<Divider color="primaryLightSubtle" /> <Divider color="primaryLightSubtle" />
</Fragment> </Fragment>

View File

@@ -27,14 +27,21 @@ export default function MobileSummary({
const scrollY = useRef(0) const scrollY = useRef(0)
const [isSummaryOpen, setIsSummaryOpen] = useState(false) const [isSummaryOpen, setIsSummaryOpen] = useState(false)
const { booking, bookingRooms, roomsAvailability, rateSummary, vat } = const {
useRatesStore((state) => ({ booking,
booking: state.booking, bookingRooms,
bookingRooms: state.booking.rooms, roomsAvailability,
roomsAvailability: state.roomsAvailability, rateSummary,
rateSummary: state.rateSummary, vat,
vat: state.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() { function toggleSummaryOpen() {
setIsSummaryOpen(!isSummaryOpen) setIsSummaryOpen(!isSummaryOpen)
@@ -71,11 +78,11 @@ export default function MobileSummary({
} }
const rooms = rateSummary.map((room, index) => const rooms = rateSummary.map((room, index) =>
mapRate(room, index, bookingRooms) room ? mapRate(room, index, bookingRooms, packages) : null
) )
const containsBookingCodeRate = rateSummary.find((r) => const containsBookingCodeRate = rateSummary.find(
isBookingCodeRate(r.product) (r) => r && isBookingCodeRate(r.product)
) )
const showDiscounted = containsBookingCodeRate || isUserLoggedIn const showDiscounted = containsBookingCodeRate || isUserLoggedIn

View File

@@ -3,8 +3,18 @@ import type {
Room, Room,
} from "@/types/components/hotelReservation/selectRate/selectRate" } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency" 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 = { const rate = {
adults: bookingRooms[index].adults, adults: bookingRooms[index].adults,
cancellationText: room.product.rateDefinition?.cancellationText ?? "", cancellationText: room.product.rateDefinition?.cancellationText ?? "",
@@ -29,6 +39,7 @@ export function mapRate(room: Rate, index: number, bookingRooms: Room[]) {
}, },
roomRate: room.product, roomRate: room.product,
roomType: room.roomType, roomType: room.roomType,
packages: roomPackages,
} }
if ("corporateCheque" in room.product) { if ("corporateCheque" in room.product) {

View File

@@ -16,16 +16,10 @@ import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import { formatPrice } from "@/utils/numberFormatting" import { formatPrice } from "@/utils/numberFormatting"
import MobileSummary from "./MobileSummary" import MobileSummary from "./MobileSummary"
import { import { getTotalPrice } from "./utils"
calculateCorporateChequePrice,
calculateRedemptionTotalPrice,
calculateTotalPrice,
calculateVoucherPrice,
} from "./utils"
import styles from "./rateSummary.module.css" import styles from "./rateSummary.module.css"
import type { Price } from "@/types/components/hotelReservation/price"
import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary" import type { RateSummaryProps } from "@/types/components/hotelReservation/selectRate/rateSummary"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateEnum } from "@/types/enums/rate" import { RateEnum } from "@/types/enums/rate"
@@ -96,9 +90,10 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}` const summaryPriceText = `${totalNights}, ${totalAdults}${totalChildren}, ${totalRooms}`
const totalRoomsRequired = bookingRooms.length const totalRoomsRequired = bookingRooms.length
const isAllRoomsSelected = rateSummary.length === totalRoomsRequired const isAllRoomsSelected =
rateSummary.filter((rate) => rate !== null).length === totalRoomsRequired
const hasMemberRates = rateSummary.some( 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 const showMemberDiscountBanner = hasMemberRates && !isUserLoggedIn
@@ -134,12 +129,15 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
const isBookingCodeRate = rateSummary.some( const isBookingCodeRate = rateSummary.some(
(rate) => (rate) =>
rate &&
"public" in rate.product && "public" in rate.product &&
rate.product.public?.rateType !== RateTypeEnum.Regular 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( const isCorporateChequeRate = rateSummary.some(
(rate) => "corporateCheque" in rate.product (rate) => rate && "corporateCheque" in rate.product
) )
const showDiscounted = const showDiscounted =
isUserLoggedIn || isUserLoggedIn ||
@@ -148,37 +146,29 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
isCorporateChequeRate isCorporateChequeRate
const mainRoomProduct = rateSummary[0] const mainRoomProduct = rateSummary[0]
let totalPriceToShow: Price const totalPriceToShow = getTotalPrice(
if ("redemption" in mainRoomProduct.product) { mainRoomProduct,
// In case of reward night (redemption) only single room booking is supported by business rules rateSummary,
totalPriceToShow = calculateRedemptionTotalPrice( isUserLoggedIn,
mainRoomProduct.product.redemption petRoomPackage
) )
} else if ("voucher" in mainRoomProduct.product) {
totalPriceToShow = calculateVoucherPrice(rateSummary) const rateProduct = rateSummary.find((rate) => rate?.product)?.product
} else if ("corporateCheque" in mainRoomProduct.product) {
totalPriceToShow = calculateCorporateChequePrice(rateSummary) if (!totalPriceToShow || !rateProduct) {
} else { return null
totalPriceToShow = calculateTotalPrice(
rateSummary,
isUserLoggedIn,
petRoomPackage
)
} }
let mainRoomCurrency = "" let mainRoomCurrency = ""
if ( if ("member" in rateProduct && rateProduct.member?.localPrice) {
"member" in mainRoomProduct.product && mainRoomCurrency = rateProduct.member.localPrice.currency
mainRoomProduct.product.member?.localPrice
) {
mainRoomCurrency = mainRoomProduct.product.member.localPrice.currency
} }
if ( if (
!mainRoomCurrency && !mainRoomCurrency &&
"public" in mainRoomProduct.product && "public" in rateProduct &&
mainRoomProduct.product.public?.localPrice rateProduct.public?.localPrice
) { ) {
mainRoomCurrency = mainRoomProduct.product.public.localPrice.currency mainRoomCurrency = rateProduct.public.localPrice.currency
} }
return ( return (
@@ -186,38 +176,56 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.content}> <div className={styles.content}>
<div className={styles.summaryText}> <div className={styles.summaryText}>
{rateSummary.map((room, index) => ( {rateSummary.map((room, index) => {
<div key={index} className={styles.roomSummary}> if (!room) {
{rateSummary.length > 1 ? ( return (
<> <div key={`unselected-${index}`}>
<Subtitle color="uiTextHighContrast"> <Subtitle color="uiTextPlaceholder">
{intl.formatMessage( {intl.formatMessage(
{ id: "Room {roomIndex}" }, { id: "Room {roomIndex}" },
{ roomIndex: index + 1 } { roomIndex: index + 1 }
)} )}
</Subtitle> </Subtitle>
<Body color="uiTextMediumContrast">{room.roomType}</Body> <Body color="uiTextPlaceholder">
<Caption color="uiTextMediumContrast"> {intl.formatMessage({ id: "Select room" })}
{getRateDetails(room.rate)}
</Caption>
</>
) : (
<>
<Subtitle color="uiTextHighContrast">
{room.roomType}
</Subtitle>
<Body color="uiTextMediumContrast">
{getRateDetails(room.rate)}
</Body> </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 */} {/* Render unselected rooms */}
{Array.from({ {Array.from({
length: totalRoomsRequired - rateSummary.length, length: totalRoomsRequired - rateSummary.length,
}).map((_, index) => ( }).map((_, index) => (
<div key={`unselected-${index}`} className={styles.roomSummary}> <div key={`unselected-${index}`}>
<Subtitle color="uiTextPlaceholder"> <Subtitle color="uiTextPlaceholder">
{intl.formatMessage( {intl.formatMessage(
{ id: "Room {roomIndex}" }, { id: "Room {roomIndex}" },
@@ -235,47 +243,45 @@ export default function RateSummary({ isUserLoggedIn }: RateSummaryProps) {
<div className={styles.promoContainer}> <div className={styles.promoContainer}>
<SignupPromoDesktop <SignupPromoDesktop
memberPrice={{ memberPrice={{
amount: rateSummary.reduce( amount: rateSummary.reduce((total, rate) => {
( if (!rate) {
total, return 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
}
}
const price = const { features, packages: roomPackages, product } = rate
product.member?.localPrice.pricePerStay ||
product.public?.localPrice.pricePerStay
if (!price) { const memberExists = "member" in product && product.member
const publicExists = "public" in product && product.public
if (!memberExists) {
if (!publicExists) {
return total return total
} }
}
const hasSelectedPetRoom = roomPackages.includes( const price =
RoomPackageCodeEnum.PET_ROOM product.member?.localPrice.pricePerStay ||
) product.public?.localPrice.pricePerStay
if (!hasSelectedPetRoom) {
return total + price if (!price) {
} return total
const isPetRoom = features.find( }
(feature) =>
feature.code === RoomPackageCodeEnum.PET_ROOM const hasSelectedPetRoom = roomPackages.includes(
) RoomPackageCodeEnum.PET_ROOM
const petRoomPrice = )
isPetRoom && petRoomPackage if (!hasSelectedPetRoom) {
? Number(petRoomPackage.localPrice.totalPrice) return total + price
: 0 }
return total + price + petRoomPrice const isPetRoom = features.find(
}, (feature) =>
0 feature.code === RoomPackageCodeEnum.PET_ROOM
), )
const petRoomPrice =
isPetRoom && petRoomPackage
? Number(petRoomPackage.localPrice.totalPrice)
: 0
return total + price + petRoomPrice
}, 0),
currency: mainRoomCurrency, currency: mainRoomCurrency,
}} }}
/> />

View File

@@ -5,6 +5,7 @@ import {
} from "@/types/components/hotelReservation/selectRate/roomFilter" } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate" import type { Rate } from "@/types/components/hotelReservation/selectRate/selectRate"
import { CurrencyEnum } from "@/types/enums/currency" import { CurrencyEnum } from "@/types/enums/currency"
import type { Packages } from "@/types/requests/packages"
import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability" import type { RedemptionProduct } from "@/types/trpc/routers/hotel/roomAvailability"
export function calculateTotalPrice( 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)
}

View File

@@ -20,9 +20,10 @@ import { RateEnum } from "@/types/enums/rate"
export default function SelectedRoomPanel() { export default function SelectedRoomPanel() {
const intl = useIntl() const intl = useIntl()
const { isUserLoggedIn, roomCategories } = useRatesStore((state) => ({ const { isUserLoggedIn, roomCategories, rooms } = useRatesStore((state) => ({
isUserLoggedIn: state.isUserLoggedIn, isUserLoggedIn: state.isUserLoggedIn,
roomCategories: state.roomCategories, roomCategories: state.roomCategories,
rooms: state.rooms,
})) }))
const { const {
actions: { modifyRate }, actions: { modifyRate },
@@ -89,6 +90,10 @@ export default function SelectedRoomPanel() {
return null return null
} }
const showModifyButton =
isMainRoom ||
(!isMainRoom && rooms.slice(0, roomNr).every((room) => room.selectedRate))
return ( return (
<div className={styles.selectedRoomPanel}> <div className={styles.selectedRoomPanel}>
<div className={styles.content}> <div className={styles.content}>
@@ -118,14 +123,16 @@ export default function SelectedRoomPanel() {
width={600} width={600}
/> />
) : null} ) : null}
<div className={styles.modifyButtonContainer}> {showModifyButton && (
<Button clean onClick={modifyRate}> <div className={styles.modifyButtonContainer}>
<Chip size="small" variant="uiTextHighContrast"> <Button clean onClick={modifyRate}>
<MaterialIcon icon="edit_square" /> <Chip size="small" variant="uiTextHighContrast">
{intl.formatMessage({ id: "Modify" })} <MaterialIcon icon="edit_square" />
</Chip> {intl.formatMessage({ id: "Modify" })}
</Button> </Chip>
</div> </Button>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -32,6 +32,15 @@
background-color: var(--UI-Input-Controls-Fill-Selected); 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) { @media screen and (max-width: 767px) {
.checkboxWrapper:hover { .checkboxWrapper:hover {
background-color: transparent; background-color: transparent;

View File

@@ -14,6 +14,7 @@ interface CheckboxProps {
value: string value: string
isSelected: boolean isSelected: boolean
iconName: MaterialSymbolProps["icon"] iconName: MaterialSymbolProps["icon"]
isDisabled: boolean
onChange: (value: string) => void onChange: (value: string) => void
} }
@@ -22,12 +23,14 @@ export default function Checkbox({
name, name,
value, value,
iconName, iconName,
isDisabled,
onChange, onChange,
}: CheckboxProps) { }: CheckboxProps) {
return ( return (
<AriaCheckbox <AriaCheckbox
className={styles.checkboxWrapper} className={styles.checkboxWrapper}
isSelected={isSelected} isSelected={isSelected}
isDisabled={isDisabled}
onChange={() => onChange(value)} onChange={() => onChange(value)}
> >
{({ isSelected }) => ( {({ isSelected }) => (
@@ -35,7 +38,10 @@ export default function Checkbox({
<span className={styles.checkbox}> <span className={styles.checkbox}>
{isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />} {isSelected && <MaterialIcon icon="check" color="Icon/Inverted" />}
</span> </span>
<Typography variant="Body/Paragraph/mdRegular"> <Typography
variant="Body/Paragraph/mdRegular"
className={styles.text}
>
<span>{name}</span> <span>{name}</span>
</Typography> </Typography>
{iconName ? ( {iconName ? (

View File

@@ -1,7 +1,15 @@
"use client" "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 { useIntl } from "react-intl"
import { Button } from "@scandic-hotels/design-system/Button"
import { ChipButton } from "@scandic-hotels/design-system/ChipButton" import { ChipButton } from "@scandic-hotels/design-system/ChipButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
@@ -16,20 +24,46 @@ import { getIconNameByPackageCode } from "./utils"
import styles from "./roomPackageFilter.module.css" import styles from "./roomPackageFilter.module.css"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
type FormValues = {
selectedPackages: RoomPackageCodeEnum[]
}
export default function RoomPackageFilter() { export default function RoomPackageFilter() {
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const packageOptions = useRatesStore((state) => state.packageOptions) const packageOptions = useRatesStore((state) => state.packageOptions)
const { const {
actions: { togglePackage }, actions: { togglePackages },
selectedPackages, selectedPackages,
} = useRoomContext() } = 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 ( return (
<div className={styles.roomPackageFilter}> <div className={styles.roomPackageFilter}>
{selectedPackages.map((pkg) => ( {selectedPackages.map((pkg) => (
<Button <AriaButton
key={pkg} key={pkg}
onPress={() => togglePackage(pkg)} onPress={() => {
const packages = selectedPackages.filter((s) => s !== pkg)
togglePackages(packages)
}}
className={styles.activeFilterButton} className={styles.activeFilterButton}
> >
<MaterialIcon <MaterialIcon
@@ -42,9 +76,9 @@ export default function RoomPackageFilter() {
size={16} size={16}
color="Icon/Interactive/Default" color="Icon/Interactive/Default"
/> />
</Button> </AriaButton>
))} ))}
<DialogTrigger> <DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<ChipButton variant="Outlined"> <ChipButton variant="Outlined">
{intl.formatMessage({ id: "Room preferences" })} {intl.formatMessage({ id: "Room preferences" })}
<MaterialIcon <MaterialIcon
@@ -55,39 +89,105 @@ export default function RoomPackageFilter() {
</ChipButton> </ChipButton>
<Popover placement="bottom end"> <Popover placement="bottom end">
<Dialog className={styles.dialog}> <Dialog className={styles.dialog}>
<div className={styles.filters}> <form onSubmit={handleSubmit(onSubmit)}>
{packageOptions.map((option) => ( <Controller
<Checkbox control={control}
key={option.code} name="selectedPackages"
name={option.description} render={({ field }) => (
value={option.code} <div>
iconName={getIconNameByPackageCode(option.code)} {packageOptions.map((option) => {
isSelected={selectedPackages.includes(option.code)} const isPetRoom =
onChange={() => togglePackage(option.code)} option.code === RoomPackageCodeEnum.PET_ROOM
/>
))} const isAllergyRoom =
</div> option.code === RoomPackageCodeEnum.ALLERGY_ROOM
<div className={styles.footer}>
<Divider color="borderDividerSubtle" /> const hasPetRoom = field.value.includes(
<Typography variant="Body/Supporting text (caption)/smRegular"> RoomPackageCodeEnum.PET_ROOM
<p className={styles.additionalInformation}> )
{intl.formatMessage(
{ const hasAllergyRoom = field.value.includes(
id: "<b>200 SEK/night</b> Important information on pricing and features of pet-friendly rooms.", RoomPackageCodeEnum.ALLERGY_ROOM
}, )
{
b: (str) => ( const isDisabled =
<Typography variant="Body/Supporting text (caption)/smBold"> (isPetRoom && hasAllergyRoom) ||
<span className={styles.additionalInformationPrice}> (isAllergyRoom && hasPetRoom)
{str}
</span> return (
</Typography> <>
), <Checkbox
} key={option.code}
)} name={option.description}
</p> value={option.code}
</Typography> iconName={getIconNameByPackageCode(option.code)}
</div> 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> </Dialog>
</Popover> </Popover>
</DialogTrigger> </DialogTrigger>

View File

@@ -23,6 +23,7 @@
.additionalInformation { .additionalInformation {
color: var(--Text-Tertiary); color: var(--Text-Tertiary);
padding: var(--Space-x1) var(--Space-x15);
} }
.additionalInformationPrice { .additionalInformationPrice {
@@ -40,3 +41,9 @@
border-width: 0; border-width: 0;
cursor: pointer; cursor: pointer;
} }
.buttonContainer {
display: flex;
justify-content: space-between;
align-items: center;
}

View File

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

View File

@@ -2,12 +2,18 @@
import { useRoomContext } from "@/contexts/SelectRate/Room" import { useRoomContext } from "@/contexts/SelectRate/Room"
import RoomListItem from "./RoomListItem" import RoomListItem from "./RoomListItem"
import { RoomsListSkeleton } from "./RoomsListSkeleton"
import ScrollToList from "./ScrollToList" import ScrollToList from "./ScrollToList"
import styles from "./rooms.module.css" import styles from "./rooms.module.css"
export default function RoomsList() { export default function RoomsList() {
const { rooms } = useRoomContext() const { rooms, isFetchingRoomFeatures } = useRoomContext()
if (isFetchingRoomFeatures) {
return <RoomsListSkeleton />
}
return ( return (
<> <>
<ScrollToList /> <ScrollToList />

View File

@@ -3,6 +3,7 @@
display: grid; display: grid;
gap: var(--Spacing-x2); gap: var(--Spacing-x2);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
overflow: hidden;
} }
.roomList > li { .roomList > li {

View File

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

View File

@@ -1,5 +1,5 @@
"use client" "use client"
import { REDEMPTION } from "@/constants/booking"
import { dt } from "@/lib/dt" import { dt } from "@/lib/dt"
import useLang from "@/hooks/useLang" import useLang from "@/hooks/useLang"
@@ -25,9 +25,6 @@ export function RoomsContainer({
const fromDateString = dt(fromDate).format("YYYY-MM-DD") const fromDateString = dt(fromDate).format("YYYY-MM-DD")
const toDateString = dt(toDate).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 } = const { data: roomsAvailability, isPending: isLoadingAvailability } =
useRoomsAvailability( useRoomsAvailability(
@@ -37,8 +34,7 @@ export function RoomsContainer({
toDateString, toDateString,
lang, lang,
childArray, childArray,
booking.bookingCode, booking
redemption
) )
const { data: packages, isPending: isLoadingPackages } = useHotelPackages( const { data: packages, isPending: isLoadingPackages } = useHotelPackages(

View File

@@ -1,10 +1,6 @@
import { useSearchParams } from "next/navigation" import { REDEMPTION } from "@/constants/booking"
import { useEffect, useMemo, useState } from "react"
import { trpc } from "@/lib/trpc/client" import { trpc } from "@/lib/trpc/client"
import { convertSearchParamsToObj, searchParamsToRecord } from "@/utils/url"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate" import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
@@ -17,79 +13,30 @@ export function useRoomsAvailability(
toDateString: string, toDateString: string,
lang: Lang, lang: Lang,
childArray: ChildrenInRoom, childArray: ChildrenInRoom,
bookingCode?: string, booking: SelectRateSearchParams
redemption?: boolean
) { ) {
const searchParams = useSearchParams() const redemption = booking.searchType
const searchParamsObj = convertSearchParamsToObj<SelectRateSearchParams>( ? booking.searchType === REDEMPTION
searchParamsToRecord(searchParams) : undefined
const roomFeatureCodesArray = booking.rooms.map(
(room) => room.packages ?? null
) )
const hasPackagesParam = searchParamsObj.rooms.some((room) => room.packages) const roomsAvailability =
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 } =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({ trpc.hotel.availability.roomsCombinedAvailability.useQuery({
adultsCount, adultsCount,
bookingCode,
childArray, childArray,
hotelId, hotelId,
lang, lang,
redemption, redemption,
roomStayEndDate: toDateString, roomStayEndDate: toDateString,
roomStayStartDate: fromDateString, roomStayStartDate: fromDateString,
bookingCode: booking.bookingCode,
roomFeatureCodesArray,
}) })
const combinedData = useMemo(() => { return roomsAvailability
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,
}
} }
export function useHotelPackages( export function useHotelPackages(

View File

@@ -186,6 +186,7 @@
"City pulse": "Byens puls", "City pulse": "Byens puls",
"City/State": "By/Stat", "City/State": "By/Stat",
"Classroom": "Classroom", "Classroom": "Classroom",
"Clear": "Ryd",
"Clear all filters": "Ryd alle filtre", "Clear all filters": "Ryd alle filtre",
"Clear searches": "Ryd søgninger", "Clear searches": "Ryd søgninger",
"Click here to log in": "Klik her for at logge ind", "Click here to log in": "Klik her for at logge ind",

View File

@@ -187,6 +187,7 @@
"City pulse": "Stadtpuls", "City pulse": "Stadtpuls",
"City/State": "Stadt/Zustand", "City/State": "Stadt/Zustand",
"Classroom": "Classroom", "Classroom": "Classroom",
"Clear": "Löschen",
"Clear all filters": "Alle Filter löschen", "Clear all filters": "Alle Filter löschen",
"Clear searches": "Suche löschen", "Clear searches": "Suche löschen",
"Click here to log in": "Klicken Sie hier, um sich einzuloggen", "Click here to log in": "Klicken Sie hier, um sich einzuloggen",

View File

@@ -187,6 +187,7 @@
"City pulse": "City pulse", "City pulse": "City pulse",
"City/State": "City/State", "City/State": "City/State",
"Classroom": "Classroom", "Classroom": "Classroom",
"Clear": "Clear",
"Clear all filters": "Clear all filters", "Clear all filters": "Clear all filters",
"Clear searches": "Clear searches", "Clear searches": "Clear searches",
"Click here to log in": "Click here to log in", "Click here to log in": "Click here to log in",

View File

@@ -185,6 +185,7 @@
"City pulse": "Kaupungin syke", "City pulse": "Kaupungin syke",
"City/State": "Kaupunki/Osavaltio", "City/State": "Kaupunki/Osavaltio",
"Classroom": "Classroom", "Classroom": "Classroom",
"Clear": "Tyhjennä",
"Clear all filters": "Tyhjennä kaikki suodattimet", "Clear all filters": "Tyhjennä kaikki suodattimet",
"Clear searches": "Tyhjennä haut", "Clear searches": "Tyhjennä haut",
"Click here to log in": "Napsauta tästä kirjautuaksesi sisään", "Click here to log in": "Napsauta tästä kirjautuaksesi sisään",

View File

@@ -185,6 +185,7 @@
"City pulse": "Byens puls", "City pulse": "Byens puls",
"City/State": "By/Stat", "City/State": "By/Stat",
"Classroom": "Classroom", "Classroom": "Classroom",
"Clear": "Rens",
"Clear all filters": "Fjern alle filtre", "Clear all filters": "Fjern alle filtre",
"Clear searches": "Tømme søk", "Clear searches": "Tømme søk",
"Click here to log in": "Klikk her for å logge inn", "Click here to log in": "Klikk her for å logge inn",

View File

@@ -185,6 +185,7 @@
"City pulse": "Stadspuls", "City pulse": "Stadspuls",
"City/State": "Ort", "City/State": "Ort",
"Classroom": "Klassrum", "Classroom": "Klassrum",
"Clear": "Rensa",
"Clear all filters": "Rensa alla filter", "Clear all filters": "Rensa alla filter",
"Clear searches": "Rensa tidigare sökningar", "Clear searches": "Rensa tidigare sökningar",
"Click here to log in": "Klicka här för att logga in", "Click here to log in": "Klicka här för att logga in",

View File

@@ -24,14 +24,16 @@ export default function RoomProvider({
roomAvailability, roomAvailability,
searchParams, searchParams,
selectedFilter, selectedFilter,
selectedPackages,
} = useRatesStore((state) => ({ } = useRatesStore((state) => ({
activeRoom: state.activeRoom, activeRoom: state.activeRoom,
booking: state.booking, booking: state.booking,
roomAvailability: state.roomsAvailability?.[idx], roomAvailability: state.roomsAvailability?.[idx],
searchParams: state.searchParams, searchParams: state.searchParams,
selectedFilter: state.rooms[idx].selectedFilter, 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 roomNr = idx + 1
const redemptionSearch = searchParams.has("searchType") const redemptionSearch = searchParams.has("searchType")
@@ -91,6 +93,40 @@ export default function RoomProvider({
} }
}, [appendRegularRates, data, enabled, isFetched, isFetching]) }, [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 ( return (
<RoomContext.Provider <RoomContext.Provider
value={{ value={{
@@ -98,6 +134,9 @@ export default function RoomProvider({
actions, actions,
isActiveRoom: activeRoom === idx, isActiveRoom: activeRoom === idx,
isFetchingAdditionalRate: isFetched ? false : isFetching, isFetchingAdditionalRate: isFetched ? false : isFetching,
isFetchingRoomFeatures: isRoomFeaturesFetched
? false
: isRoomFeaturesFetching,
isMainRoom: roomNr === 1, isMainRoom: roomNr === 1,
roomAvailability, roomAvailability,
roomNr, roomNr,

View File

@@ -2,8 +2,6 @@ import { z } from "zod"
import { Lang } from "@/constants/languages" import { Lang } from "@/constants/languages"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums" import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Country } from "@/types/enums/country" import { Country } from "@/types/enums/country"
@@ -48,6 +46,9 @@ export const roomsCombinedAvailabilityInputSchema = z.object({
roomStayEndDate: z.string(), roomStayEndDate: z.string(),
roomStayStartDate: z.string(), roomStayStartDate: z.string(),
redemption: z.boolean().optional().default(false), redemption: z.boolean().optional().default(false),
roomFeatureCodesArray: z
.array(z.array(z.nativeEnum(RoomPackageCodeEnum)).nullable())
.optional(),
}) })
export const selectedRoomAvailabilityInputSchema = z.object({ export const selectedRoomAvailabilityInputSchema = z.object({
@@ -167,16 +168,17 @@ export const roomFeaturesInputSchema = z.object({
hotelId: z.string(), hotelId: z.string(),
startDate: z.string(), startDate: z.string(),
endDate: z.string(), endDate: z.string(),
adultsCount: z.array(z.number()), adults: z.number(),
childArray: z childrenInRoom: z
.array( .array(
nullableArrayObjectValidator( z.object({
z.object({ age: z.number(),
age: z.number(), bed: z.nativeEnum(ChildBedMapEnum),
bed: z.nativeEnum(ChildBedMapEnum), })
})
)
) )
.nullable(), .optional(),
roomFeatureCode: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(), roomFeatureCodes: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
roomIndex: z.number().optional(),
}) })
export type RoomFeaturesInput = z.input<typeof roomFeaturesInputSchema>

View File

@@ -37,6 +37,7 @@ import {
hotelsAvailabilityInputSchema, hotelsAvailabilityInputSchema,
nearbyHotelIdsInput, nearbyHotelIdsInput,
ratesInputSchema, ratesInputSchema,
type RoomFeaturesInput,
roomFeaturesInputSchema, roomFeaturesInputSchema,
roomPackagesInputSchema, roomPackagesInputSchema,
roomsCombinedAvailabilityInputSchema, 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({ export const hotelQueryRouter = router({
availability: router({ availability: router({
hotelsByCity: safeProtectedServiceProcedure hotelsByCity: safeProtectedServiceProcedure
@@ -576,6 +646,7 @@ export const hotelQueryRouter = router({
redemption, redemption,
roomStayEndDate, roomStayEndDate,
roomStayStartDate, roomStayStartDate,
roomFeatureCodesArray,
}, },
}) => { }) => {
const apiLang = toApiLang(lang) 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) { if (rateCode) {
validateAvailabilityData.data.mustBeGuaranteed = validateAvailabilityData.data.mustBeGuaranteed =
validateAvailabilityData.data.rateDefinitions.find( validateAvailabilityData.data.rateDefinitions.find(
@@ -982,73 +1082,7 @@ export const hotelQueryRouter = router({
roomFeatures: serviceProcedure roomFeatures: serviceProcedure
.input(roomFeaturesInputSchema) .input(roomFeaturesInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { hotelId, startDate, endDate, adultsCount, childArray } = input return await getRoomFeatures(input, ctx.serviceToken)
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
})
}), }),
}), }),
rates: router({ rates: router({

View File

@@ -106,3 +106,16 @@ export function isRoomPackageCode(
code as RoomPackageCodeEnum 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))
)
}

View File

@@ -6,9 +6,9 @@ import { create, useStore } from "zustand"
import { RatesContext } from "@/contexts/Rates" import { RatesContext } from "@/contexts/Rates"
import { import {
filterRoomsBySelectedPackages,
findProductInRoom, findProductInRoom,
findSelectedRate, findSelectedRate,
isRoomPackageCode,
} from "./helpers" } from "./helpers"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter" import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
@@ -72,11 +72,18 @@ export function createRatesStore({
for (const [idx, room] of booking.rooms.entries()) { for (const [idx, room] of booking.rooms.entries()) {
if (room.rateCode && room.roomTypeCode) { if (room.rateCode && room.roomTypeCode) {
const roomConfiguration = roomConfigurations?.[idx] const roomConfiguration = roomConfigurations?.[idx]
const selectedPackages = room.packages ?? []
let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages(
selectedPackages,
roomConfiguration
)
const selectedRoom = findSelectedRate( const selectedRoom = findSelectedRate(
room.rateCode, room.rateCode,
room.counterRateCode, room.counterRateCode,
room.roomTypeCode, room.roomTypeCode,
roomConfiguration rooms
) )
if (!selectedRoom) { if (!selectedRoom) {
@@ -97,6 +104,8 @@ export function createRatesStore({
roomType: selectedRoom.roomType, roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode, roomTypeCode: selectedRoom.roomTypeCode,
} }
} else {
rateSummary[idx] = null
} }
} }
} }
@@ -105,9 +114,10 @@ export function createRatesStore({
if (searchParams.has("modifyRateIndex")) { if (searchParams.has("modifyRateIndex")) {
activeRoom = Number(searchParams.get("modifyRateIndex")) activeRoom = Number(searchParams.get("modifyRateIndex"))
} else if (rateSummary.length === booking.rooms.length) { } else if (rateSummary.length === booking.rooms.length) {
// Since all rooms has selections, all sections should be // Finds the first unselected room and sets that to active
// closed on load // if no unselected rooms it will return -1 and close all rooms
activeRoom = -1 const unselectedRoomIndex = rateSummary.findIndex((rate) => !rate)
activeRoom = unselectedRoomIndex
} }
return create<RatesState>()((set) => { return create<RatesState>()((set) => {
@@ -126,6 +136,13 @@ export function createRatesStore({
roomConfigurations, roomConfigurations,
rooms: booking.rooms.map((room, idx) => { rooms: booking.rooms.map((room, idx) => {
const roomConfiguration = roomConfigurations[idx] const roomConfiguration = roomConfigurations[idx]
const selectedPackages = room.packages ?? []
let rooms: RoomConfiguration[] = filterRoomsBySelectedPackages(
selectedPackages,
roomConfiguration
)
const selectedRate = const selectedRate =
findSelectedRate( findSelectedRate(
room.rateCode, 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 { return {
actions: { actions: {
appendRegularRates(roomConfigurations) { 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() { closeSection() {
return set( return set(
produce((state: RatesState) => { produce((state: RatesState) => {
@@ -229,44 +273,53 @@ export function createRatesStore({
}) })
) )
}, },
togglePackage(code) { togglePackages(selectedPackages) {
return set( return set(
produce((state: RatesState) => { 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 state.rooms[idx].selectedPackages = selectedPackages
const rateSummaryItem = state.rateSummary[idx]
const roomConfiguration = state.roomConfigurations[idx] const roomConfiguration = state.roomConfigurations[idx]
if (roomConfiguration) { if (roomConfiguration) {
const searchParams = new URLSearchParams(state.searchParams) const searchParams = new URLSearchParams(state.searchParams)
if (selectedPackages.length) { if (selectedPackages.length) {
state.rooms[idx].rooms = roomConfiguration.filter(
(room) =>
selectedPackages.every((pkg) =>
room.features.find((feat) => feat.code === pkg)
)
)
searchParams.set( searchParams.set(
`room[${idx}].packages`, `room[${idx}].packages`,
selectedPackages.join(",") selectedPackages.join(",")
) )
if (state.rateSummary[idx]) { if (rateSummaryItem) {
state.rateSummary[idx].packages = selectedPackages rateSummaryItem.packages = selectedPackages
} }
} else { } else {
state.rooms[idx].rooms = roomConfiguration state.rooms[idx].rooms = roomConfiguration
if (state.rateSummary[idx]) { if (rateSummaryItem) {
state.rateSummary[idx].packages = [] rateSummaryItem.packages = []
} }
searchParams.delete(`room[${idx}].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( state.searchParams = new ReadonlyURLSearchParams(
searchParams searchParams
) )

View File

@@ -18,20 +18,21 @@ export interface SummaryProps {
isMember: boolean isMember: boolean
} }
export interface SummaryUIProps { export interface EnterDetailsSummaryProps {
booking: SelectRateSearchParams booking: SelectRateSearchParams
isMember: boolean isMember: boolean
totalPrice: Price totalPrice: Price
toggleSummaryOpen: () => void
vat: number vat: number
}
export interface EnterDetailsSummaryProps extends SummaryUIProps {
rooms: RoomState[] rooms: RoomState[]
toggleSummaryOpen: () => void
} }
export interface SelectRateSummaryProps extends SummaryUIProps { export interface SelectRateSummaryProps {
rooms: { booking: SelectRateSearchParams
isMember: boolean
totalPrice: Price
vat: number
rooms: Array<{
adults: number adults: number
childrenInRoom: Child[] | undefined childrenInRoom: Child[] | undefined
roomType: string roomType: string
@@ -39,5 +40,7 @@ export interface SelectRateSummaryProps extends SummaryUIProps {
roomRate: RoomRate roomRate: RoomRate
rateDetails: string[] | undefined rateDetails: string[] | undefined
cancellationText: string cancellationText: string
}[] packages?: Packages
} | null>
toggleSummaryOpen: () => void
} }

View File

@@ -1,9 +1,13 @@
import type { RatesState, SelectedRoom } from "@/types/stores/rates" import type { RatesState, SelectedRoom } from "@/types/stores/rates"
export interface RoomContextValue extends Omit<SelectedRoom, "actions"> { export interface RoomContextValue extends Omit<SelectedRoom, "actions"> {
actions: Omit<SelectedRoom["actions"], "appendRegularRates"> actions: Omit<
SelectedRoom["actions"],
"appendRegularRates" | "addRoomFeatures"
>
isActiveRoom: boolean isActiveRoom: boolean
isFetchingAdditionalRate: boolean isFetchingAdditionalRate: boolean
isFetchingRoomFeatures: boolean
isMainRoom: boolean isMainRoom: boolean
roomAvailability: roomAvailability:
| NonNullable<RatesState["roomsAvailability"]>[number] | NonNullable<RatesState["roomsAvailability"]>[number]

View File

@@ -25,10 +25,16 @@ export interface AvailabilityError {
interface Actions { interface Actions {
appendRegularRates: (roomConfigurations: RoomConfiguration[]) => void appendRegularRates: (roomConfigurations: RoomConfiguration[]) => void
addRoomFeatures: (
roomFeatures: {
roomTypeCode: RoomConfiguration["roomTypeCode"]
features: RoomConfiguration["features"]
}[]
) => void
closeSection: () => void closeSection: () => void
modifyRate: () => void modifyRate: () => void
selectFilter: (filter: BookingCodeFilterEnum) => void selectFilter: (filter: BookingCodeFilterEnum) => void
togglePackage: (code: RoomPackageCodeEnum) => void togglePackages: (codes: RoomPackageCodeEnum[]) => void
selectRate: (rate: SelectedRate) => void selectRate: (rate: SelectedRate) => void
} }
@@ -57,7 +63,7 @@ export interface RatesState {
packages: NonNullable<Packages> packages: NonNullable<Packages>
pathname: string pathname: string
petRoomPackage: NonNullable<Packages>[number] | undefined petRoomPackage: NonNullable<Packages>[number] | undefined
rateSummary: Rate[] rateSummary: Array<Rate | null>
rooms: SelectedRoom[] rooms: SelectedRoom[]
roomCategories: Room[] roomCategories: Room[]
roomConfigurations: RoomConfiguration[][] roomConfigurations: RoomConfiguration[][]